From eb50b215eac1cd5afe84b9ddd203cf9ef218dd00 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Wed, 16 Sep 2026 19:47:20 +0000 Subject: [PATCH 01/69] config: give Endpoint its own file and a parser Endpoint validation lived in internal/tui as endpointFromInput, so the rule for what counts as a reachable Aperture location was only enforced on the two screens that happened to call it. The inline URL override added next runs outside those screens and needs the same rule, and copying it would have left two definitions to drift apart. Moves Endpoint, Bridge and DefaultLocation into endpoint.go with ParseEndpoint, leaving settings.go holding the persisted Settings and its file IO. This is the first step of the DDD split: config becomes the domain package and its store moves out later. Revisit if the store split lands first, which would make endpoint.go the seed of a separate domain package instead. --- internal/config/endpoint.go | 44 ++++++++++++++++++++++++++++++++ internal/config/endpoint_test.go | 44 ++++++++++++++++++++++++++++++++ internal/config/settings.go | 21 --------------- 3 files changed, 88 insertions(+), 21 deletions(-) create mode 100644 internal/config/endpoint.go create mode 100644 internal/config/endpoint_test.go diff --git a/internal/config/endpoint.go b/internal/config/endpoint.go new file mode 100644 index 0000000..66a604a --- /dev/null +++ b/internal/config/endpoint.go @@ -0,0 +1,44 @@ +package config + +import ( + "fmt" + "net/url" + "strings" +) + +// DefaultLocation is the well-known Aperture location. It is the first +// candidate every connection attempt tries, direct or bridged, and the +// fallback 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"` +} + +// ParseEndpoint turns user input into an Endpoint reached over bridgeID, which +// is empty for a direct connection. A bare host is assumed to be http, since +// Aperture is reached over the tailnet. +func ParseEndpoint(value, bridgeID string) (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 Endpoint{}, fmt.Errorf("endpoint URL must be an absolute http or https URL") + } + return Endpoint{URL: strings.TrimRight(value, "/"), BridgeID: bridgeID}, nil +} + +func sameEndpoint(a, b Endpoint) bool { + return a.URL == b.URL && a.BridgeID == b.BridgeID +} diff --git a/internal/config/endpoint_test.go b/internal/config/endpoint_test.go new file mode 100644 index 0000000..83d6dc7 --- /dev/null +++ b/internal/config/endpoint_test.go @@ -0,0 +1,44 @@ +package config + +import "testing" + +func TestParseEndpoint(t *testing.T) { + tests := []struct { + name string + in string + bridgeID string + want Endpoint + wantErr bool + }{ + {name: "bare host assumes http", in: "ai", want: Endpoint{URL: "http://ai"}}, + {name: "trailing slash trimmed", in: "http://ai/", want: Endpoint{URL: "http://ai"}}, + {name: "surrounding space trimmed", in: " http://ai ", want: Endpoint{URL: "http://ai"}}, + {name: "https preserved", in: "https://aperture.example.ts.net", want: Endpoint{URL: "https://aperture.example.ts.net"}}, + { + name: "bridge recorded", + in: "aperture", + bridgeID: "bridge-abcdef", + want: Endpoint{URL: "http://aperture", BridgeID: "bridge-abcdef"}, + }, + {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 := ParseEndpoint(tt.in, tt.bridgeID) + if tt.wantErr { + if err == nil { + t.Fatalf("ParseEndpoint(%q) = %+v, want error", tt.in, got) + } + return + } + if err != nil { + t.Fatalf("ParseEndpoint(%q) error = %v", tt.in, err) + } + if got != tt.want { + t.Errorf("ParseEndpoint(%q) = %+v, want %+v", tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/config/settings.go b/internal/config/settings.go index bdbfb7c..93acb33 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -16,23 +16,6 @@ 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. type Settings struct { // Bridges is the set of embedded tsnet nodes the user has configured. @@ -117,10 +100,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 From a123e55d519a87cd195d76ab79506525eee3dbbd Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Wed, 16 Sep 2026 19:47:47 +0000 Subject: [PATCH 02/69] tui: discover the bridge endpoint instead of demanding a URL Adding a bridge endpoint asked for a URL before it would connect, while a direct connection just tries the well-known location and only asks if that fails. Nobody adding their first bridge knows the hostname yet, so the flow stopped on a question the user had opened the bridge to answer. A bridge now probes DefaultLocation through the new node straight away. The guess is not free: someone who does know their hostname would be stuck watching it time out, so the connect screen carries a live URL field that cancels the running attempt and retargets it, and Esc abandons the attempt outright. Both go through the same cancellation, which is also the only exit from a bridge waiting on a login that will never come. Cancelling or retargeting removes the guessed endpoint it wrote to settings, so an abandoned attempt leaves nothing behind, and attempts carry an id so a cancelled probe's late result cannot take the screen back. The in-flight state moved off the model into an activation type rather than becoming six more model fields. Pasted input was dropped here: a multi-rune paste failed the old len(s)==1 check, and matching on KeyMsg.String() instead would have typed "up" into the field when someone pressed Up. textField keys off the message type. --- internal/tui/menus.go | 57 +++--- internal/tui/tui.go | 378 ++++++++++++++++++++++++++++++++------- internal/tui/tui_test.go | 262 +++++++++++++++++++++++++-- 3 files changed, 580 insertions(+), 117 deletions(-) diff --git a/internal/tui/menus.go b/internal/tui/menus.go index 25c759c..19ac28a 100644 --- a/internal/tui/menus.go +++ b/internal/tui/menus.go @@ -2,7 +2,6 @@ package tui import ( "fmt" - "net/url" "os" "strings" @@ -135,18 +134,6 @@ 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 -} - func simpleErrorCmd(err error) tea.Cmd { return func() tea.Msg { return menu.SimpleDoneMsg{Err: err} } } @@ -204,7 +191,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} } } @@ -320,6 +307,10 @@ func (m *model) setupGuideMenu() *menu.Menu { } 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: @@ -348,8 +339,8 @@ 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) + m.promptForInput("Edit Endpoint:", "URL", target.URL, func(v string) tea.Cmd { + next, err := config.ParseEndpoint(v, target.BridgeID) if err != nil { return simpleErrorCmd(err) } @@ -427,8 +418,8 @@ 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 { + ep, err := config.ParseEndpoint(v, "") if err != nil { return simpleErrorCmd(err) } @@ -462,23 +453,19 @@ func (m *model) endpointBridgeMenu() *menu.Menu { items = append(items, menu.MenuItem{ Label: p.Name, Description: p.ID, - Action: func() menu.Result { - m.promptForBridgeEndpoint(p) - return menu.Result{} - }, + 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,21 +473,23 @@ 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) - } +// 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 a +// different URL while the guess runs, so knowing it costs no waiting. +func (m *model) connectBridgeCmd(bridge config.Bridge) tea.Cmd { + ep := config.Endpoint{URL: config.DefaultLocation, BridgeID: bridge.ID} + ephemeral := !m.endpointConfigured(ep) + if ephemeral { if err := m.g.UpsertEndpoint(ep); err != nil { return simpleErrorCmd(err) } - return m.activateEndpointCmd(ep) - }) + } + return m.activateEndpoint(ep, ephemeral) } func (m *model) endpointLabel(ep config.Endpoint) string { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 12476c0..97e19ce 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -13,6 +13,8 @@ import ( "net/http" "strings" "time" + "unicode" + "unicode/utf8" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -81,24 +83,91 @@ 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 connected bool } +// activation is the connection attempt currently on screen. It owns the +// attempt's identity and cancellation handle, and the URL the user can type +// over the top of it while it runs; the log tail it produces stays on the +// model because the failure screen still renders it after the attempt ends. +// +// cancel is nil for attempts that cannot be interrupted (the post-launch +// re-check), which is what makes Esc and the inline override inert there. +type activation struct { + id int + endpoint config.Endpoint + label string + cancel context.CancelFunc + // ephemeral records that this flow is what put endpoint into settings, + // so abandoning or overriding the attempt takes it back out instead of + // leaving an endpoint nobody chose. + ephemeral bool + logCh chan string + logCtx context.Context + // override is the inline "different Aperture URL" editor shown while a + // bridge attempt runs. + override textField +} + +// cancelable reports whether Esc can interrupt this attempt. +func (a *activation) cancelable() bool { return a != nil && a.cancel != nil } + +// overridable 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) overridable() bool { return a.cancelable() && a.endpoint.BridgeID != "" } + +// 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 +} + +// insert appends the text a key press carries. A typed character arrives as +// one rune and a pasted URL as many in a single message; both are text, and +// dropping the paste would leave the user retyping an endpoint by hand. Named +// keys and Alt chords carry no text and are ignored, as are control runes: +// 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 + } + if len(msg.Runes) == 0 { + return + } + for _, r := range msg.Runes { + if unicode.IsControl(r) { + return + } + } + f.value += string(msg.Runes) + f.err = "" +} + +func (f *textField) backspace() { + if f.value == "" { + return + } + _, size := utf8.DecodeLastRuneInString(f.value) + f.value = f.value[:len(f.value)-size] + f.err = "" +} + +func (f *textField) reset() { *f = textField{} } + func (m *model) Init() tea.Cmd { return m.activateEndpointCmd(m.g.ActiveEndpoint()) } @@ -111,6 +180,10 @@ type preflightResult struct { } 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 endpoint config.Endpoint host string providers []config.ProviderInfo @@ -170,38 +243,52 @@ func fetchProvidersContext(ctx context.Context, host string, timeout time.Durati } func (m *model) activateEndpointCmd(ep config.Endpoint) tea.Cmd { + return m.activateEndpoint(ep, false) +} + +// activateEndpoint starts a cancellable attempt to connect to ep. ephemeral +// marks an endpoint this flow just wrote to settings on the user's behalf, so +// cancelling or overriding the attempt can take it back out again. +func (m *model) activateEndpoint(ep config.Endpoint, ephemeral bool) 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 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, + endpoint: ep, + label: "Checking " + ep.URL + " ...", + cancel: cancel, + ephemeral: ephemeral, } + m.act = act bridge, ok := m.g.Bridge(ep.BridgeID) - if !ok { - m.preflightLabel = "Checking " + ep.URL + " ..." + switch { + case ep.BridgeID == "": return func() tea.Msg { + defer cancel() + provs, err := fetchProvidersContext(ctx, ep.URL, providerFetchTimeout) + return endpointActivationResult{id: act.id, endpoint: ep, host: ep.URL, providers: provs, err: err} + } + case !ok: + return func() tea.Msg { + defer cancel() return endpointActivationResult{ + id: act.id, endpoint: ep, host: ep.URL, err: fmt.Errorf("bridge %s is not configured", ep.BridgeID), } } - } - if m.bridgeManager == nil { + case m.bridgeManager == nil: return func() tea.Msg { + defer cancel() return endpointActivationResult{ + id: act.id, endpoint: ep, host: ep.URL, err: fmt.Errorf("bridge manager is not configured"), @@ -210,27 +297,132 @@ func (m *model) activateEndpointCmd(ep config.Endpoint) tea.Cmd { } 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 + " ..." + act.logCh = ch + act.logCtx = ctx + act.label = "Connecting bridge " + bridge.Name + " to " + ep.URL + " ..." bridgeLogf := bridgeLogSink(ctx, ch) activate := 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} + return endpointActivationResult{id: act.id, 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} + return endpointActivationResult{id: act.id, endpoint: ep, host: localURL, providers: provs, err: err} } return tea.Batch(activate, waitBridgeLog(ctx, ch)) } +// 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() + if !act.ephemeral { + return nil + } + act.ephemeral = false + return m.removeEndpoint(act.endpoint) +} + +// removeEndpoint deletes ep from settings. The active endpoint at index 0 is +// left alone: it is the connection the user falls back to. +func (m *model) removeEndpoint(ep config.Endpoint) error { + for i, existing := range m.g.Settings.Endpoints { + if i == 0 || !sameEndpoint(existing, ep) { + continue + } + return m.g.RemoveEndpoint(i) + } + return nil +} + +// 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 + if len(m.stack) == 0 { + 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 + } + next, err := config.ParseEndpoint(value, act.endpoint.BridgeID) + 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 + } + if sameEndpoint(next, act.endpoint) { + act.override.reset() + return m, nil + } + m.stopActivation() + + ephemeral := !m.endpointConfigured(next) + if act.ephemeral { + // Replace rather than add: the guessed endpoint was never reachable + // and nobody asked for it. + if err := m.g.ReplaceEndpoint(act.endpoint, next); err != nil { + m.errMsg = err.Error() + m.step = stepError + return m, nil + } + } else if ephemeral { + if err := m.g.UpsertEndpoint(next); err != nil { + m.errMsg = err.Error() + m.step = stepError + return m, nil + } + } + return m, m.activateEndpoint(next, ephemeral) +} + func bridgeLogSink(ctx context.Context, ch chan<- string) func(string) { return func(line string) { line = strings.TrimSpace(line) @@ -270,7 +462,10 @@ func waitBridgeLog(ctx context.Context, ch chan string) tea.Cmd { } func (m *model) quitCmd() tea.Cmd { - cancel := m.bridgeCancel + var cancel context.CancelFunc + if m.act != nil { + cancel = m.act.cancel + } bridgeManager := m.bridgeManager return func() tea.Msg { if cancel != nil { @@ -311,7 +506,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.ClearScreen case endpointActivationResult: - m.bridgeCancel = nil + if m.act == nil || msg.id != m.act.id { + // Cancelled or overridden: a newer attempt owns the screen. + return m, nil + } + m.act.cancel = nil if msg.err != nil { if sameEndpoint(msg.endpoint, m.g.ActiveEndpoint()) { m.connected = false @@ -346,19 +545,16 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.ClearScreen case bridgeLogMsg: - if m.bridgeLogCh != msg.ch { + if m.act == nil || m.act.logCh != msg.ch { return m, nil } m.bridgeLogs = appendBridgeLog(m.bridgeLogs, msg.line) - if m.bridgeLogCh != nil { - return m, waitBridgeLog(m.bridgeLogCtx, m.bridgeLogCh) - } - return m, nil + return m, waitBridgeLog(m.act.logCtx, m.act.logCh) 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 @@ -376,7 +572,8 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // agent was running. m.popToRoot() m.step = stepPreflight - m.preflightLabel = "Checking " + m.g.ApertureHost + " ..." + // No cancel handle: this re-check owns the screen until it answers. + m.act = &activation{label: "Checking " + m.g.ApertureHost + " ..."} return m, runPreflight(m.g.ApertureHost) case menu.InstallDoneMsg: @@ -409,10 +606,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": @@ -617,48 +811,95 @@ 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() + } + if !m.act.cancelable() { + return m, nil + } + if msg.String() == "esc" { + return m.cancelActivation() + } + if !m.act.overridable() { + 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) 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) + "\n") + for _, line := range m.bridgeLogs { + sb.WriteString(dimStyle.Render(m.wrapText(" ", line))) + sb.WriteString("\n") } + switch { + case m.act.overridable(): + 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") + } + sb.WriteString("\n") + sb.WriteString(dimStyle.Render("Enter to switch · Esc to cancel\n")) + case m.act.cancelable(): + sb.WriteString("\n") + sb.WriteString(dimStyle.Render("Esc to cancel\n")) + } + return sb.String() } func (m *model) View() string { switch m.step { case stepPreflight: - label := m.preflightLabel - if label == "" { - label = "Checking " + m.g.ApertureHost + " ..." - } - 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("\n") - } - return sb.String() + return m.viewPreflight() case stepError: var sb strings.Builder sb.WriteString(errorStyle.Render("Error")) @@ -674,7 +915,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() @@ -1008,13 +1249,14 @@ 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 } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 76525e8..aed76b6 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -427,11 +427,14 @@ func TestPreflightFailure_ShowsSetupGuide(t *testing.T) { func TestEndpointActivationFailure_ShowsSetupGuide(t *testing.T) { withFakeTailscale(t, tsConnected) withFakeClients(t, nil) + ep := config.Endpoint{URL: "http://ai"} m := &model{ g: &config.Global{ApertureHost: "http://ai"}, } + m.activateEndpointCmd(ep) m.Update(endpointActivationResult{ - endpoint: config.Endpoint{URL: "http://ai"}, + id: m.act.id, + endpoint: ep, err: fmt.Errorf("timeout"), }) if !m.forcedToEndpoint { @@ -525,7 +528,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{{URL: "http://other"}}, }}, step: stepMenu, } @@ -545,20 +548,250 @@ 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.Endpoint{URL: config.DefaultLocation, BridgeID: bridgeID} + if m.act == nil || !sameEndpoint(m.act.endpoint, want) { + t.Fatalf("activation = %+v, want %+v", m.act, want) + } + if !m.act.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{{URL: "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.URL != config.DefaultLocation || m.act.endpoint.BridgeID != bridge.ID { + t.Fatalf("activation = %+v, want %s via %s", m.act, config.DefaultLocation, bridge.ID) + } + if !m.act.overridable() { + t.Error("bridge discovery should accept a typed URL while it runs") + } +} + +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.Endpoint{URL: "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.Endpoint{URL: "http://aperture.example.ts.net", BridgeID: bridge.ID} + if m.act == nil || !sameEndpoint(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 || !sameEndpoint(got[0], previous) || !sameEndpoint(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{{URL: "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.Endpoint{URL: "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 || !sameEndpoint(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, endpoint: guessed.endpoint, 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{{URL: "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.Endpoint{URL: "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") +} + +func TestSetupGuideExplainsDefaultLocationGuess(t *testing.T) { + bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} + target := config.Endpoint{URL: config.DefaultLocation, BridgeID: 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) } } @@ -596,13 +829,12 @@ 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") } + want := config.Endpoint{URL: config.DefaultLocation, BridgeID: bridge.ID} if got := m.g.ActiveEndpoint(); !sameEndpoint(got, old) { t.Fatalf("active endpoint changed before activation: %+v", got) } @@ -610,7 +842,7 @@ func TestBridgeEndpointFailureKeepsPreviousEndpointActive(t *testing.T) { t.Fatalf("candidate endpoint was not saved: %+v", m.g.Settings.Endpoints) } - msg := cmd() + msg := res.Cmd() result, ok := msg.(endpointActivationResult) if !ok { t.Fatalf("activation message = %T", msg) From 77d93239dc18202e0f7d5989febe2cb9c168b29a Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Wed, 16 Sep 2026 19:48:09 +0000 Subject: [PATCH 03/69] README: bridge mode no longer asks for a URL --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9fcb3e6..d1dfd41 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,11 @@ 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. +3. Follow the Tailscale login prompt for the bridge. 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 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. + 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. ### Flags From c6469e984ebfb1b75eb955d9282fa56ae23a2b6c Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Wed, 16 Sep 2026 20:39:47 +0000 Subject: [PATCH 04/69] tui: let a working endpoint be retargeted A guessed URL that answers is not the same as the right Aperture. On a tailnet that already has a host called "ai", the default guess connects, so the two existing ways to change the URL both go missing: the connect screen's inline override is gone the moment the attempt succeeds, and the setup guide only appears on failure. Deleting the endpoint and adding it back guesses "ai" again and lands in the same place, so a user who wanted a different Aperture, or the same one through a bridge into another tailnet, has no way to say so. The endpoints menu takes "e" on the row under the cursor and edits its URL, keeping the bridge it is reached through, then reconnects. The setup guide's editor is now that same prompt rather than a second copy of it, and it still follows the failed endpoint through the rename so the failure screen keeps naming what is being tried. The alternative was asking for a URL again before every bridge connection, which is what the discovery flow removed: nobody adding their first bridge knows the hostname. The guess stays; correcting it no longer requires it to fail first. --- README.md | 2 ++ internal/tui/menus.go | 51 ++++++++++++++++++++++++++++++---------- internal/tui/tui_test.go | 51 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index d1dfd41..94bc0d3 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ To use bridge mode: 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. +`http://ai` can also answer and still be the wrong Aperture, which is what happens when the bridge joins a tailnet that already has a host called `ai`. Press `e` on `Aperture Endpoints` to point the selected endpoint somewhere else; it keeps the bridge it is reached through and reconnects. + 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. ### Flags diff --git a/internal/tui/menus.go b/internal/tui/menus.go index 19ac28a..ee42235 100644 --- a/internal/tui/menus.go +++ b/internal/tui/menus.go @@ -247,6 +247,20 @@ func (m *model) endpointsMenu() *menu.Menu { Hidden: true, Action: func() menu.Result { return menu.Result{Next: m.addEndpointConnectionMenu()} }, }) + // Hidden: "e" retargets the row under the cursor. Surfaced via the footer hint. + items = append(items, menu.MenuItem{ + Label: "edit", + Shortcut: "e", + Hidden: true, + Action: func() menu.Result { + idx := m.cursor() + if idx < 0 || idx >= len(m.g.Settings.Endpoints) { + return menu.Result{} + } + m.promptEditEndpoint(m.g.Settings.Endpoints[idx]) + return menu.Result{} + }, + }) // Hidden: "d" deletes the row under the cursor. items = append(items, menu.MenuItem{ Label: "delete", @@ -276,7 +290,7 @@ func (m *model) endpointsMenu() *menu.Menu { return &menu.Menu{ Title: endpointsTitle, Items: items, - Hint: "Enter to select · d to remove · a to add · Esc to go back", + Hint: "Enter to select · e to edit · d to remove · a to add · Esc to go back", OnBack: func() tea.Cmd { if len(m.stack) <= 1 { if m.forcedToEndpoint { @@ -339,17 +353,7 @@ func (m *model) setupGuideMenu() *menu.Menu { { Label: "Edit endpoint URL", Action: func() menu.Result { - m.promptForInput("Edit Endpoint:", "URL", target.URL, func(v string) tea.Cmd { - next, err := config.ParseEndpoint(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{} }, }, @@ -405,6 +409,29 @@ func (m *model) setupGuideMenu() *menu.Menu { } } +// promptEditEndpoint edits ep's URL in place and connects to what the user +// typed, keeping whichever bridge ep is reached through. It is the only way to +// retarget an endpoint that connects successfully: the guessed default answers +// on any tailnet with a host called "ai", and a success shows neither the +// connect screen's inline override nor the setup guide's editor. +func (m *model) promptEditEndpoint(ep config.Endpoint) { + m.promptForInput("Edit Endpoint:", "URL", ep.URL, func(v string) tea.Cmd { + next, err := config.ParseEndpoint(v, ep.BridgeID) + if err != nil { + return simpleErrorCmd(err) + } + if err := m.g.ReplaceEndpoint(ep, next); err != nil { + return simpleErrorCmd(err) + } + // Follow the rename, so a failure screen already showing ep keeps + // naming the endpoint the user is now trying. + if m.failedEndpoint != nil && sameEndpoint(*m.failedEndpoint, ep) { + m.failedEndpoint = &next + } + return m.activateEndpointCmd(next) + }) +} + func (m *model) clearEndpointFailure() { m.failedEndpoint = nil m.preflightErr = "" diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index aed76b6..f9c2aa4 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -777,6 +777,57 @@ func TestSetupGuideEditPrefillsFailedURL(t *testing.T) { t.Fatal("Edit endpoint URL item not found") } +// A guessed URL that answers is not necessarily the Aperture the user wanted: +// on a tailnet that already has a host called "ai", both a direct connection +// and a new bridge land there and succeed, and nothing fails to open the setup +// guide's editor. The endpoints menu has to be able to retarget a working +// endpoint, or that first success is the only one reachable. +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.Endpoint{URL: config.DefaultLocation, BridgeID: 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.Endpoint{URL: "http://aperture.example.ts.net", BridgeID: bridge.ID} + if got := m.g.Settings.Endpoints; len(got) != 1 || !sameEndpoint(got[0], want) { + t.Fatalf("endpoints = %+v, want the row rewritten to %+v", got, want) + } + if m.act == nil || !sameEndpoint(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.Endpoint{URL: config.DefaultLocation, BridgeID: bridge.ID} From 374946a6be281831b4031855c397b6682f2d8f4b Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Wed, 16 Sep 2026 21:07:57 +0000 Subject: [PATCH 05/69] config: record which tailnet a bridge is on Picking a bridge is picking a tailnet, and the connection picker has to say which one a bridge reaches before the user selects it. Nothing in settings knew: a bridge was an ID and a name, and the tailnet only existed in the running node's status, so a bridge that had not been started this session could not be labelled at all. Storing the name the node reported is a cache, not a source of truth, which is why SetBridgeTailnet treats an unknown bridge as a no-op and the accessor side prefers a live node's answer. The alternative, asking tailscaled or bringing every configured bridge up to read its status, costs a login per bridge to render a menu. Revisit if bridges ever hold more than one tailnet at a time; then this becomes a list and the picker needs to choose within a bridge. --- internal/config/endpoint.go | 4 ++++ internal/config/global.go | 20 ++++++++++++++++++++ internal/config/state_test.go | 24 ++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/internal/config/endpoint.go b/internal/config/endpoint.go index 66a604a..f17e0b7 100644 --- a/internal/config/endpoint.go +++ b/internal/config/endpoint.go @@ -22,6 +22,10 @@ type Endpoint struct { type Bridge struct { ID string `json:"id"` Name string `json:"name"` + // Tailnet is the network the node logged in to, recorded after a + // successful connection so the connection picker can say which tailnet a + // bridge reaches before it is started again. + Tailnet string `json:"tailnet,omitempty"` } // ParseEndpoint turns user input into an Endpoint reached over bridgeID, which diff --git a/internal/config/global.go b/internal/config/global.go index 99c5f01..1df6e00 100644 --- a/internal/config/global.go +++ b/internal/config/global.go @@ -195,6 +195,26 @@ func (g *Global) AddBridge(name string) (Bridge, error) { return p, nil } +// SetBridgeTailnet records the tailnet a bridge logged in to and persists it. +// 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 a bridge if no endpoint still references it. func (g *Global) RemoveBridge(id string) error { for _, ep := range g.Settings.Endpoints { diff --git a/internal/config/state_test.go b/internal/config/state_test.go index a99ca31..1746145 100644 --- a/internal/config/state_test.go +++ b/internal/config/state_test.go @@ -258,6 +258,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) From e22c44230f6ca604efa4bb32c2c35f651a8bc4e6 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Wed, 16 Sep 2026 21:08:03 +0000 Subject: [PATCH 06/69] bridges: report the tailnet a node joined and support leaving it A bridge holds one tailnet at a time, so letting the user change tailnets means logging the node out: tsnet reuses the credentials in its state dir on every start, so closing and reopening the node lands back on the same tailnet. SwitchTailnet therefore brings the node up before logging out, which is also what removes the device from that tailnet instead of orphaning it there, and drops the node so the next Activate builds a fresh one and prompts for a login. Up already returns the login status, so recording CurrentTailnet.Name costs no extra call. Doing it anywhere else would need a second LocalAPI round trip per bridge. The bring-up half of Activate moved into runningNode so the switch path shares it; the proxy half is unchanged. --- internal/bridges/manager.go | 201 +++++++++++++++++++++++-------- internal/bridges/manager_test.go | 87 +++++++++++++ 2 files changed, 239 insertions(+), 49 deletions(-) diff --git a/internal/bridges/manager.go b/internal/bridges/manager.go index e2ec470..53bfb68 100644 --- a/internal/bridges/manager.go +++ b/internal/bridges/manager.go @@ -24,6 +24,9 @@ type Manager struct { debug bool nodes map[string]*nodeRuntime + // tailnets is the network each running node logged in to, keyed by bridge + // ID. Read back by the TUI to label a bridge with the tailnet it reaches. + tailnets map[string]string newNode func(bridge config.Bridge, stateDir string, userLogf, debugLogf func(string, ...any)) tailnetNode } @@ -48,6 +51,7 @@ type tailnetNode interface { Up(context.Context) (*ipnstate.Status, error) Status(context.Context) (*ipnstate.Status, error) DialContext(context.Context, string, string) (net.Conn, error) + Logout(context.Context) error Close() error } @@ -71,6 +75,17 @@ func (n *tsnetNode) DialContext(ctx context.Context, network, address string) (n return n.server.Dial(ctx, network, address) } +// Logout drops the node's tailnet credentials. The node must be running: the +// login state lives behind its in-process LocalAPI, so logging out is how the +// node leaves the tailnet it is on rather than reusing it on the next start. +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() } @@ -79,8 +94,9 @@ func (n *tsnetNode) Close() error { // 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), + debug: debug, + nodes: make(map[string]*nodeRuntime), + tailnets: make(map[string]string), } m.newNode = func(bridge config.Bridge, stateDir string, userLogf, debugLogf func(string, ...any)) tailnetNode { s := &tsnet.Server{ @@ -113,47 +129,9 @@ func (m *Manager) Activate(ctx context.Context, bridge config.Bridge, remoteURL 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.") + rt, status, err := m.runningNode(ctx, bridge, logf) + if err != nil { + return "", err } if m.debug { // Up deliberately returns status without peers. Ask the in-process @@ -187,6 +165,121 @@ func (m *Manager) Activate(ctx context.Context, bridge config.Bridge, remoteURL return proxy.localURL, nil } +// runningNode returns the bridge's node, starting it if this is the first use. +// status is the login status Up reported, and is nil for a node that was +// already running. Callers hold no lock. +func (m *Manager) runningNode(ctx context.Context, bridge config.Bridge, logf func(string)) (*nodeRuntime, *ipnstate.Status, error) { + m.mu.Lock() + rt := m.nodes[bridge.ID] + if rt != nil { + m.mu.Unlock() + return rt, nil, nil + } + stateDir, err := config.BridgeStateDir(bridge.ID) + if err != nil { + m.mu.Unlock() + return nil, nil, 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...)) + } + } + rt = &nodeRuntime{ + node: m.newNode(bridge, stateDir, userLogf, debugLogf), + proxies: make(map[string]*proxyRuntime), + } + m.nodes[bridge.ID] = rt + m.mu.Unlock() + + logf("Starting bridge " + bridge.Name + " (" + bridge.ID + ")") + 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 nil, nil, errors.Join(err, rt.node.Close()) + } + logf("Bridge connected.") + + // Up returns the login status, so the tailnet this bridge reaches costs no + // extra call. The connection picker names it on rows the user has not + // connected to yet. + if status != nil && status.CurrentTailnet != nil && status.CurrentTailnet.Name != "" { + m.mu.Lock() + if m.tailnets == nil { + m.tailnets = make(map[string]string) + } + m.tailnets[bridge.ID] = status.CurrentTailnet.Name + m.mu.Unlock() + } + return rt, status, nil +} + +// Tailnet returns the network the bridge's node logged in to during this +// session, or "" when it has not been started or reported one. +func (m *Manager) Tailnet(bridgeID string) string { + if m == nil { + return "" + } + m.mu.Lock() + defer m.mu.Unlock() + return m.tailnets[bridgeID] +} + +// SwitchTailnet logs the bridge out of the tailnet it is on and discards its +// node, so the next Activate starts a fresh one and asks for a new login. +// +// The node has to be running to be logged out: its credentials live behind the +// in-process LocalAPI, and closing the node without logging out would reuse +// them on the next start. A node that was never started this session is +// therefore brought up on the old tailnet first, which is also what leaves the +// device removed from it rather than orphaned. +func (m *Manager) SwitchTailnet(ctx context.Context, bridge config.Bridge, logf func(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) {} + } + rt, _, err := m.runningNode(ctx, bridge, logf) + if err != nil { + return err + } + + logf("Logging bridge " + bridge.Name + " out of its tailnet ...") + logoutErr := rt.node.Logout(ctx) + + // Under the lock, as in Close: an Activate that took rt before the delete + // may still be adding a proxy to it. + m.mu.Lock() + if m.nodes[bridge.ID] == rt { + delete(m.nodes, bridge.ID) + } + delete(m.tailnets, bridge.ID) + errs := []error{logoutErr} + for key, proxy := range rt.proxies { + errs = append(errs, closeProxy(proxy)) + delete(rt.proxies, key) + } + errs = append(errs, rt.node.Close()) + m.mu.Unlock() + + if err := errors.Join(errs...); err != nil { + return err + } + logf("Bridge logged out. Log in to the tailnet you want next.") + return nil +} + // Close shuts down all active reverse proxies and tsnet nodes. func (m *Manager) Close() error { if m == nil { @@ -198,18 +291,28 @@ func (m *Manager) Close() error { 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) - } + errs = append(errs, closeProxy(proxy)) delete(rt.proxies, key) } if err := rt.node.Close(); err != nil { errs = append(errs, err) } delete(m.nodes, id) + delete(m.tailnets, id) + } + return errors.Join(errs...) +} + +// closeProxy shuts down one localhost reverse proxy. An already-closed server +// or listener is not a failure: Close and SwitchTailnet can both reach the +// same proxy. +func closeProxy(proxy *proxyRuntime) error { + var errs []error + 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) } return errors.Join(errs...) } diff --git a/internal/bridges/manager_test.go b/internal/bridges/manager_test.go index 846c99a..aeff2aa 100644 --- a/internal/bridges/manager_test.go +++ b/internal/bridges/manager_test.go @@ -24,7 +24,9 @@ type fakeNode struct { statusErr error dialErr error dialFn bridgeDialFunc + logoutErr error up int + loggedOut int closed bool } @@ -326,6 +328,91 @@ func TestDialWithDNSRetry(t *testing.T) { }) } +func TestActivateRecordsTailnet(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer backend.Close() + + m := NewManager(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 := m.Activate(context.Background(), bridge, "http://aperture.tailnet", nil); err != nil { + t.Fatal(err) + } + if got := m.Tailnet(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.tailnets[bridge.ID] = "corp.example.com" + first := f.node + + if err := f.manager.SwitchTailnet(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 := f.manager.Tailnet(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 := f.manager.Activate(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 := f.manager.SwitchTailnet(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 From a61e26a3b48d73f4819357692b091bc8adcf1d9e Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Wed, 16 Sep 2026 21:08:13 +0000 Subject: [PATCH 07/69] tui: put a connection picker at the root of the menu With two bridges configured and a reachable http://ai, the launcher connects on its own at startup and nothing on screen leads to either bridge. The only path was Settings, Aperture Endpoints, "a", Bridge, pick: an add-an-endpoint flow used as a connect flow, two levels down and behind a key nobody is told about. Settings, Bridges looked like the right screen and its rows did nothing at all. So the picker is a visible row on the agent menu, and every action on it is a row too. Enter on a connection opens its page (connect, change URL, switch tailnet, remove) rather than connecting straight away: the "e" and "d" keys that did those things only worked if you already knew them, and a cursor-reading handler cannot be a visible row, because selecting it moves the cursor onto itself. It is the same screen as Settings, Aperture Endpoints rather than a second one, and the old a/e/d keys still work, so the existing flow is unchanged. A bridge with no endpoint gets a row as well, described by the endpoint it would create; that is how a second tailnet is reached the first time. --- internal/tui/menus.go | 282 ++++++++++++++++++++++++++++++++++++--- internal/tui/tui.go | 35 ++++- internal/tui/tui_test.go | 182 ++++++++++++++++++++++++- 3 files changed, 469 insertions(+), 30 deletions(-) diff --git a/internal/tui/menus.go b/internal/tui/menus.go index ee42235..288abb2 100644 --- a/internal/tui/menus.go +++ b/internal/tui/menus.go @@ -51,6 +51,15 @@ func (m *model) rootMenu() *menu.Menu { items = append(items, it) } + // Visible, because picking which Aperture to talk to is not a setting: 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: "Aperture connection", + Description: "switch endpoint, bridge or tailnet", + Action: func() menu.Result { return menu.Result{Next: m.endpointsMenu()} }, + }) + hints := []string{"[s] Settings"} if len(uninstalled) > 0 { hints = append(hints, "[i] Install agents") @@ -182,8 +191,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{ @@ -219,28 +228,40 @@ func (m *model) bridgesMenu() *menu.Menu { return &menu.Menu{ Title: "Bridges", 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", + } +} + +// 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.bridgeTailnet(bridge); name != "" { + return "tailnet " + name } + return bridge.ID } -// endpointsMenu lists configured endpoints with add/delete affordances. -// Selecting an entry runs preflight and promotes it only after success. +// endpointsMenu is the connection picker: every Aperture this launcher can +// reach, one row each, whether it is a saved endpoint or a bridge that has no +// endpoint yet. Selecting a row opens its page rather than connecting straight +// away, so connecting, retargeting, switching tailnet and removing are all on +// screen instead of behind remembered keys. 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", @@ -290,7 +311,7 @@ func (m *model) endpointsMenu() *menu.Menu { return &menu.Menu{ Title: endpointsTitle, Items: items, - Hint: "Enter to select · e to edit · 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 { @@ -304,6 +325,218 @@ func (m *model) endpointsMenu() *menu.Menu { } } +// connectionRow is one line on the connection picker. A saved endpoint is one +// row; so is a bridge nothing points at yet, described by the endpoint it would +// create, because a bridge with no endpoint is still a connection the user has +// to be able to pick. That 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 ep.BridgeID != "" { + used[ep.BridgeID] = true + row.bridge, _ = m.g.Bridge(ep.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.Endpoint{URL: config.DefaultLocation, BridgeID: b.ID}, + bridge: b, + }) + } + return rows +} + +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.ep.BridgeID == "" { + return "" + } + if name := m.bridgeTailnet(row.bridge); name != "" { + return "tailnet " + name + } + return "tailnet not known yet" +} + +// bridgeTailnet prefers what the running node reports to what was saved: a +// bridge that switched tailnets this session leaves a stale name on disk until +// the next successful connection rewrites it. +func (m *model) bridgeTailnet(bridge config.Bridge) string { + if name := m.bridgeManager.Tailnet(bridge.ID); name != "" { + return name + } + return bridge.Tailnet +} + +// connectionMenu is 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.ep.BridgeID != "" { + description := "log the bridge out and sign in to a different tailnet" + if name := m.bridgeTailnet(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.removeConnection(row.ep) }, + }) + default: + items = append(items, menu.MenuItem{ + Label: "Remove bridge", + Description: row.bridge.ID, + Action: func() menu.Result { + if err := m.g.RemoveBridge(row.bridge.ID); err != nil { + return errResult(err.Error()) + } + m.refreshEndpointsMenu() + return menu.Result{Cmd: tea.ClearScreen} + }, + }) + } + + return &menu.Menu{ + Title: title, + Items: items, + Hint: "Enter to select · Esc to go back", + } +} + +func (m *model) removeConnection(ep config.Endpoint) menu.Result { + for i, existing := range m.g.Settings.Endpoints { + if !sameEndpoint(existing, ep) { + continue + } + if i == 0 { + return errResult("connect to another endpoint before removing the active one") + } + if err := m.g.RemoveEndpoint(i); err != nil { + return errResult(err.Error()) + } + break + } + if m.failedEndpoint != nil && sameEndpoint(*m.failedEndpoint, ep) { + m.clearEndpointFailure() + m.resetStack(m.rootMenu()) + return menu.Result{Cmd: tea.ClearScreen} + } + m.refreshEndpointsMenu() + return menu.Result{Cmd: tea.ClearScreen} +} + +// 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.bridgeTailnet(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 { + // Drop the recorded name now: an abandoned login would + // otherwise leave the picker naming a tailnet the bridge + // has already left. + if err := m.g.SetBridgeTailnet(row.bridge.ID, ""); err != nil { + return errResult(err.Error()) + } + 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. @@ -479,7 +712,7 @@ func (m *model) endpointBridgeMenu() *menu.Menu { p := p items = append(items, menu.MenuItem{ Label: p.Name, - Description: p.ID, + Description: m.bridgeRowDescription(p), Action: func() menu.Result { return menu.Result{Cmd: m.connectBridgeCmd(p)} }, }) } @@ -509,14 +742,21 @@ func (m *model) endpointBridgeMenu() *menu.Menu { // of demanding a URL the user may not know. The connect screen takes a // different URL while the guess runs, so knowing it costs no waiting. func (m *model) connectBridgeCmd(bridge config.Bridge) tea.Cmd { - ep := config.Endpoint{URL: config.DefaultLocation, BridgeID: bridge.ID} + return m.connectVia(config.Endpoint{URL: config.DefaultLocation, BridgeID: bridge.ID}, false) +} + +// connectVia connects to ep, saving it first when it is not in settings yet so +// the failure screen has something to name, retry and edit. switchTailnet logs +// the bridge out on the way, which is what makes the next connection ask for a +// login instead of reusing the tailnet it is already on. +func (m *model) connectVia(ep config.Endpoint, switchTailnet bool) tea.Cmd { ephemeral := !m.endpointConfigured(ep) if ephemeral { if err := m.g.UpsertEndpoint(ep); err != nil { return simpleErrorCmd(err) } } - return m.activateEndpoint(ep, ephemeral) + return m.activateEndpoint(ep, ephemeral, switchTailnet) } func (m *model) endpointLabel(ep config.Endpoint) string { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 97e19ce..f4156c3 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -243,13 +243,15 @@ func fetchProvidersContext(ctx context.Context, host string, timeout time.Durati } func (m *model) activateEndpointCmd(ep config.Endpoint) tea.Cmd { - return m.activateEndpoint(ep, false) + return m.activateEndpoint(ep, false, false) } // activateEndpoint starts a cancellable attempt to connect to ep. ephemeral // marks an endpoint this flow just wrote to settings on the user's behalf, so // cancelling or overriding the attempt can take it back out again. -func (m *model) activateEndpoint(ep config.Endpoint, ephemeral bool) tea.Cmd { +// switchTailnet logs the bridge out before connecting, so the attempt starts +// from a login prompt rather than the tailnet the node is already on. +func (m *model) activateEndpoint(ep config.Endpoint, ephemeral, switchTailnet bool) tea.Cmd { m.stopActivation() m.step = stepPreflight m.preflightErr = "" @@ -300,9 +302,20 @@ func (m *model) activateEndpoint(ep config.Endpoint, ephemeral bool) tea.Cmd { act.logCh = ch act.logCtx = ctx act.label = "Connecting bridge " + bridge.Name + " to " + ep.URL + " ..." + if switchTailnet { + act.label = "Switching bridge " + bridge.Name + " to a different tailnet ..." + } bridgeLogf := bridgeLogSink(ctx, ch) activate := func() tea.Msg { defer cancel() + // Inside the attempt, so it shares the attempt's cancellation and log + // 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 switchTailnet { + if err := m.bridgeManager.SwitchTailnet(ctx, bridge, bridgeLogf); err != nil { + return endpointActivationResult{id: act.id, endpoint: ep, host: ep.URL, err: err} + } + } localURL, err := m.bridgeManager.Activate(ctx, bridge, ep.URL, bridgeLogf) if err != nil { return endpointActivationResult{id: act.id, endpoint: ep, host: ep.URL, err: err} @@ -316,6 +329,21 @@ func (m *model) activateEndpoint(ep config.Endpoint, ephemeral bool) tea.Cmd { return tea.Batch(activate, waitBridgeLog(ctx, ch)) } +// recordBridgeTailnet saves the tailnet a bridge just connected through, so +// the connection picker can name it on a later run before the bridge is +// started again. A failed write is not worth interrupting a connection that +// worked: the picker falls back to saying the tailnet is not known yet. +func (m *model) recordBridgeTailnet(ep config.Endpoint) { + if ep.BridgeID == "" { + return + } + name := m.bridgeManager.Tailnet(ep.BridgeID) + if name == "" { + return + } + _ = m.g.SetBridgeTailnet(ep.BridgeID, name) +} + // 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. @@ -420,7 +448,7 @@ func (m *model) overrideActivationURL(value string) (tea.Model, tea.Cmd) { return m, nil } } - return m, m.activateEndpoint(next, ephemeral) + return m, m.activateEndpoint(next, ephemeral, false) } func bridgeLogSink(ctx context.Context, ch chan<- string) func(string) { @@ -534,6 +562,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } } + m.recordBridgeTailnet(msg.endpoint) m.g.ApertureHost = msg.host m.g.Providers = msg.providers m.connected = true diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index f9c2aa4..4942b2a 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -63,16 +63,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 + the connection row, then hidden shortcut items + // (settings + install-agents). Visible count: A, C, Aperture connection. + 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", "Aperture connection"} + if !slices.Equal(visible, want) { + t.Errorf("visible items = %v, want %v", visible, want) } } @@ -828,6 +829,175 @@ func TestEndpointsMenu_EditRetargetsWorkingEndpoint(t *testing.T) { } } +// 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{ + {URL: config.DefaultLocation}, + {URL: config.DefaultLocation, BridgeID: "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()) + + idx, _ := findItem(t, m.top().Items, "Aperture connection") + m.activate(idx) + 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.Endpoint{URL: config.DefaultLocation, BridgeID: "bridge-bbbbbb"} + if m.act == nil || !sameEndpoint(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 m.act == nil || m.act.endpoint.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 has no endpoint now, so it comes back as a bridge row. + findItem(t, m.top().Items, "Connect via Work") +} + +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.Endpoint{URL: config.DefaultLocation, BridgeID: "bridge-bbbbbb"} + if m.act == nil || !sameEndpoint(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.Endpoint{URL: config.DefaultLocation, BridgeID: bridge.ID} From e6066b7d8f86cc5ed41138334fc60bcb592cc5fd Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Wed, 16 Sep 2026 21:08:18 +0000 Subject: [PATCH 08/69] README: document the connection picker and tailnet switching The bridge-mode walkthrough still sent readers to Settings and the "a" key, which is no longer how you reach a bridge, and nothing said a bridge is on one tailnet at a time or what switching costs. --- README.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 94bc0d3..f0a759c 100644 --- a/README.md +++ b/README.md @@ -53,16 +53,26 @@ 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`. +1. Choose `Aperture connection` on the agent menu (also reachable as `Settings`, then `Aperture Endpoints`). +2. Choose `Add a connection`, then `Bridge`, then an existing bridge or `Add Bridge`. 3. Follow the Tailscale login prompt for the bridge. 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 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. -`http://ai` can also answer and still be the wrong Aperture, which is what happens when the bridge joins a tailnet that already has a host called `ai`. Press `e` on `Aperture Endpoints` to point the selected endpoint somewhere else; it keeps the bridge it is reached through and reconnects. +### Choosing a connection -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. +`Aperture 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 From 63c30d573fc352a1cc4b17c9e2a57c9378cc5298 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Wed, 16 Sep 2026 21:13:49 +0000 Subject: [PATCH 09/69] tui: move the connection picker into the root hints A numbered row put picking an Aperture in the list of editors to launch, which is not what that list is for. It belongs with Settings and Install agents at the bottom, so [c] Change connection leads the hints there. Still on screen, so it is not a key you have to already know. --- README.md | 4 ++-- internal/tui/menus.go | 20 ++++++++++---------- internal/tui/tui_test.go | 12 +++++++----- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index f0a759c..1409025 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ This is useful on machines where installing Tailscale is not practical, where Ap To use bridge mode: -1. Choose `Aperture connection` on the agent menu (also reachable as `Settings`, then `Aperture Endpoints`). +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. Follow the Tailscale login prompt for the bridge. 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. @@ -62,7 +62,7 @@ If your Aperture answers on a different hostname, type it on the connect screen ### Choosing a connection -`Aperture 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. +`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. diff --git a/internal/tui/menus.go b/internal/tui/menus.go index 288abb2..7205d3d 100644 --- a/internal/tui/menus.go +++ b/internal/tui/menus.go @@ -51,16 +51,7 @@ func (m *model) rootMenu() *menu.Menu { items = append(items, it) } - // Visible, because picking which Aperture to talk to is not a setting: 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: "Aperture connection", - Description: "switch endpoint, bridge or tailnet", - Action: func() menu.Result { return menu.Result{Next: m.endpointsMenu()} }, - }) - - hints := []string{"[s] Settings"} + hints := []string{"[c] Change connection", "[s] Settings"} if len(uninstalled) > 0 { hints = append(hints, "[i] Install agents") } @@ -68,6 +59,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", diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 4942b2a..c6899d3 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -63,15 +63,15 @@ func TestRootMenu_ShowsInstalledClients(t *testing.T) { m := &model{g: &config.Global{}} root := m.rootMenu() - // Installed clients + the connection row, then hidden shortcut items - // (settings + install-agents). Visible count: A, C, Aperture connection. + // 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 = append(visible, it.Label) } } - want := []string{"A", "C", "Aperture connection"} + want := []string{"A", "C"} if !slices.Equal(visible, want) { t.Errorf("visible items = %v, want %v", visible, want) } @@ -879,8 +879,10 @@ func TestRootMenu_OpensConnectionPicker(t *testing.T) { m := pickerModel(t) m.resetStack(m.rootMenu()) - idx, _ := findItem(t, m.top().Items, "Aperture connection") - m.activate(idx) + 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) } From 40536a63b784a8812e791dc0a81664453c383cf2 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Wed, 16 Sep 2026 21:18:15 +0000 Subject: [PATCH 10/69] tui: make "d to remove" remove the row the cursor is on The footer promised it on every row and delivered on almost none. The handler indexed Settings.Endpoints by cursor position, which stopped being the row list when the picker grew rows for bridges that have no endpoint: the cursor on one of those indexed past the end and the key silently did nothing. A single saved endpoint was inert too, refused by a length guard before the branch that would have explained why. So both aliases resolve a row through connectionRows, the same list the screen is drawn from, and removal is the row's own action: delete the endpoint, or the bridge when nothing points at it. The active row now says why it stays instead of ignoring the key. --- internal/tui/menus.go | 68 +++++++++++++++++++++++++--------------- internal/tui/tui_test.go | 40 +++++++++++++++++++++++ 2 files changed, 82 insertions(+), 26 deletions(-) diff --git a/internal/tui/menus.go b/internal/tui/menus.go index 7205d3d..367c687 100644 --- a/internal/tui/menus.go +++ b/internal/tui/menus.go @@ -274,11 +274,14 @@ func (m *model) endpointsMenu() *menu.Menu { Shortcut: "e", Hidden: true, Action: func() menu.Result { - idx := m.cursor() - if idx < 0 || idx >= len(m.g.Settings.Endpoints) { + row, ok := m.connectionAtCursor() + if !ok { return menu.Result{} } - m.promptEditEndpoint(m.g.Settings.Endpoints[idx]) + if !row.saved { + return errResult("connect through " + row.bridge.Name + " first, then its URL can be changed") + } + m.promptEditEndpoint(row.ep) return menu.Result{} }, }) @@ -288,23 +291,11 @@ func (m *model) endpointsMenu() *menu.Menu { Shortcut: "d", Hidden: true, Action: func() menu.Result { - idx := m.cursor() - if idx < 0 || idx >= len(m.g.Settings.Endpoints) || len(m.g.Settings.Endpoints) <= 1 { - 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 m.failedEndpoint != nil && sameEndpoint(*m.failedEndpoint, removed) { - m.clearEndpointFailure() - m.resetStack(m.rootMenu()) + row, ok := m.connectionAtCursor() + if !ok { return menu.Result{} } - return menu.Result{Replace: m.endpointsMenu()} + return m.removeConnectionRow(row) }, }) @@ -361,6 +352,19 @@ func (m *model) connectionRows() []connectionRow { return rows } +// connectionAtCursor resolves the picker row the cursor is on. The hidden "e" +// and "d" aliases act through it rather than indexing Settings.Endpoints, so +// they see the same rows the user does: a bridge with no endpoint is a row too, +// and indexing past the endpoints made those keys silently do nothing. +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 @@ -455,19 +459,13 @@ func (m *model) connectionMenu(row connectionRow) *menu.Menu { case row.saved: items = append(items, menu.MenuItem{ Label: "Remove connection", - Action: func() menu.Result { return m.removeConnection(row.ep) }, + Action: func() menu.Result { return m.removeConnectionRow(row) }, }) default: items = append(items, menu.MenuItem{ Label: "Remove bridge", Description: row.bridge.ID, - Action: func() menu.Result { - if err := m.g.RemoveBridge(row.bridge.ID); err != nil { - return errResult(err.Error()) - } - m.refreshEndpointsMenu() - return menu.Result{Cmd: tea.ClearScreen} - }, + Action: func() menu.Result { return m.removeConnectionRow(row) }, }) } @@ -478,6 +476,24 @@ func (m *model) connectionMenu(row connectionRow) *menu.Menu { } } +// removeConnectionRow deletes what a picker row stands for: the endpoint, or +// the bridge itself when no endpoint points at it yet. Shared by the row's page +// and the "d" key, which have to agree on what removing a row means. +func (m *model) removeConnectionRow(row connectionRow) menu.Result { + switch { + case row.active: + return errResult("connect to another endpoint before removing the active one") + case row.saved: + return m.removeConnection(row.ep) + default: + if err := m.g.RemoveBridge(row.bridge.ID); err != nil { + return errResult(err.Error()) + } + m.refreshEndpointsMenu() + return menu.Result{Cmd: tea.ClearScreen} + } +} + func (m *model) removeConnection(ep config.Endpoint) menu.Result { for i, existing := range m.g.Settings.Endpoints { if !sameEndpoint(existing, ep) { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index c6899d3..4177af2 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -987,6 +987,46 @@ func TestConnectionPicker_RemovesInactiveConnection(t *testing.T) { findItem(t, m.top().Items, "Connect via Work") } +// 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].BridgeID != "" { + 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()) From ab55a2c624593de7d9e4b56d91bfa55a9af38edb Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Wed, 16 Sep 2026 21:46:26 +0000 Subject: [PATCH 11/69] tui: open the browser at the bridge login link tsnet reprints "restart with TS_AUTHKEY set, or go to: " every five seconds until the node is authorized, so the connect screen filled with the same URL seven times over and the only way forward was copying it out of a terminal by hand. The log tail now carries one "Authorize this bridge in your browser" line per distinct URL and the launcher starts the platform opener on it. Detection sits in the bridgeLogMsg case because that is the single point every bridge log line crosses; parsing it in the manager would mean a second sink next to the one the screen already reads. The dedupe key lives on the activation so a tailnet switch, which produces a new URL inside the same attempt, opens again. exec.Start, not Run: an opener can block for the life of the browser it launches. That means a missing opener is caught and a headless box that has xdg-open but no display is not, which is why the link stays on screen either way. Revisit if a dependency ever shows up that handles the display check. --- README.md | 2 +- internal/tui/browser.go | 50 ++++++++++++++++++++++++++++++++++ internal/tui/tui.go | 45 ++++++++++++++++++++++++++++++- internal/tui/tui_test.go | 58 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 internal/tui/browser.go diff --git a/README.md b/README.md index 1409025..6c70372 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ To use bridge mode: 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. Follow the Tailscale login prompt for the bridge. No URL is asked for: the bridge looks for Aperture at `http://ai`, the same location a direct connection starts from. +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 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. diff --git a/internal/tui/browser.go b/internal/tui/browser.go new file mode 100644 index 0000000..12e25fa --- /dev/null +++ b/internal/tui/browser.go @@ -0,0 +1,50 @@ +package tui + +import ( + "os/exec" + "runtime" + "strings" +) + +// tsnetAuthURLMarker is what tsnet logs ahead of the login link while a bridge +// waits to be authorized ("... restart with TS_AUTHKEY set, or go to: "). +// It repeats the whole line every few seconds until login completes. +const tsnetAuthURLMarker = "or go to: " + +// authURLFromLog returns the Tailscale login link a bridge log line carries, +// or "" when it carries none. The https:// requirement is not cosmetic: the +// result is handed to a desktop opener, and anything else (a file path, a +// leading dash) is not a link the user asked us to follow. +func authURLFromLog(line string) string { + _, rest, ok := strings.Cut(line, tsnetAuthURLMarker) + if !ok { + return "" + } + url := strings.TrimSpace(rest) + if !strings.HasPrefix(url, "https://") || strings.ContainsAny(url, " \t") { + return "" + } + return url +} + +// openURL asks the desktop to open a link. Start, not Run: the opener can +// block for as long as the browser it launches lives, and a headless box +// fails here by not having an opener at all, which Start already reports. +func openURL(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("cmd", "/c", "start", "", url) + default: + cmd = exec.Command("xdg-open", url) + } + // Anything the opener prints would land in the middle of the TUI. + cmd.Stdout, cmd.Stderr = nil, nil + if err := cmd.Start(); err != nil { + return err + } + go cmd.Wait() // reap it; the opener outlives this call + return nil +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index f4156c3..fffc829 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -117,6 +117,11 @@ type activation struct { ephemeral bool logCh chan string logCtx context.Context + // authURL is the Tailscale login link already surfaced for this attempt. + // tsnet reprints its line every few seconds, so this is what keeps the + // log tail from filling with one repeated URL and the browser from being + // opened again on each repeat. + authURL string // override is the inline "different Aperture URL" editor shown while a // bridge attempt runs. override textField @@ -195,6 +200,19 @@ type bridgeLogMsg struct { line string } type bridgeLogDoneMsg struct{ ch chan string } + +// 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)} } +} + type quitMsg struct{ Err error } func runPreflight(host string) tea.Cmd { @@ -577,8 +595,26 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.act == nil || m.act.logCh != msg.ch { return m, nil } + next := waitBridgeLog(m.act.logCtx, m.act.logCh) + if url := authURLFromLog(msg.line); url != "" { + if url == m.act.authURL { + return m, next // tsnet reprinting the same link + } + m.act.authURL = url + m.bridgeLogs = appendBridgeLog(m.bridgeLogs, bridgeAuthLogPrefix+url) + return m, tea.Batch(next, openURLCmd(m.act.id, url)) + } m.bridgeLogs = appendBridgeLog(m.bridgeLogs, msg.line) - return m, waitBridgeLog(m.act.logCtx, m.act.logCh) + return m, next + + case browserOpenMsg: + // Only the failure is worth a line: a browser that opened is on the + // user's screen, and the link itself is already in the log tail. + if m.act == nil || m.act.id != msg.id || msg.err == nil { + return m, nil + } + m.bridgeLogs = appendBridgeLog(m.bridgeLogs, "Could not open a browser here ("+msg.err.Error()+"). Open the link above to authorize.") + return m, nil case bridgeLogDoneMsg: if m.act != nil && m.act.logCh == msg.ch { @@ -674,8 +710,15 @@ func appendBridgeLog(logs []string, line string) []string { return logs } +// bridgeAuthLogPrefix labels the login link on the connect screen. It is also +// an importantBridgeLog prefix: the link is the one line the user must act on, +// and tsnet's own chatter would otherwise push it off the tail. +const bridgeAuthLogPrefix = "Authorize this bridge in your browser: " + func importantBridgeLog(line string) bool { for _, prefix := range []string{ + bridgeAuthLogPrefix, + "Could not open a browser here", "Bridge network:", "Bridge health:", "Bridge target ", diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 4177af2..89adf02 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1213,6 +1213,64 @@ func TestAppendBridgeLogRetainsDiagnosticsOverTsnetNoise(t *testing.T) { } } +func TestAuthURLFromLog(t *testing.T) { + const tsnetLine = "To start this tsnet server, restart with TS_AUTHKEY set, or go to: https://login.tailscale.com/a/17bceb7b0129ba" + for _, tt := range []struct { + line string + want string + }{ + {tsnetLine, "https://login.tailscale.com/a/17bceb7b0129ba"}, + {"magicsock: home is derp-1", ""}, + {"or go to: http://evil.example.com", ""}, + {"or go to: --version", ""}, + {"or go to: https://login.tailscale.com/a/x --flag", ""}, + } { + if got := authURLFromLog(tt.line); got != tt.want { + t.Errorf("authURLFromLog(%q) = %q, want %q", tt.line, got, tt.want) + } + } +} + +func TestBridgeAuthURLIsShownOnceAndOpened(t *testing.T) { + const tsnetLine = "To start this tsnet server, restart with TS_AUTHKEY set, or go to: https://login.tailscale.com/a/17bceb7b0129ba" + + ch := make(chan string, 1) + // Cancelled: waitBridgeLog then answers immediately, so a repeat log line + // can be distinguished from one that also dispatched a browser open + // without running the open itself. + ctx, cancel := context.WithCancel(context.Background()) + cancel() + m := &model{ + g: &config.Global{}, + act: &activation{id: 7, logCh: ch, logCtx: ctx}, + } + + _, cmd := m.Update(bridgeLogMsg{ch: ch, line: tsnetLine}) + if _, ok := cmd().(tea.BatchMsg); !ok { + t.Fatalf("first auth URL did not dispatch a browser open") + } + _, cmd = m.Update(bridgeLogMsg{ch: ch, line: tsnetLine}) + if _, ok := cmd().(tea.BatchMsg); ok { + t.Errorf("repeated auth URL dispatched a second browser open") + } + + if len(m.bridgeLogs) != 1 { + t.Fatalf("bridge logs = %q, want one line", m.bridgeLogs) + } + if want := bridgeAuthLogPrefix + "https://login.tailscale.com/a/17bceb7b0129ba"; m.bridgeLogs[0] != want { + t.Errorf("log line = %q, want %q", m.bridgeLogs[0], want) + } + + m.Update(browserOpenMsg{id: 7, err: errors.New("exec: \"xdg-open\": not found")}) + if len(m.bridgeLogs) != 2 || !strings.Contains(m.bridgeLogs[1], "Open the link above") { + 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) != 2 { + t.Errorf("a stale attempt's open failure was shown: %q", m.bridgeLogs) + } +} + 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) From 32a709161dcb6e5cb2ca1d9e4225c71d512d517f Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Wed, 16 Sep 2026 22:03:06 +0000 Subject: [PATCH 12/69] tui: show that a slow connection is still moving A bridge start that took over a minute looked hung: the attempt line is static, and between "Listening on " and the result the screen says nothing at all while the /v1/models request runs, which is up to 30s for a bridge. Nothing on screen distinguished that from a deadlock, and the run that prompted this did finish and launch its agent. So the attempt counts itself up once a second, and the bridge path logs the request it is waiting on before it makes it. A spinner was the alternative and carries less: the number is what tells you whether to keep waiting or press Esc. The tick is keyed to the attempt id and stops as soon as the screen changes, so a cancelled or superseded attempt cannot leave a timer running behind the menu. activateEndpoint now returns a batch, which is why the tests unwrap one. --- internal/tui/tui.go | 46 +++++++++++++++++++++++++++++++++++++--- internal/tui/tui_test.go | 43 ++++++++++++++++++++++++++++++++++--- 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index fffc829..10e3568 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -110,6 +110,7 @@ type activation struct { id int endpoint config.Endpoint label string + started time.Time cancel context.CancelFunc // ephemeral records that this flow is what put endpoint into settings, // so abandoning or overriding the attempt takes it back out instead of @@ -213,6 +214,16 @@ func openURLCmd(id int, url string) tea.Cmd { return func() tea.Msg { return browserOpenMsg{id: id, err: openURL(url)} } } +// activationTickMsg repaints the connect screen once a second so a slow +// attempt is visibly still running. Bringing a bridge up and then asking +// Aperture for its models can take tens of seconds during which nothing is +// logged, and a frozen screen is indistinguishable from 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 } func runPreflight(host string) tea.Cmd { @@ -270,6 +281,11 @@ func (m *model) activateEndpointCmd(ep config.Endpoint) tea.Cmd { // switchTailnet logs the bridge out before connecting, so the attempt starts // from a login prompt rather than the tailnet the node is already on. func (m *model) activateEndpoint(ep config.Endpoint, ephemeral, switchTailnet bool) tea.Cmd { + cmd := m.beginActivation(ep, ephemeral, switchTailnet) + return tea.Batch(cmd, activationTick(m.act.id)) +} + +func (m *model) beginActivation(ep config.Endpoint, ephemeral, switchTailnet bool) tea.Cmd { m.stopActivation() m.step = stepPreflight m.preflightErr = "" @@ -281,6 +297,7 @@ func (m *model) activateEndpoint(ep config.Endpoint, ephemeral, switchTailnet bo id: m.activationSeq, endpoint: ep, label: "Checking " + ep.URL + " ...", + started: time.Now(), cancel: cancel, ephemeral: ephemeral, } @@ -338,6 +355,10 @@ func (m *model) activateEndpoint(ep config.Endpoint, ephemeral, switchTailnet bo if err != nil { return endpointActivationResult{id: act.id, endpoint: ep, host: ep.URL, err: err} } + // The request below is the longest silent stretch of the whole + // attempt: the bridge is up, so tsnet has stopped logging, and + // nothing else names the host being waited on. + bridgeLogf("Asking " + ep.URL + " for its models ...") provs, err := fetchProvidersContext(ctx, localURL, bridgeProviderFetchTimeout) if err != nil { err = fmt.Errorf("bridge %s could not reach %s: %w", bridge.Name, ep.URL, err) @@ -607,6 +628,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.bridgeLogs = appendBridgeLog(m.bridgeLogs, msg.line) 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 itself is already in the log tail. @@ -638,8 +665,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.popToRoot() m.step = stepPreflight // No cancel handle: this re-check owns the screen until it answers. - m.act = &activation{label: "Checking " + m.g.ApertureHost + " ..."} - return m, runPreflight(m.g.ApertureHost) + m.activationSeq++ + m.act = &activation{id: m.activationSeq, label: "Checking " + m.g.ApertureHost + " ...", started: time.Now()} + return m, tea.Batch(runPreflight(m.g.ApertureHost), activationTick(m.act.id)) case menu.InstallDoneMsg: if msg.Err != nil { @@ -938,13 +966,25 @@ func (m *model) updatePreflight(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } +// 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) + "\n") + sb.WriteString(m.wrapText("", dotYellow+" "+label+activationElapsed(m.act)) + "\n") for _, line := range m.bridgeLogs { sb.WriteString(dimStyle.Render(m.wrapText(" ", line))) sb.WriteString("\n") diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 89adf02..b63b5bc 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1105,7 +1105,7 @@ func TestBridgeEndpointFailureKeepsPreviousEndpointActive(t *testing.T) { t.Fatalf("candidate endpoint was not saved: %+v", m.g.Settings.Endpoints) } - msg := res.Cmd() + msg := activationResult(t, res.Cmd) result, ok := msg.(endpointActivationResult) if !ok { t.Fatalf("activation message = %T", msg) @@ -1154,8 +1154,7 @@ func TestDirectEndpointIsPromotedOnlyAfterModelsSucceed(t *testing.T) { if got := m.g.ActiveEndpoint(); !sameEndpoint(got, old) { t.Fatalf("active endpoint changed before /v1/models: %+v", got) } - activation := cmd() - m.Update(activation) + m.Update(activationResult(t, cmd)) if got := m.g.ActiveEndpoint(); got.URL != srv.URL { t.Fatalf("active endpoint = %+v, want %q", got, srv.URL) } @@ -1164,6 +1163,44 @@ 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) + } +} + func TestBridgeLogSinkIgnoresLateLogsAfterCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) ch := make(chan string, 1) From 51e813e36a14725ad2b3080841c984a3ee4dffe5 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Wed, 16 Sep 2026 23:47:19 +0000 Subject: [PATCH 13/69] bridges: resolve a bridge target against the node's own peer map A first connection through a freshly started bridge hung for the full 30s fetch timeout and only worked on a manual retry. A SIGQUIT dump caught it: the dial was parked in tsdial.SystemDial on a host-network connect that never completes. tsnet's UserDial resolves MagicDNS from the node's netmap and falls through to the host resolver when the netmap has not landed yet. On a machine that is itself on a tailnet, that fallback answers: this box's own tailnet has a node called "ai" at 100.81.69.95, while the bridge's tailnet has one at 100.105.9.12. tsnet has no route for the foreign address, so it system-dials it and blackholes. Had that node been listening on 80, the bridge would have quietly proxied to the wrong tailnet instead, which is the worse half of the bug. So resolve the target through the node itself: poll its status until the target shows up as a peer, then dial that IP. A name that never appears is not necessarily broken (a subnet router or the tailnet's DNS can serve it), so after the window we still hand the name to tsnet, now with a log line saying the dial may leave the tailnet. This replaces dialWithDNSRetry, which retried only *net.DNSError. That was aimed at the same window but never fired here: the leaked lookup succeeded. Measured against the real tailnet, cold node, the peer map takes ~1.7s to arrive, so the 5s window has room; raising it would only lengthen the wait for targets that are legitimately not peers. Revisit if a slow link pushes a real target past it. --- internal/bridges/manager.go | 162 ++++++++++++++---- internal/bridges/manager_test.go | 277 ++++++++++++++++++++++--------- 2 files changed, 325 insertions(+), 114 deletions(-) diff --git a/internal/bridges/manager.go b/internal/bridges/manager.go index 53bfb68..aea0b5a 100644 --- a/internal/bridges/manager.go +++ b/internal/bridges/manager.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "net/http/httputil" + "net/netip" "net/url" "strings" "sync" @@ -23,7 +24,11 @@ type Manager struct { mu sync.Mutex debug bool - nodes map[string]*nodeRuntime + // 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 + nodes map[string]*nodeRuntime // tailnets is the network each running node logged in to, keyed by bridge // ID. Read back by the TUI to label a bridge with the tailnet it reaches. tailnets map[string]string @@ -32,8 +37,8 @@ type Manager struct { } const ( - bridgeDNSRetryWindow = 5 * time.Second - bridgeDNSRetryInterval = 250 * time.Millisecond + bridgePeerWaitWindow = 5 * time.Second + bridgePeerWaitInterval = 250 * time.Millisecond ) type nodeRuntime struct { @@ -94,9 +99,11 @@ func (n *tsnetNode) Close() error { // 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), - tailnets: make(map[string]string), + debug: debug, + peerWait: bridgePeerWaitWindow, + peerWaitInterval: bridgePeerWaitInterval, + nodes: make(map[string]*nodeRuntime), + tailnets: make(map[string]string), } m.newNode = func(bridge config.Bridge, stateDir string, userLogf, debugLogf func(string, ...any)) tailnetNode { s := &tsnet.Server{ @@ -156,7 +163,7 @@ func (m *Manager) Activate(ctx context.Context, bridge config.Bridge, remoteURL return proxy.localURL, nil } - proxy, err := startProxy(rt.node, target, logf, m.debug) + proxy, err := m.startProxy(rt.node, target, logf) if err != nil { return "", err } @@ -351,7 +358,8 @@ func parseTarget(raw string) (*url.URL, error) { return target, nil } -func startProxy(node tailnetNode, target *url.URL, logf func(string), debug bool) (*proxyRuntime, error) { +func (m *Manager) startProxy(node tailnetNode, target *url.URL, logf func(string)) (*proxyRuntime, error) { + debug := m.debug ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return nil, err @@ -363,13 +371,14 @@ func startProxy(node tailnetNode, target *url.URL, logf func(string), debug bool if debug { logf(fmt.Sprintf("Bridge dialing network=%s address=%s", network, address)) } - conn, attempts, err := dialWithDNSRetry( + conn, attempts, err := dialViaNode( ctx, - node.DialContext, + node, network, address, - bridgeDNSRetryWindow, - bridgeDNSRetryInterval, + logf, + m.peerWait, + m.peerWaitInterval, ) elapsed := time.Since(start).Round(time.Millisecond) if err != nil { @@ -408,49 +417,136 @@ func startProxy(node tailnetNode, target *url.URL, logf func(string), debug bool 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( +// dialViaNode dials address over the bridge's node, resolving a name against +// the node's own peer map first and dialing the IP it finds. +// +// Handing the name straight to tsnet is what made a first connection hang for +// 30s: until the node's netmap lands, tsnet's resolver falls through to the +// host resolver, and on a machine that is itself on a tailnet that answers +// with a same-named node on the *host's* tailnet. tsnet then sees an address +// it has no route for and system-dials it, so the bridge either blackholes +// until the fetch times out or, worse, proxies to the wrong tailnet's node. +// Resolving through the node cannot leave the bridge's tailnet, and waiting +// for the peer to appear is the same wait the old DNS retry was aiming at. +func dialViaNode( ctx context.Context, - dial bridgeDialFunc, + node tailnetNode, network, address string, - retryWindow, retryInterval time.Duration, + logf func(string), + peerWaitWindow, peerWaitInterval time.Duration, ) (net.Conn, int, error) { - deadline := time.Now().Add(retryWindow) + 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 + } + // Not every target is a tailnet node: a subnet router or the tailnet's + // own DNS can serve it. Those only resolve the way tsnet resolves, so + // fall through and say so, since this is the path that can leave the + // tailnet. + logf(fmt.Sprintf("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 { - conn, err := dial(ctx, network, address) + status, err := node.Status(ctx) attempts++ if err == nil { - return conn, attempts, 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 nil, attempts, ctxErr - } - var dnsErr *net.DNSError - if !errors.As(err, &dnsErr) || retryWindow <= 0 || retryInterval <= 0 { - return nil, attempts, err + return netip.Addr{}, attempts, ctxErr } remaining := time.Until(deadline) - if remaining <= 0 { - return nil, attempts, err + if remaining <= 0 || interval <= 0 { + return netip.Addr{}, attempts, err } - if retryInterval > remaining { - retryInterval = remaining + if interval > remaining { + interval = remaining } - timer := time.NewTimer(retryInterval) + timer := time.NewTimer(interval) select { case <-ctx.Done(): timer.Stop() - return nil, attempts, ctx.Err() + return netip.Addr{}, attempts, ctx.Err() case <-timer.C: } } } +// peerAddr returns the tailnet address the node has for host, matching either a +// peer's full MagicDNS name or its first label, the short form endpoint URLs +// usually carry. +func peerAddr(status *ipnstate.Status, host string) (netip.Addr, bool) { + if status == nil { + return netip.Addr{}, false + } + want := strings.ToLower(strings.TrimSuffix(host, ".")) + for _, peer := range status.Peer { + if peer == nil || !magicDNSNameMatches(peer.DNSName, want) { + continue + } + if ip, ok := preferIPv4(peer.TailscaleIPs); ok { + return ip, true + } + } + return netip.Addr{}, false +} + +func magicDNSNameMatches(dnsName, host string) bool { + name := strings.ToLower(strings.TrimSuffix(dnsName, ".")) + if name == "" { + return false + } + if name == host { + return true + } + label, _, _ := strings.Cut(name, ".") + return label == host +} + +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(logf func(string), status *ipnstate.Status, target *url.URL) { if status == nil { logf("Bridge network status is unavailable.") diff --git a/internal/bridges/manager_test.go b/internal/bridges/manager_test.go index aeff2aa..5c26268 100644 --- a/internal/bridges/manager_test.go +++ b/internal/bridges/manager_test.go @@ -9,17 +9,20 @@ import ( "net/http/httptest" "net/netip" "strings" + "sync" "sync/atomic" "testing" "time" "github.com/tailscale/aperture-cli/internal/config" "tailscale.com/ipn/ipnstate" + "tailscale.com/types/key" ) type fakeNode struct { backendAddr string status *ipnstate.Status + statusFn func() (*ipnstate.Status, error) upErr error statusErr error dialErr error @@ -28,6 +31,9 @@ type fakeNode struct { up int loggedOut int closed bool + + mu sync.Mutex + dialed []string } func (n *fakeNode) Up(context.Context) (*ipnstate.Status, error) { @@ -36,10 +42,16 @@ func (n *fakeNode) Up(context.Context) (*ipnstate.Status, error) { } 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, _ string) (net.Conn, error) { +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) } @@ -50,6 +62,28 @@ func (n *fakeNode) DialContext(ctx context.Context, network, _ string) (net.Conn return d.DialContext(ctx, network, n.backendAddr) } +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 { + 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")}, + Peer: map[key.NodePublic]*ipnstate.PeerStatus{ + key.NewNode().Public(): {DNSName: dnsName, TailscaleIPs: ips}, + }, + } +} + func TestActivateDebugDiagnostics(t *testing.T) { status := &ipnstate.Status{ BackendState: "Running", @@ -63,6 +97,7 @@ func TestActivateDebugDiagnostics(t *testing.T) { } node := &fakeNode{status: status, dialErr: errors.New("lookup aperture on 127.0.0.53:53: no such host")} m := NewManager(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 } @@ -135,6 +170,12 @@ func TestActivateNormalLoggingOmitsDebugDiagnostics(t *testing.T) { 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", @@ -177,34 +218,26 @@ func TestActivateNormalLoggingOmitsDebugDiagnostics(t *testing.T) { } } -func TestActivateRetriesDNSWhilePeerMapArrives(t *testing.T) { +// 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 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 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 } - var d net.Dialer - return d.DialContext(ctx, network, address) + return tailnetStatus("ai.example.ts.net.", "100.64.0.2"), nil } m := NewManager(true) + m.peerWaitInterval = time.Millisecond m.newNode = func(_ config.Bridge, _ string, _ func(string, ...any), _ func(string, ...any)) tailnetNode { return node } @@ -229,105 +262,184 @@ func TestActivateRetriesDNSWhilePeerMapArrives(t *testing.T) { 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 := 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, "attempts=2") { - t.Fatalf("logs missing recovered dial attempt count:\n%s", got) + if got := strings.Join(logs, "\n"); !strings.Contains(got, "remote=") { + t.Fatalf("logs missing the connected dial:\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://")) - } +func TestDialViaNode(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer backend.Close() + backendAddr := backend.Listener.Addr().String() + discard := func(string) {} - conn, gotAttempts, err := dialWithDNSRetry( - context.Background(), dial, "tcp", "ai:80", time.Second, time.Millisecond, + 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 gotAttempts != 2 { - t.Fatalf("attempts = %d, want 2", gotAttempts) + 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("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 + 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 } - _, gotAttempts, err := dialWithDNSRetry( - context.Background(), dial, "tcp", "ai:80", 5*time.Millisecond, time.Hour, + conn, attempts, err := dialViaNode( + context.Background(), node, "tcp", "ai:80", discard, time.Second, time.Millisecond, ) - if !errors.Is(err, wantErr) { - t.Fatalf("error = %v, want %v", err, wantErr) + if err != nil { + t.Fatal(err) + } + conn.Close() + if attempts != 3 { + t.Errorf("status polls = %d, want 3", attempts) } - if gotAttempts < 2 || gotAttempts != attempts { - t.Fatalf("attempts = %d/%d, want at least 2 matching attempts", gotAttempts, 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("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 + t.Run("falls back to the 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", "ai:80", + func(line string) { logs = append(logs, line) }, + 5*time.Millisecond, time.Millisecond, + ) + if err != nil { + t.Fatal(err) } + conn.Close() + if got := node.dialedAddrs(); len(got) != 1 || got[0] != "ai:80" { + t.Errorf("dialed %v, want [ai:80]", 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 + }} - _, gotAttempts, err := dialWithDNSRetry( - context.Background(), dial, "tcp", "ai:80", time.Second, time.Millisecond, + conn, _, err := dialViaNode( + context.Background(), node, "tcp", "100.64.0.2:80", discard, time.Second, time.Millisecond, ) - if !errors.Is(err, wantErr) { - t.Fatalf("error = %v, want %v", err, wantErr) + if err != nil { + t.Fatal(err) } - if gotAttempts != 1 || attempts != 1 { - t.Fatalf("attempts = %d/%d, want 1/1", gotAttempts, attempts) + 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()) - attempts := 0 - dial := func(context.Context, string, string) (net.Conn, error) { - attempts++ + node := &fakeNode{backendAddr: backendAddr} + node.statusFn = func() (*ipnstate.Status, error) { cancel() - return nil, &net.DNSError{Err: "server misbehaving", Name: "ai"} + return &ipnstate.Status{BackendState: "Running"}, nil } - _, gotAttempts, err := dialWithDNSRetry( - ctx, dial, "tcp", "ai:80", time.Second, time.Second, - ) + _, 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 gotAttempts != 1 || attempts != 1 { - t.Fatalf("attempts = %d/%d, want 1/1", gotAttempts, attempts) + 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() @@ -432,7 +544,10 @@ func activate(t *testing.T, backend *httptest.Server) activatedFixture { 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()} + f.node = &fakeNode{ + backendAddr: backend.Listener.Addr().String(), + status: tailnetStatus("aperture.tailnet.", "100.64.0.2"), + } return f.node } From 0370c8393caedf9b409587cc35f82fa6c11fdf25 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Thu, 17 Sep 2026 00:22:36 +0000 Subject: [PATCH 14/69] bridges: surface a bridge's login link from the IPN bus, not tsnet's poll A bridge that had never logged in sat on "LocalBackend state is NeedsLogin" for 16s and got SIGQUIT'd as hung. It was not hung: its logtail buffer shows the control plane answered with an auth URL 300ms before the kill, and tsnet only prints that URL from printAuthURLLoop, a 5s poll. Measured against a fresh node, the bus has the link at 4.04s and tsnet prints it at 5.02s, so the link can be a full poll interval late on top of however long registration took, with nothing on screen saying what is being waited for. Watching the bus alongside Up costs one goroutine that ends with the activation. Leaving it to tsnet would have meant either living with the 5s window or polling Status ourselves, which is the same information arriving later. Revisit if tsnet grows a callback for this. --- internal/bridges/manager.go | 69 +++++++++++++++++++++++++++++++ internal/bridges/manager_test.go | 71 ++++++++++++++++++++++++++++++++ internal/tui/browser.go | 27 ++++++++---- internal/tui/tui.go | 2 +- internal/tui/tui_test.go | 4 ++ 5 files changed, 164 insertions(+), 9 deletions(-) diff --git a/internal/bridges/manager.go b/internal/bridges/manager.go index aea0b5a..e3a393c 100644 --- a/internal/bridges/manager.go +++ b/internal/bridges/manager.go @@ -15,6 +15,8 @@ import ( "time" "github.com/tailscale/aperture-cli/internal/config" + "tailscale.com/client/local" + "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" "tailscale.com/tsnet" ) @@ -56,10 +58,16 @@ type tailnetNode interface { Up(context.Context) (*ipnstate.Status, error) Status(context.Context) (*ipnstate.Status, error) DialContext(context.Context, string, string) (net.Conn, error) + WatchLogin(context.Context, func(string)) Logout(context.Context) error Close() error } +// AuthLogPrefix labels the login link in a bridge's activation log. Callers +// parse it back out of the log stream to open a browser, so the text is part +// of this package's API rather than a message that can be reworded freely. +const AuthLogPrefix = "Authorize this bridge in your browser: " + type tsnetNode struct { server *tsnet.Server } @@ -80,6 +88,59 @@ func (n *tsnetNode) DialContext(ctx context.Context, network, address string) (n return n.server.Dial(ctx, network, address) } +// WatchLogin logs what an interactive login is waiting on, until ctx is done. +// +// tsnet surfaces the login link from a five second poll loop of its own, so a +// link that lands just after a tick stays invisible for most of that window. +// A bridge that took sixteen seconds to register showed the user nothing but +// "NeedsLogin" and got killed a few hundred milliseconds before the link would +// have been printed. The IPN bus has the link the moment the control plane +// answers, so watch that instead of waiting for tsnet to notice. +func (n *tsnetNode) WatchLogin(ctx context.Context, logf func(string)) { + // A cancelled watch is how this returns on every connection that works, + // so only a failure the caller did not ask for is worth a line. + report := func(err error) { + if err != nil && ctx.Err() == nil { + logf("Could not watch the bridge's login state: " + err.Error()) + } + } + + // LocalClient calls Start, so this blocks until the node is initialized, + // the same bring-up Up is waiting on in parallel. + lc, err := n.server.LocalClient() + if err != nil { + report(err) + return + } + watcher, err := lc.WatchIPNBus(ctx, ipn.NotifyInitialState) + if err != nil { + report(err) + return + } + defer watcher.Close() + report(reportLogin(watcher, logf)) +} + +// reportLogin logs login progress from an IPN bus watch until it ends. +func reportLogin(watcher *local.IPNBusWatcher, logf func(string)) error { + announced := false + for { + notify, err := watcher.Next() + if err != nil { + return err + } + if notify.State != nil && *notify.State == ipn.NeedsLogin && !announced { + // Otherwise the wait for the control plane to answer is silent, + // and the only thing on screen is tsnet's "NeedsLogin". + announced = true + logf("This bridge is not logged in to a tailnet yet. Waiting for a login link ...") + } + if notify.BrowseToURL != nil { + logf(AuthLogPrefix + *notify.BrowseToURL) + } + } +} + // Logout drops the node's tailnet credentials. The node must be running: the // login state lives behind its in-process LocalAPI, so logging out is how the // node leaves the tailnet it is on rather than reusing it on the next start. @@ -203,6 +264,14 @@ func (m *Manager) runningNode(ctx context.Context, bridge config.Bridge, logf fu m.mu.Unlock() logf("Starting bridge " + bridge.Name + " (" + bridge.ID + ")") + + // Up blocks until the node is Running, which for a bridge that has never + // logged in means blocking until the user visits a link nothing has shown + // them yet. The watch runs alongside it and ends with it. + watchCtx, stopWatch := context.WithCancel(ctx) + defer stopWatch() + go rt.node.WatchLogin(watchCtx, logf) + status, err := rt.node.Up(ctx) if err != nil { m.mu.Lock() diff --git a/internal/bridges/manager_test.go b/internal/bridges/manager_test.go index 5c26268..fa81167 100644 --- a/internal/bridges/manager_test.go +++ b/internal/bridges/manager_test.go @@ -23,6 +23,8 @@ type fakeNode struct { backendAddr string status *ipnstate.Status statusFn func() (*ipnstate.Status, error) + watchFn func(logf func(string)) + upFn func() upErr error statusErr error dialErr error @@ -38,6 +40,9 @@ type fakeNode struct { func (n *fakeNode) Up(context.Context) (*ipnstate.Status, error) { n.up++ + if n.upFn != nil { + n.upFn() + } return n.status, n.upErr } @@ -62,6 +67,15 @@ func (n *fakeNode) DialContext(ctx context.Context, network, address string) (ne return d.DialContext(ctx, network, n.backendAddr) } +// WatchLogin stands in for the IPN bus watch: watchFn is what a test wants the +// bus to report, and it runs until the manager cancels the watch. +func (n *fakeNode) WatchLogin(ctx context.Context, logf func(string)) { + if n.watchFn != nil { + n.watchFn(logf) + } + <-ctx.Done() +} + func (n *fakeNode) dialedAddrs() []string { n.mu.Lock() defer n.mu.Unlock() @@ -270,6 +284,63 @@ func TestActivateWaitsForPeerMapBeforeDialing(t *testing.T) { } } +// TestActivateLogsLoginLinkWhileUpBlocks covers the bridge that looked hung: a +// node that has never logged in blocks in Up until someone visits a link, so +// the link has to reach the log while Up is still blocked, not after it. +func TestActivateLogsLoginLinkWhileUpBlocks(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer backend.Close() + + const url = "https://login.tailscale.com/a/28ba393017981" + watched := make(chan struct{}) + node := &fakeNode{ + backendAddr: backend.Listener.Addr().String(), + status: tailnetStatus("ai.example.ts.net.", "100.64.0.2"), + } + node.watchFn = func(logf func(string)) { + logf(AuthLogPrefix + url) + close(watched) + } + // Up stands in for the wait on an interactive login, and gives up so a + // manager that never watches fails the assertion instead of hanging. + node.upFn = func() { + select { + case <-watched: + case <-time.After(2 * time.Second): + } + } + + m := NewManager(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 := m.Activate( + context.Background(), + config.Bridge{ID: "bridge-abcdef", Name: "Work"}, + "http://ai", + func(line string) { + mu.Lock() + defer mu.Unlock() + logs = append(logs, line) + }, + ); err != nil { + t.Fatal(err) + } + + mu.Lock() + defer mu.Unlock() + for _, line := range logs { + if line == AuthLogPrefix+url { + return + } + } + 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() diff --git a/internal/tui/browser.go b/internal/tui/browser.go index 12e25fa..153ab75 100644 --- a/internal/tui/browser.go +++ b/internal/tui/browser.go @@ -4,6 +4,8 @@ import ( "os/exec" "runtime" "strings" + + "github.com/tailscale/aperture-cli/internal/bridges" ) // tsnetAuthURLMarker is what tsnet logs ahead of the login link while a bridge @@ -11,20 +13,29 @@ import ( // It repeats the whole line every few seconds until login completes. const tsnetAuthURLMarker = "or go to: " +// authURLMarkers are the two phrasings that carry a login link. The bridge +// manager emits its line the moment the IPN bus has the link; tsnet's own +// line comes out of a five second poll, so it usually repeats one that is +// already on screen. +var authURLMarkers = []string{bridges.AuthLogPrefix, tsnetAuthURLMarker} + // authURLFromLog returns the Tailscale login link a bridge log line carries, // or "" when it carries none. The https:// requirement is not cosmetic: the // result is handed to a desktop opener, and anything else (a file path, a // leading dash) is not a link the user asked us to follow. func authURLFromLog(line string) string { - _, rest, ok := strings.Cut(line, tsnetAuthURLMarker) - if !ok { - return "" - } - url := strings.TrimSpace(rest) - if !strings.HasPrefix(url, "https://") || strings.ContainsAny(url, " \t") { - return "" + for _, marker := range authURLMarkers { + _, rest, ok := strings.Cut(line, marker) + if !ok { + continue + } + url := strings.TrimSpace(rest) + if !strings.HasPrefix(url, "https://") || strings.ContainsAny(url, " \t") { + return "" + } + return url } - return url + return "" } // openURL asks the desktop to open a link. Start, not Run: the opener can diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 10e3568..ec96ab5 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -741,7 +741,7 @@ func appendBridgeLog(logs []string, line string) []string { // bridgeAuthLogPrefix labels the login link on the connect screen. It is also // an importantBridgeLog prefix: the link is the one line the user must act on, // and tsnet's own chatter would otherwise push it off the tail. -const bridgeAuthLogPrefix = "Authorize this bridge in your browser: " +const bridgeAuthLogPrefix = bridges.AuthLogPrefix func importantBridgeLog(line string) bool { for _, prefix := range []string{ diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index b63b5bc..64feab7 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -13,6 +13,7 @@ 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/menu" @@ -1257,6 +1258,9 @@ func TestAuthURLFromLog(t *testing.T) { want string }{ {tsnetLine, "https://login.tailscale.com/a/17bceb7b0129ba"}, + // The manager's own line, which beats tsnet's by up to five seconds. + {bridges.AuthLogPrefix + "https://login.tailscale.com/a/17bceb7b0129ba", "https://login.tailscale.com/a/17bceb7b0129ba"}, + {bridges.AuthLogPrefix + "http://evil.example.com", ""}, {"magicsock: home is derp-1", ""}, {"or go to: http://evil.example.com", ""}, {"or go to: --version", ""}, From dc681406af89e6f4428bac956999da3c81bb593b Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Thu, 17 Sep 2026 00:37:38 +0000 Subject: [PATCH 15/69] tui: give the login link a footer and a copy button The browser open is unreachable over SSH and fails invisibly: xdg-open exists on the remote box so Start succeeds, then it exits 3 ("no method available") a moment later, by which time nothing is watching. The link was a dim line in a log tail that tsnet keeps pushing around, so the one thing the user has to act on looked like chatter. The link now has the foot of the connect screen to itself, in the palette's bright green on a dark terminal and its plain green on a light one, with a copy button next to it. Copying goes over OSC 52 rather than xclip/pbcopy: the clipboard that matters belongs to the terminal the user is looking at, which over SSH is not the machine aperture runs on. tmux and screen get their passthrough wrapping. Mouse reporting is only on while that button is showing. Leaving it on costs the terminal's own click-drag selection everywhere else, which is a bad trade on screens full of URLs and error text. The click hit test matches columns and ignores the row: this TUI renders inline, so the row the footer landed on is not knowable from the model. Worth revisiting if it ever moves to the alternate screen. --- go.mod | 2 +- internal/tui/browser.go | 31 ++++++++- internal/tui/tui.go | 147 ++++++++++++++++++++++++++++++++++++--- internal/tui/tui_test.go | 133 ++++++++++++++++++++++++++++++----- 4 files changed, 286 insertions(+), 27 deletions(-) diff --git a/go.mod b/go.mod index b81f9b8..c7b7def 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ 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 @@ -13,7 +14,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 diff --git a/internal/tui/browser.go b/internal/tui/browser.go index 153ab75..9cdedcc 100644 --- a/internal/tui/browser.go +++ b/internal/tui/browser.go @@ -1,10 +1,12 @@ package tui import ( + "os" "os/exec" "runtime" "strings" + "github.com/aymanbagabas/go-osc52/v2" "github.com/tailscale/aperture-cli/internal/bridges" ) @@ -41,7 +43,8 @@ func authURLFromLog(line string) string { // openURL asks the desktop to open a link. Start, not Run: the opener can // block for as long as the browser it launches lives, and a headless box // fails here by not having an opener at all, which Start already reports. -func openURL(url string) error { +// Overridable in tests, which must not launch a browser. +var openURL = func(url string) error { var cmd *exec.Cmd switch runtime.GOOS { case "darwin": @@ -59,3 +62,29 @@ func openURL(url string) error { go cmd.Wait() // reap it; the opener outlives this call return nil } + +// copyToClipboard puts s on the clipboard of whatever terminal is displaying +// this TUI, over OSC 52. A local clipboard helper (xclip, pbcopy) would put it +// on the clipboard of the machine aperture runs on, which over SSH is the +// wrong machine and the one case where the user most needs the link: the +// escape sequence travels back up the SSH session to the terminal the user is +// actually looking at. Overridable in tests, which have no terminal to write +// escape sequences at. +// +// Terminals that don't implement OSC 52 (or have it off, which some do by +// default for paste-injection reasons) drop the sequence silently, so a nil +// error here means sent, not pasted. +var copyToClipboard = func(s string) error { + seq := osc52.New(s) + // tmux and screen eat escape sequences they don't recognize, so the + // passthrough wrapping is what gets this 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/tui.go b/internal/tui/tui.go index ec96ab5..4b1dec4 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -40,6 +40,10 @@ var ( errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) dimStyle = lipgloss.NewStyle().Faint(true) greenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + // authStyle is the login link at the foot of the connect screen: the + // palette's bright green on a dark terminal, its 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("●") @@ -97,6 +101,12 @@ type model struct { bridgeLogs []string failedEndpoint *config.Endpoint connected bool + + // mouseOn tracks whether mouse reporting is currently enabled. It is only + // on while the login link's copy button is on screen: with reporting on, + // the terminal's own click-drag selection needs a Shift the user has no + // reason to expect, and every other screen here is text worth selecting. + mouseOn bool } // activation is the connection attempt currently on screen. It owns the @@ -123,6 +133,10 @@ type activation struct { // log tail from filling with one repeated URL and the browser from being // opened 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 @@ -214,6 +228,16 @@ 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. Bringing a bridge up and then asking // Aperture for its models can take tens of seconds during which nothing is @@ -545,7 +569,33 @@ func (m *model) quitCmd() tea.Cmd { } } +// Update handles a message and then reconciles mouse reporting with what is on +// screen. Doing it here rather than at each transition is what keeps reporting +// from being left on by a path nobody thought about: every way off the connect +// screen goes through this function. func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + next, cmd := m.update(msg) + if mouse := m.syncMouse(); mouse != nil { + return next, tea.Batch(cmd, mouse) + } + return next, cmd +} + +// syncMouse returns the command that turns mouse reporting on or off, or nil +// when it already matches the screen. +func (m *model) syncMouse() tea.Cmd { + want := m.step == stepPreflight && m.act != nil && m.act.authURL != "" + if want == m.mouseOn { + return nil + } + m.mouseOn = want + if want { + return tea.EnableMouseCellMotion + } + return tea.DisableMouse +} + +func (m *model) update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width @@ -621,8 +671,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if url == m.act.authURL { return m, next // tsnet reprinting 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.bridgeLogs = appendBridgeLog(m.bridgeLogs, bridgeAuthLogPrefix+url) + m.act.copied = false return m, tea.Batch(next, openURLCmd(m.act.id, url)) } m.bridgeLogs = appendBridgeLog(m.bridgeLogs, msg.line) @@ -636,13 +688,30 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case browserOpenMsg: // Only the failure is worth a line: a browser that opened is on the - // user's screen, and the link itself is already in the log tail. + // user's screen, and the link itself is already at the foot of this + // one. Over SSH this is the common case, not an edge case: the remote + // box has an opener that exits "no method available" a moment after it + // starts, or none at all. if m.act == nil || m.act.id != msg.id || msg.err == nil { return m, nil } - m.bridgeLogs = appendBridgeLog(m.bridgeLogs, "Could not open a browser here ("+msg.err.Error()+"). Open the link above to authorize.") + m.bridgeLogs = appendBridgeLog(m.bridgeLogs, "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, "Could not copy the link ("+msg.err.Error()+"). Select it with the mouse instead.") + return m, nil + } + m.act.copied = true return m, nil + case tea.MouseMsg: + return m.updateMouse(msg) + case bridgeLogDoneMsg: if m.act != nil && m.act.logCh == msg.ch { m.act.logCh = nil @@ -738,15 +807,10 @@ func appendBridgeLog(logs []string, line string) []string { return logs } -// bridgeAuthLogPrefix labels the login link on the connect screen. It is also -// an importantBridgeLog prefix: the link is the one line the user must act on, -// and tsnet's own chatter would otherwise push it off the tail. -const bridgeAuthLogPrefix = bridges.AuthLogPrefix - func importantBridgeLog(line string) bool { for _, prefix := range []string{ - bridgeAuthLogPrefix, "Could not open a browser here", + "Could not copy the link", "Bridge network:", "Bridge health:", "Bridge target ", @@ -966,6 +1030,66 @@ func (m *model) updatePreflight(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } +// authCopyLabel and authCopiedLabel are the copy button beside the login link, +// before and after a click. The glyph alone is not a word anyone reads as +// "clickable", so it carries one. +const ( + authCopyLabel = "⧉ copy" + authCopiedLabel = "✓ copied" +) + +// authFooter renders the login link pinned to the foot of the connect screen, +// and reports the terminal columns its copy button occupies. +// +// Only columns: a mouse click carries an absolute terminal row, and this TUI +// renders inline rather than in the alternate screen, so the row the footer +// landed on is not knowable from here. A click in the button's columns on some +// other row copies a link the user was asking for anyway. +// +// ponytail: column-only hit test, row-accurate if this ever moves to altscreen. +func (m *model) authFooter() (text string, startCol, endCol int) { + act := m.act + if act == nil || act.authURL == "" { + return "", 0, 0 + } + button := authCopyLabel + if act.copied { + button = authCopiedLabel + } + link := m.wrapText("", "Authorize this bridge in your browser: "+act.authURL) + lastLine := link[strings.LastIndex(link, "\n")+1:] + sep := " " + startCol = ansi.StringWidth(lastLine) + 1 + if m.width > 0 && startCol+ansi.StringWidth(button) > m.width { + sep = "\n" + startCol = 0 + } + // 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. + lines := strings.Split(link+sep+button, "\n") + for i, line := range lines { + lines[i] = authStyle.Render(line) + } + return strings.Join(lines, "\n"), startCol, startCol + ansi.StringWidth(button) +} + +// updateMouse turns a click on the copy button into a clipboard write. Mouse +// reporting is only on while that button is showing, so there is nothing else +// on screen a click could mean. +func (m *model) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft { + return m, nil + } + if m.step != stepPreflight || m.act == nil || m.act.authURL == "" { + return m, nil + } + _, startCol, endCol := m.authFooter() + if msg.X < startCol || msg.X >= endCol { + return m, nil + } + return m, copyURLCmd(m.act.id, m.act.authURL) +} + // 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 { @@ -1005,6 +1129,11 @@ func (m *model) viewPreflight() string { 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() } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 64feab7..298c158 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1272,46 +1272,147 @@ func TestAuthURLFromLog(t *testing.T) { } } +// 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) + } + } +} + +const testAuthURL = "https://login.tailscale.com/a/17bceb7b0129ba" + func TestBridgeAuthURLIsShownOnceAndOpened(t *testing.T) { - const tsnetLine = "To start this tsnet server, restart with TS_AUTHKEY set, or go to: https://login.tailscale.com/a/17bceb7b0129ba" + const tsnetLine = "To start this tsnet server, restart with TS_AUTHKEY set, or go to: " + testAuthURL + + var opened []string + orig := openURL + openURL = func(url string) error { + opened = append(opened, url) + return nil + } + t.Cleanup(func() { openURL = orig }) ch := make(chan string, 1) - // Cancelled: waitBridgeLog then answers immediately, so a repeat log line - // can be distinguished from one that also dispatched a browser open - // without running the open itself. + // 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()) cancel() m := &model{ - g: &config.Global{}, - act: &activation{id: 7, logCh: ch, logCtx: ctx}, + g: &config.Global{}, + width: 100, + act: &activation{id: 7, logCh: ch, logCtx: ctx}, } _, cmd := m.Update(bridgeLogMsg{ch: ch, line: tsnetLine}) - if _, ok := cmd().(tea.BatchMsg); !ok { - t.Fatalf("first auth URL did not dispatch a browser open") + 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: tsnetLine}) - if _, ok := cmd().(tea.BatchMsg); ok { - t.Errorf("repeated auth URL dispatched a second browser open") + runCmd(t, cmd) + if len(opened) != 1 { + t.Errorf("repeated auth URL opened the browser again: %q", opened) } - if len(m.bridgeLogs) != 1 { - t.Fatalf("bridge logs = %q, want one line", m.bridgeLogs) + // 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) } - if want := bridgeAuthLogPrefix + "https://login.tailscale.com/a/17bceb7b0129ba"; m.bridgeLogs[0] != want { - t.Errorf("log line = %q, want %q", m.bridgeLogs[0], want) + footer, _, _ := m.authFooter() + if want := "Authorize this bridge in your browser: " + testAuthURL; !strings.Contains(ansi.Strip(footer), want) { + t.Errorf("footer = %q, want it to contain %q", ansi.Strip(footer), want) } m.Update(browserOpenMsg{id: 7, err: errors.New("exec: \"xdg-open\": not found")}) - if len(m.bridgeLogs) != 2 || !strings.Contains(m.bridgeLogs[1], "Open the link above") { + if len(m.bridgeLogs) != 1 || !strings.Contains(m.bridgeLogs[0], "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) != 2 { + if len(m.bridgeLogs) != 1 { t.Errorf("a stale attempt's open failure was shown: %q", m.bridgeLogs) } } +// TestAuthFooterCopyButton covers the SSH case: no browser opens there, so the +// only way to the link is the terminal's own clipboard, over OSC 52. +func TestAuthFooterCopyButton(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, + act: &activation{id: 3, authURL: testAuthURL}, + } + _, startCol, endCol := m.authFooter() + if startCol <= 0 || endCol <= startCol { + t.Fatalf("copy button columns = [%d,%d), want a range past the link", startCol, endCol) + } + + click := func(x int) tea.Cmd { + _, cmd := m.Update(tea.MouseMsg{X: x, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft}) + return cmd + } + runCmd(t, click(startCol-1)) + runCmd(t, click(endCol)) + if len(copies) != 0 { + t.Errorf("a click beside the button copied: %q", copies) + } + if !m.mouseOn { + t.Error("mouse reporting is off, so no click can reach the copy button") + } + + runCmd(t, click(startCol)) + if len(copies) != 1 || copies[0] != testAuthURL { + t.Fatalf("clipboard = %q, want one copy of %q", copies, testAuthURL) + } + m.Update(clipboardMsg{id: 3}) + if footer, _, _ := m.authFooter(); !strings.Contains(footer, authCopiedLabel) { + t.Errorf("footer = %q, want it to confirm the copy", ansi.Strip(footer)) + } + + // Off the connect screen the button is gone, and the terminal gets its own + // click-drag selection back. + m.step = stepMenu + m.Update(activationTickMsg{id: 3}) + if m.mouseOn { + t.Error("mouse reporting stayed on after the copy button left the screen") + } +} + +// TestAuthFooterWrapsButtonToItsOwnLine keeps the click target on screen when +// the link alone fills the terminal. +func TestAuthFooterWrapsButtonToItsOwnLine(t *testing.T) { + m := &model{ + g: &config.Global{}, + width: len("Authorize this bridge in your browser: " + testAuthURL), + act: &activation{id: 3, authURL: testAuthURL}, + } + footer, startCol, endCol := m.authFooter() + if startCol != 0 { + t.Errorf("copy button starts at column %d, want the start of its own line", startCol) + } + if endCol > m.width { + t.Errorf("copy button ends at column %d, past the %d column terminal", endCol, m.width) + } + if last := ansi.Strip(footer[strings.LastIndex(footer, "\n")+1:]); last != authCopyLabel { + t.Errorf("last footer line = %q, want just the copy button", last) + } +} + 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) From b4d10457e0e6406cb0eff667b613bd67c42678d5 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Thu, 17 Sep 2026 00:53:47 +0000 Subject: [PATCH 16/69] tui: stamp every bridge log line with how far into the attempt it landed A bridge that had never connected took 29s to come up and the log gave no way to say where the time went. Three changes have now been aimed at that wait (the IPN bus watch, the peer-map resolve, the progress tick), each picked from a symptom, because the connect screen prints an unordered bag of strings: "waiting for a login link" and "Bridge connected." sit on adjacent lines whether the gap was 200ms or half a minute. The goroutine dump from this one shows the node parked in the control plane's first /machine/register with no followup URL, so the wait was upstream of every line we print, which is exactly the thing the log should have said. Stamped at the sink rather than where the message is handled: tsnet logs arrive in bursts and a stamp read after the channel queue attributes the queueing delay to the wrong line. Carrying the elapsed time as a field rather than a prefix keeps importantBridgeLog matching on text. Revisit when the log stream becomes typed events; the stamp belongs on the event then, not on a rendered line. --- internal/tui/tui.go | 64 ++++++++++++++++++++++++++++------------ internal/tui/tui_test.go | 60 ++++++++++++++++++++++++++----------- 2 files changed, 87 insertions(+), 37 deletions(-) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 4b1dec4..9d68d3a 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -98,7 +98,7 @@ type model struct { activationSeq int preflightErr string forcedToEndpoint bool // true when preflight failure dropped user on endpoints menu - bridgeLogs []string + bridgeLogs []bridgeLine failedEndpoint *config.Endpoint connected bool @@ -126,7 +126,7 @@ type activation struct { // so abandoning or overriding the attempt takes it back out instead of // leaving an endpoint nobody chose. ephemeral bool - logCh chan string + logCh chan bridgeLine logCtx context.Context // authURL is the Tailscale login link already surfaced for this attempt. // tsnet reprints its line every few seconds, so this is what keeps the @@ -142,6 +142,12 @@ type activation struct { override textField } +// 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), text: text} +} + // cancelable reports whether Esc can interrupt this attempt. func (a *activation) cancelable() bool { return a != nil && a.cancel != nil } @@ -210,11 +216,27 @@ type endpointActivationResult struct { err error } +// bridgeLine is one activation log line and how far into the attempt it was +// produced. The elapsed time is the reason this is a struct and not a string: +// a bridge that takes half a minute to come up spends that time in one of +// three places (the control plane answering with a login link, the user in the +// browser, the first dial), and an unstamped log cannot tell them apart. Three +// separate fixes have now been aimed at that wait without knowing which. +type bridgeLine struct { + elapsed time.Duration + text string +} + +// String renders a log line the way the connect screen shows it. +func (l bridgeLine) String() string { + return fmt.Sprintf("+%-6s %s", l.elapsed.Round(100*time.Millisecond), l.text) +} + type bridgeLogMsg struct { - ch chan string - line string + ch chan bridgeLine + line bridgeLine } -type bridgeLogDoneMsg struct{ ch chan string } +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 @@ -357,14 +379,14 @@ func (m *model) beginActivation(ep config.Endpoint, ephemeral, switchTailnet boo } } - ch := make(chan string, 32) + ch := make(chan bridgeLine, 32) act.logCh = ch act.logCtx = ctx act.label = "Connecting bridge " + bridge.Name + " to " + ep.URL + " ..." if switchTailnet { act.label = "Switching bridge " + bridge.Name + " to a different tailnet ..." } - bridgeLogf := bridgeLogSink(ctx, ch) + bridgeLogf := bridgeLogSink(ctx, ch, act.started) activate := func() tea.Msg { defer cancel() // Inside the attempt, so it shares the attempt's cancellation and log @@ -514,12 +536,16 @@ func (m *model) overrideActivationURL(value string) (tea.Model, tea.Cmd) { return m, m.activateEndpoint(next, ephemeral, false) } -func bridgeLogSink(ctx context.Context, ch chan<- string) func(string) { - return func(line string) { - line = strings.TrimSpace(line) - if line == "" { +func bridgeLogSink(ctx context.Context, ch chan<- bridgeLine, started time.Time) func(string) { + return func(text string) { + text = strings.TrimSpace(text) + if text == "" { 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), text: text} select { case <-ctx.Done(): return @@ -533,7 +559,7 @@ func bridgeLogSink(ctx context.Context, ch chan<- string) func(string) { } } -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 @@ -667,7 +693,7 @@ func (m *model) update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } next := waitBridgeLog(m.act.logCtx, m.act.logCh) - if url := authURLFromLog(msg.line); url != "" { + if url := authURLFromLog(msg.line.text); url != "" { if url == m.act.authURL { return m, next // tsnet reprinting the same link } @@ -695,7 +721,7 @@ func (m *model) update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.act == nil || m.act.id != msg.id || msg.err == nil { return m, nil } - m.bridgeLogs = appendBridgeLog(m.bridgeLogs, "Could not open a browser here ("+msg.err.Error()+"). Use the link below to authorize.") + 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: @@ -703,7 +729,7 @@ func (m *model) update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } if msg.err != nil { - m.bridgeLogs = appendBridgeLog(m.bridgeLogs, "Could not copy the link ("+msg.err.Error()+"). Select it with the mouse instead.") + m.bridgeLogs = appendBridgeLog(m.bridgeLogs, m.act.logLine("Could not copy the link ("+msg.err.Error()+"). Select it with the mouse instead.")) return m, nil } m.act.copied = true @@ -792,12 +818,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 !importantBridgeLog(line.text) { drop = i break } @@ -1110,7 +1136,7 @@ func (m *model) viewPreflight() string { 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))) + sb.WriteString(dimStyle.Render(m.wrapText(" ", line.String()))) sb.WriteString("\n") } switch { @@ -1387,7 +1413,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" diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 298c158..682365e 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1202,10 +1202,31 @@ func TestActivationTickRunsOnlyWhileConnecting(t *testing.T) { } } +// 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) + logf := bridgeLogSink(context.Background(), ch, time.Now().Add(-12500*time.Millisecond)) + logf(" Bridge connected. ") + + line := <-ch + if line.text != "Bridge connected." { + t.Errorf("text = %q, want it trimmed", line.text) + } + 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) + logf := bridgeLogSink(ctx, ch, time.Now()) cancel() close(ch) @@ -1216,8 +1237,8 @@ func TestBridgeLogSinkIgnoresLateLogsAfterCancellation(t *testing.T) { 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{text: "final dial error"} cancel() msg := waitBridgeLog(ctx, ch)() @@ -1225,25 +1246,28 @@ 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.text != "final dial error" { + t.Errorf("line = %q, want final dial error", logMsg.line.text) } } 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{ + {text: `Bridge network: state=Running tailnet="example.com" peers=598`}, + {text: `Bridge target is visible: requested="aperture.example.ts.net"`}, } for i := range bridgeLogLimit + 10 { - logs = appendBridgeLog(logs, fmt.Sprintf("magicsock: noisy line %d", i)) + logs = appendBridgeLog(logs, bridgeLine{text: fmt.Sprintf("magicsock: noisy line %d", i)}) } - logs = appendBridgeLog(logs, "Bridge dial failed: lookup failed") + logs = appendBridgeLog(logs, bridgeLine{text: "Bridge dial failed: lookup failed"}) if len(logs) != bridgeLogLimit { t.Fatalf("len(logs) = %d, want %d", len(logs), bridgeLogLimit) } - got := strings.Join(logs, "\n") + var got string + for _, line := range logs { + got += line.text + "\n" + } for _, want := range []string{"Bridge network:", "Bridge target is visible:", "Bridge dial failed:"} { if !strings.Contains(got, want) { t.Errorf("logs lost %q:\n%s", want, got) @@ -1299,7 +1323,7 @@ func TestBridgeAuthURLIsShownOnceAndOpened(t *testing.T) { } t.Cleanup(func() { openURL = orig }) - ch := make(chan string, 1) + 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()) @@ -1310,12 +1334,12 @@ func TestBridgeAuthURLIsShownOnceAndOpened(t *testing.T) { act: &activation{id: 7, logCh: ch, logCtx: ctx}, } - _, cmd := m.Update(bridgeLogMsg{ch: ch, line: tsnetLine}) + _, cmd := m.Update(bridgeLogMsg{ch: ch, line: bridgeLine{text: tsnetLine}}) 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: tsnetLine}) + _, cmd = m.Update(bridgeLogMsg{ch: ch, line: bridgeLine{text: tsnetLine}}) runCmd(t, cmd) if len(opened) != 1 { t.Errorf("repeated auth URL opened the browser again: %q", opened) @@ -1332,7 +1356,7 @@ func TestBridgeAuthURLIsShownOnceAndOpened(t *testing.T) { } m.Update(browserOpenMsg{id: 7, err: errors.New("exec: \"xdg-open\": not found")}) - if len(m.bridgeLogs) != 1 || !strings.Contains(m.bridgeLogs[0], "Use the link below") { + if len(m.bridgeLogs) != 1 || !strings.Contains(m.bridgeLogs[0].text, "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")}) @@ -1529,8 +1553,8 @@ func TestFailureViewWrapsDiagnostics(t *testing.T) { 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{ + {text: `Bridge network: state=Running tailnet="example.com" dns_suffix="example.ts.net" peers=597`}, }, } m.resetStack(m.setupGuideMenu()) From dceb2aa11e920be2a8a02009f9df7db47d710244 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Thu, 17 Sep 2026 01:12:51 +0000 Subject: [PATCH 17/69] docs: model the Connection context before rewriting the bridge boundary A bridge that had never connected took 29s and the screen showed only NeedsLogin. The dump puts the node in the control plane's first /machine/register with no follow-up URL, so it was waiting for a login link to exist. That is a different wait from waiting for the user to finish in the browser, and both are ipn.NeedsLogin, so nothing on screen could tell them apart. Three fixes have now been aimed at that wait: opening the browser at the link, reading the link off the IPN bus, resolving the target against the peer map. All correct, none in the phase the wait was in. Modelling first rather than patching again because the thing missing is a name. Four mechanisms carry progress out of internal/bridges (a func(string) sink, a chan bridgeLine, tsnet's own prose, an *ipnstate.Status return) and none says 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, and the link rides a 32-slot channel that drops on overflow while --debug puts the tsnet backend logger on the same channel. Writing it down rather than going straight to code because the decisions are the expensive part: Connection spans bring-up and the model fetch as one context, ApertureHost splits into Endpoint and Gateway, and owning the IPN bus watch means dropping tsnet.Server.Up and absorbing what it does beyond waiting for Running. That last one is reversible only at the cost of going back to two watchers on a LocalBackend whose own comments assume one. CLAUDE.md records the conventions these follow so they are reviewable in a diff rather than living in one person's tooling. All five Mermaid diagrams rendered before committing. --- CLAUDE.md | 35 ++ docs/adr/0001-connection-bounded-context.md | 182 +++++++++++ docs/specs/connection-context-map.md | 100 ++++++ docs/specs/connection-domain-model.md | 333 ++++++++++++++++++++ 4 files changed, 650 insertions(+) create mode 100644 CLAUDE.md create mode 100644 docs/adr/0001-connection-bounded-context.md create mode 100644 docs/specs/connection-context-map.md create mode 100644 docs/specs/connection-domain-model.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d2a6f89 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,35 @@ +# aperture-cli + +## Design + +This project is being taken toward domain-driven design. New work that +introduces or reshapes a domain concept is modelled before it is written. + +- 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. `Crossing`, not `NodeManager`. +- 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/adr/NNNN-.md` — the decision and the forcing reason. + +Mermaid diagrams in those files are rendered before the commit that adds them. + +Current: [Connection](docs/adr/0001-connection-bounded-context.md). + +## Conventions + +- Commit prefixes match the package touched: `tui:`, `bridges:`, `config:`. +- `make test` is the gate. diff --git a/docs/adr/0001-connection-bounded-context.md b/docs/adr/0001-connection-bounded-context.md new file mode 100644 index 0000000..4086c69 --- /dev/null +++ b/docs/adr/0001-connection-bounded-context.md @@ -0,0 +1,182 @@ +# 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`. + +## Context + +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 the +node inside the control plane's first `POST /machine/register` +(`controlclient/direct.go:853`) with an empty 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 now 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 instead of tsnet's 5s poll. +- `82fab61` resolves the target against the node's own peer map before dialing. + +Each is correct. None could have shortened this particular wait, because none +of them is in the phase the wait was in. The forcing reason for this ADR is not +that the code is untidy: it is that four separate mechanisms carry progress +(a `func(string)` sink, a `chan bridgeLine`, `tsnet`'s own prose, an +`*ipnstate.Status` return) and none of them names what the attempt is waiting +on, so a fourth fix would be aimed the same way. + +Two concrete defects fall out of the same shape: + +- The TUI recovers the login link by string-matching two markers, one of which + is a phrase inside tsnet's log text (`"or go to: "`, `browser.go:16`). A + reworded upstream log line silently stops the browser from opening. +- The link travels on a 32-slot channel whose sink drops on overflow + (`tui.go:557`). Under `--debug` the tsnet backend logger shares that channel, + so a burst of chatter can discard the one line the user cannot proceed + without. + +Three IPN bus watchers run against one backend: tsnet's inside `Up`, ours +inside `WatchLogin`, and tsnet's `printAuthURLLoop`. `LocalBackend.sendToLocked` +iterates every watcher while holding `b.mu`, and the code comments there assume +one. + +## Bounded contexts + +One: **Connection**. It spans the whole flow, from the user picking an endpoint +to a client knowing where to send requests, including the `/v1/models` fetch +that follows bring-up. Splitting bring-up from the fetch is the reason nobody +owns "what is this attempt waiting on"; the user experiences one wait. + +Neighbours and patterns are in +[the context map](../specs/connection-context-map.md). + +## Ubiquitous language + +Connection Attempt, Endpoint, Gateway, Route, Bridge, Crossing, Login Link, +Phase, Progress. Defined in the context map. Three of these resolve words that +currently mean two things: `Gateway` versus `Endpoint` splits `ApertureHost`, +`Crossing` versus `Bridge` splits the running node from the saved record, and +`Phase` takes over from `Status`. + +## Domain objects and invariants + +Full model in [the domain model](../specs/connection-domain-model.md). The +invariants this ADR is accountable for: + +- An Attempt's `Trail` accounts for its whole wall clock with no gaps, so + "where did 29 seconds go" has an answer. +- `AwaitingLoginLink` and `AwaitingAuthorization` are different phases despite + being the same `ipn.State`. +- A `LoginLink` is parsed once, at the boundary, and is `https` with no + whitespace. Nothing downstream re-derives it from text. +- Only `Noted` events may be dropped under backpressure. +- No vendor type crosses the context boundary. + +## 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` through `Up` and `Status`; the replacement port speaks +Connection's own types and publishes `Event`. + +Taking ownership of 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, we already have `Status` on the port | +| `resetServeStateOnce`: clear serve config and advertised services | skipped | + +Skipping `resetServeStateOnce` is deliberate. It exists to clear serve config +and service advertisements persisted by an earlier run of a differently +configured program, and we call neither `SetServeConfig` nor set +`AdvertiseServices`, so there is nothing of ours in the bridge state dir for it +to clear. Both halves are reachable from exported API if that changes: +`lc.SetServeConfig` and `local.Client.EditPrefs` with `AdvertiseServicesSet` +(the unexported `s.lb.EditPrefs` that `Up` uses is equivalent). Revisit if the +CLI ever serves anything over a bridge. + +`printAuthURLLoop` cannot be switched off: `go s.printAuthURLLoop()` is +unconditional in `start()` and no field or envknob guards it. Setting +`Server.UserLogf` to a no-op is the only way to stop its prose reaching us, and +that is what we do, because with a typed `LoginRequired` event its output is +not a source any more. So the watcher count goes three to two while a login is +outstanding, and to one after: `printAuthURLLoop` exits when the state leaves +`NeedsLogin`. + +## Decision + +1. Connection is one bounded context spanning bridge bring-up and the model + fetch. +2. The boundary out of `internal/bridges` becomes a typed `Event` stream. + Delete `bridges.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`, and silence `UserLogf`. +5. `Gateway` replaces `ApertureHost` at the boundary into `internal/clients` + and `internal/profiles`. +6. `ConnectionAttempt` and `Crossing` are their own types. `Manager` does not + grow fields; `Crossing` takes the node, its routes and its tailnet, which is + most of what `Manager` holds today. + +Artifacts land in `docs/specs/` and `docs/adr/`, and the conventions they +follow are recorded in the repo's `CLAUDE.md` rather than in any one person's +tooling. + +## Consequences + +Good: + +- A wait has a name and a duration, so the next report of a slow connection is + diagnosable from the screen rather than from a `SIGQUIT` dump. +- The browser opens because a `LoginRequired` event arrived, not because a log + line matched a phrase in a vendored package. +- The login link cannot be dropped by debug chatter. +- One watcher instead of two inside `LocalBackend`'s lock. +- `internal/clients` stops receiving a field that means two things. + +Bad, and accepted: + +- We now own the bring-up loop, including `Notify.ErrMessage` handling and the + `TailscaleIPs` check. If tsnet adds a step to `Up`, we will not get it. +- Phase detection reads several `Notify` fields (`State`, `BrowseToURL`, + `LoginFinished`, `SelfChange`) whose exact ordering is upstream behaviour, not + contract. `WatchIPNBus` is documented as unstable. +- `printAuthURLLoop` still runs and still calls `StatusWithoutPeers` every five + seconds while a login is outstanding. Nothing we can do from outside tsnet. +- A migration: every `g.ApertureHost` reader changes. + +## Alternatives considered + +**Timestamp the log lines and stop there.** Already shipped (`53f2148`) and it +is what made the phases visible enough to name. It is not enough on its own: +the TUI still parses prose to decide to open a browser, the link still shares a +lossy channel, and an elapsed time against an unnamed line still does not say +which of two `NeedsLogin` waits you are in. + +**Keep `tsnet.Server.Up` and add phases from the existing second watcher.** +Smaller diff, and it avoids owning the bring-up loop. Rejected because it keeps +two LocalAPI watchers on a backend that assumes one, and because a lagging +watcher is evicted with a terminal `ErrMessage` +(`closeLaggingWatchSessionLocked`) that `Up` converts into +`tsnet.Up: backend: IPN bus consumer fell behind`: a bridge failure with no +relationship to anything the user did. + +**Patch tsnet upstream to publish phases.** The right long-term answer for +`printAuthURLLoop` and for phase signals generally, 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 starts serving anything +over a bridge (which puts `resetServeStateOnce` back in scope). diff --git a/docs/specs/connection-context-map.md b/docs/specs/connection-context-map.md new file mode 100644 index 0000000..465aa4c --- /dev/null +++ b/docs/specs/connection-context-map.md @@ -0,0 +1,100 @@ +# Connection context map + +Scope: everything between the user picking an endpoint and a client having +somewhere to send requests. Written before the refactor that replaces the +string log channel between `internal/bridges` and `internal/tui` with typed +events. + +## 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, plus which Bridge (if any) reaches it. | 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 Crossing: a `127.0.0.1:0` listener reverse-proxying over the Crossing. | 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. | +| Crossing | The live tailnet membership for one Bridge: joins a tailnet, may need a login, carries dials. Outlives any one Attempt. | The Bridge record. The proxy. | +| Login Link | The URL that authorizes a Crossing. `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 Crossing joined. Recorded on the Bridge so the picker can name it before the Crossing exists. | | +| Provider | A model provider read from the Aperture's `/v1/models`. | | + +## Contexts + +| Context | Subdomain | Owns | Lives in | +|---|---|---|---| +| Connection | Core | Connection Attempt, Phase, Progress, Login Link, Gateway, Route, Crossing | `internal/bridges`, the activation half of `internal/tui` | +| 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
crossing, 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. | + +## 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 `Crossing`. "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. | + +## Stored, derived, transient + +| Fact | Where it lives | +|---|---| +| Endpoint list, active endpoint | stored, `settings.json` | +| Bridge id, name, last tailnet | stored, `settings.json` | +| Crossing tailnet credentials | stored by tsnet under the bridge state dir, never by us | +| Crossing, 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 + +- `Crossing` is the one term chosen rather than agreed. It names what the live + node does for this program (puts us on a tailnet so we can reach the far + side) and avoids `Node`, which is the vendor's word for it and for every peer + in the netmap. Alternatives considered: `Link`, `Bridgehead`. +- Whether `Phase` should survive a Crossing being reused. A second Attempt over + an already-open Crossing 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-domain-model.md b/docs/specs/connection-domain-model.md new file mode 100644 index 0000000..247c8e2 --- /dev/null +++ b/docs/specs/connection-domain-model.md @@ -0,0 +1,333 @@ +# Connection domain model + +Objects in the Connection context. Language is fixed by +[the context map](connection-context-map.md). + +## 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 Crossing asks for authorization. Nil for a direct Endpoint or a Crossing 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`. +- `Succeed(Gateway)` / `Fail(error)` / `Cancel()` — terminal, once. +- `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 Crossing phase. + +### States + +```mermaid +stateDiagram-v2 + [*] --> AskingForModels: direct endpoint + [*] --> StartingCrossing: bridged endpoint + + StartingCrossing --> AwaitingLoginLink: crossing needs login + StartingCrossing --> 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 + + StartingCrossing --> Failed + AwaitingLoginLink --> Failed + AwaitingAuthorization --> Failed + JoiningTailnet --> Failed + FindingEndpoint --> Failed + AskingForModels --> Failed + + StartingCrossing --> Cancelled + AwaitingLoginLink --> Cancelled + AwaitingAuthorization --> Cancelled + JoiningTailnet --> Cancelled + FindingEndpoint --> Cancelled + AskingForModels --> Cancelled + + Ready --> [*] + Failed --> [*] + Cancelled --> [*] +``` + +### Relationships + +- 1 ConnectionAttempt → 1 Endpoint. +- 1 ConnectionAttempt → 0..1 Crossing, by bridge id, not by ownership. The Crossing 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 | +|---|---|---| +| `StartingCrossing` | 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 | `Notify.LoginFinished`, then `Notify.SelfChange` when the netmap lands | +| `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 Crossing. + +| 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. Where a client sends requests. + +| Field | Type | +|---|---| +| `URL` | `string` | +| `ViaBridge` | `bool` | + +Behaviors: `DirectGateway(Endpoint) Gateway`, `RoutedGateway(Route) Gateway`, +`String()`. + +Invariants: non-empty absolute URL with scheme and host. `ViaBridge` is true +if and only if the URL is a Route's local end. Nothing outside the Connection +context needs `ViaBridge`; it exists so a log or an error can say which of the +two a URL is, which `ApertureHost` cannot. + +## Crossing + +Entity, aggregate root. The live tailnet membership for one Bridge. Separate +aggregate from ConnectionAttempt because it is cached by bridge id and reused +across Attempts (`Manager.nodes`), so it cannot be owned by any one of them. + +| Field | Type | Note | +|---|---|---| +| `BridgeID` | `string` | Identity. At most one Crossing per Bridge. | +| `Tailnet` | `string` | The network joined, empty until the netmap lands. | +| `Routes` | `map[string]*Route` | Keyed by target URL. | + +Behaviors: `Open(ctx) (<-chan Event, error)`, `RouteTo(Endpoint) (Route, error)`, +`LeaveTailnet(ctx) error`, `Close() error`. + +Invariants: +- A Route can only be created through an open Crossing. +- `LeaveTailnet` destroys the Crossing: credentials live behind the node's own LocalAPI, so a close without a logout silently reuses them next time. +- Closing closes every Route first. +- Exactly one IPN bus watch per Crossing. Today there are two of ours plus one of tsnet's; see the ADR. + +### 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 Crossing aggregate. The local door to one Endpoint. + +| Field | Type | +|---|---| +| `LocalURL` | `string`, a `127.0.0.1:` listener | +| `Target` | `config.Endpoint` | + +Behaviors: `Gateway() Gateway`, `Close() error`. + +Invariants: belongs to exactly one Crossing and one Endpoint. Its listener is +bound to loopback only. Resolves the target against the Crossing's own peer map +before dialing, never the host resolver, because the host may itself be on a +tailnet with a same-named node. + +## 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 Crossing 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| Crossing : "uses" + Endpoint }o--o| Bridge : "reached through" + Bridge ||--o| Crossing : "runs as" + Crossing ||--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) + +Succeed(Gateway) + +Fail(error) + +Cancel() + +Slowest() Progress + +Supersedes(ConnectionAttempt) bool + } + class Phase { + <> + StartingCrossing + 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 Crossing { + +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 ..> Crossing + Crossing --> Route + Route --> Gateway +``` + +## Open, not assumed + +- Whether a reused Crossing 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 `Crossing` keeps that name. See the context map. +- Whether `Route` deserves a lifecycle of its own. It is currently created once + and closed with its Crossing, so it has no interesting states, and a state + machine for it would be invented rather than observed. From 7126b9862e242f4b375c27832f6f83b3f5f99c2c Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Thu, 17 Sep 2026 01:15:36 +0000 Subject: [PATCH 18/69] docs: name the agent instructions file AGENTS.md CLAUDE.md is one vendor's filename for a file that every agent in this repo reads. AGENTS.md is the cross-tool convention, and the contents are project conventions, not instructions to one assistant. --- CLAUDE.md => AGENTS.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename CLAUDE.md => AGENTS.md (100%) diff --git a/CLAUDE.md b/AGENTS.md similarity index 100% rename from CLAUDE.md rename to AGENTS.md From 63bb2d7519d53294dded6543d0d2368d4ace1686 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Thu, 17 Sep 2026 01:16:45 +0000 Subject: [PATCH 19/69] docs: define the Connection domain events to 100% The model named six events in a three-column table, which is the failure the defining-contracts skill exists to catch: a name is not a contract. Filling every field found two holes that reading the model well did not. The domain service column is the anti-anemia check, and two events have a reaction with no owning object. TailnetJoined spans Crossing and Bridge and is resolved today by recordBridgeTailnet reaching from the TUI into the manager and then into settings. Ready spans the attempt and Client Launch and is resolved by assigning g.ApertureHost, a shared mutable global five client packages read whenever they run. Nothing owns "which Gateway is current", which is how that field came to mean two things without anyone deciding it should. Both go back to the model as open rather than getting a name here. API and DDL are recorded absent with reasons rather than skipped: the CLI has no callers to enumerate and no relational store. The schema rules still catch one thing, Bridge.Tailnet being empty until a crossing joins, which is a nullable "has not happened yet" in JSON clothing. Kept, with the exception recorded next to it, because a settings document rewritten whole does not want a collection to model an absence the picker already renders. --- AGENTS.md | 7 ++ docs/specs/connection-contracts.md | 115 ++++++++++++++++++++++++++ docs/specs/connection-domain-model.md | 5 ++ 3 files changed, 127 insertions(+) create mode 100644 docs/specs/connection-contracts.md diff --git a/AGENTS.md b/AGENTS.md index d2a6f89..c50b931 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,8 +23,15 @@ Artifacts, written before the code: relationships, ambiguous terms. - `docs/specs/-domain-model.md` — one section per object, with fields, behaviours, invariants, states and relationships. +- `docs/specs/-contracts.md` — every domain event to 100%, and every + aggregate transition traced through them. A contract this project does not + have (there is no service API and no relational store) is recorded as absent + with its reason, never left blank. - `docs/adr/NNNN-.md` — the decision and the forcing reason. +The discipline is the `ddd` plugin's `domain-driven-design` and +`defining-contracts` skills; these paths are the project's, not the tool's. + Mermaid diagrams in those files are rendered before the commit that adds them. Current: [Connection](docs/adr/0001-connection-bounded-context.md). diff --git a/docs/specs/connection-contracts.md b/docs/specs/connection-contracts.md new file mode 100644 index 0000000..47e1144 --- /dev/null +++ b/docs/specs/connection-contracts.md @@ -0,0 +1,115 @@ +# Connection contracts + +Pass 1. Contracts for the objects in +[the domain model](connection-domain-model.md). + +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 `Crossing` 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` | Crossing | `Starting → NeedsLogin`, on the first `Notify.BrowseToURL` | `Link LoginLink` | ConnectionAttempt (`Authorize`), connect screen (footer, copy button, 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` | Crossing | `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` | Crossing | 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 Crossing and Bridge: "the Bridge records the tailnet its +Crossing joined, so the picker can name it before the Crossing exists again". +Today that is `model.recordBridgeTailnet` (`tui.go:421`), which reaches into +`Manager.Tailnet(bridgeID)` and then `g.SetBridgeTailnet`. The TUI is 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. + +## Persisted facts + +No DDL, but the schema rules still apply to `settings.json` and one of them +bites. + +`Bridge.Tailnet` is empty until a Crossing 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 → `StartingCrossing` | `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. | +| Crossing `Starting → NeedsLogin` | `LoginRequired` | | +| Crossing `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. | +| Crossing `NeedsLogin → Joining` | none | Same. The authorization that caused it is already on screen. | +| Crossing `Joining → Open` | `TailnetJoined` | | +| Crossing `→ Closed` via `Close` | none | Deliberately silent. Process teardown; there is no consumer left to react. | +| Crossing `→ 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 Crossing per Bridge" is enforced by a map keyed on bridge id + under a mutex. Same situation. + +## 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 index 247c8e2..2d2a14d 100644 --- a/docs/specs/connection-domain-model.md +++ b/docs/specs/connection-domain-model.md @@ -321,6 +321,11 @@ classDiagram ## Open, not assumed +- Two cross-aggregate reactions have no owning object, found by the + [contracts pass](connection-contracts.md): recording the tailnet a Crossing + joined onto its Bridge, and deciding which Gateway is current for the next + client launch. Both live in the TUI today, which orchestrates but should not + decide. Needs resolving before the events are implemented. - Whether a reused Crossing 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. From 826bcf342d91285b53646e78e09c7ccb34d6d2e5 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Thu, 17 Sep 2026 01:18:26 +0000 Subject: [PATCH 20/69] docs: state DDD as the design paradigm, drop the tooling reference The file named a specific plugin and two skill names, which is one contributor's toolchain leaking into a project convention. What the repo needs to say is the rule, not what anyone runs to follow it. Also corrects the artifact list to match the discipline: domain model, data model and contracts are one first step rather than a model followed later by contracts. Writing them apart is what produces a model that reads well and a set of events that turn out to be names. --- AGENTS.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c50b931..7bce970 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,13 @@ ## Design -This project is being taken toward domain-driven design. New work that -introduces or reshapes a domain concept is modelled before it is written. +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; @@ -23,15 +28,12 @@ Artifacts, written before the code: relationships, ambiguous terms. - `docs/specs/-domain-model.md` — one section per object, with fields, behaviours, invariants, states and relationships. -- `docs/specs/-contracts.md` — every domain event to 100%, and every - aggregate transition traced through them. A contract this project does not - have (there is no service API and no relational store) is recorded as absent - with its reason, never left blank. +- `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. -The discipline is the `ddd` plugin's `domain-driven-design` and -`defining-contracts` skills; these paths are the project's, not the tool's. - Mermaid diagrams in those files are rendered before the commit that adds them. Current: [Connection](docs/adr/0001-connection-bounded-context.md). From 6708373ebdc32d661d97d63d276af4324cb63722 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Thu, 17 Sep 2026 01:27:28 +0000 Subject: [PATCH 21/69] ci: make check the gate, and give it something test does not do CI restated the formatting check inline and then called make test, so the workflow and the Makefile were two definitions of the gate and a clean local run did not mean a clean CI. check earns its place over test by running the suite under the race detector and by building. A bridge is several goroutines racing a control plane over channels nobody owns end to end, which is the failure this project actually has, and go test will not find one. Dropped redeploy: install covers it. lint is gofmt plus vet plus a tidy diff, all from the toolchain, so a clean checkout runs it without installing anything. Reaching for golangci-lint would have meant pinning a version and a config file to catch what vet already catches here. Cost worth knowing: the race build of the tailscale tree is slow, minutes not seconds, and the matrix runs it twice. --- .github/workflows/ci-linux.yaml | 15 +++++---------- Makefile | 16 +++++++++++++++- 2 files changed, 20 insertions(+), 11 deletions(-) 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/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 From 7c214a23d77836c576daa2bc042a605dc66db50e Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Thu, 17 Sep 2026 02:11:59 +0000 Subject: [PATCH 22/69] docs: the live tailnet node is a Machine, not a Crossing Crossing was invented. The thing already has a name in the system it wraps and we were ignoring it: the control plane registers it with POST /machine/register (controlclient/direct.go:839), keys it with a MachineKey, and reports ipn.NeedsMachineAuth when it is unauthorized. The user reads that same word in their admin console under Machines. An invented name costs a translation every time someone moves between this code, a tsnet trace and the console, and that translation is where "the bridge is not logged in" became unreadable in the first place. Node was the alternative and is worse: tsnet uses it for our node and for every peer in the netmap, so it is ambiguous in the one package that has to be exact. The word now collides with "the computer aperture runs on", which browser.go:69 uses when it explains that an SSH session writes to the wrong clipboard. The domain object takes the word, the computer is the host, and that comment gets reworded when its file is touched. Recorded in the ambiguous-terms table so the collision is decided rather than rediscovered. Also fixes the ADR's pointer to CLAUDE.md, renamed in 3b725d8. Revisit if the control plane renames it: the v2 API and /machine/set-device-attr already say "device" at the edges. --- AGENTS.md | 4 +- docs/adr/0001-connection-bounded-context.md | 10 ++-- docs/specs/connection-context-map.md | 35 ++++++----- docs/specs/connection-contracts.md | 30 +++++----- docs/specs/connection-domain-model.md | 66 ++++++++++----------- 5 files changed, 76 insertions(+), 69 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7bce970..704e2ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,9 @@ the code, and let each correct the other. 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. `Crossing`, not `NodeManager`. + 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 diff --git a/docs/adr/0001-connection-bounded-context.md b/docs/adr/0001-connection-bounded-context.md index 4086c69..e47ebe6 100644 --- a/docs/adr/0001-connection-bounded-context.md +++ b/docs/adr/0001-connection-bounded-context.md @@ -55,10 +55,10 @@ Neighbours and patterns are in ## Ubiquitous language -Connection Attempt, Endpoint, Gateway, Route, Bridge, Crossing, Login Link, +Connection Attempt, Endpoint, Gateway, Route, Bridge, Machine, Login Link, Phase, Progress. Defined in the context map. Three of these resolve words that currently mean two things: `Gateway` versus `Endpoint` splits `ApertureHost`, -`Crossing` versus `Bridge` splits the running node from the saved record, and +`Machine` versus `Bridge` splits the running node from the saved record, and `Phase` takes over from `Status`. ## Domain objects and invariants @@ -124,12 +124,12 @@ outstanding, and to one after: `printAuthURLLoop` exits when the state leaves `TailscaleIPs` check, skip `resetServeStateOnce`, and silence `UserLogf`. 5. `Gateway` replaces `ApertureHost` at the boundary into `internal/clients` and `internal/profiles`. -6. `ConnectionAttempt` and `Crossing` are their own types. `Manager` does not - grow fields; `Crossing` takes the node, its routes and its tailnet, which is +6. `ConnectionAttempt` and `Machine` are their own types. `Manager` does not + grow fields; `Machine` takes the node, its routes and its tailnet, which is most of what `Manager` holds today. Artifacts land in `docs/specs/` and `docs/adr/`, and the conventions they -follow are recorded in the repo's `CLAUDE.md` rather than in any one person's +follow are recorded in the repo's `AGENTS.md` rather than in any one person's tooling. ## Consequences diff --git a/docs/specs/connection-context-map.md b/docs/specs/connection-context-map.md index 465aa4c..5276f62 100644 --- a/docs/specs/connection-context-map.md +++ b/docs/specs/connection-context-map.md @@ -12,20 +12,28 @@ events. | 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, plus which Bridge (if any) reaches it. | 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 Crossing: a `127.0.0.1:0` listener reverse-proxying over the Crossing. | A tailnet route or subnet route. | +| 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. | -| Crossing | The live tailnet membership for one Bridge: joins a tailnet, may need a login, carries dials. Outlives any one Attempt. | The Bridge record. The proxy. | -| Login Link | The URL that authorizes a Crossing. `https` only, no whitespace, opened in a browser or copied. | Any URL in a log line. | +| 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. | +| 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 Crossing joined. Recorded on the Bridge so the picker can name it before the Crossing exists. | | +| 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, Crossing | `internal/bridges`, the activation half of `internal/tui` | +| Connection | Core | Connection Attempt, Phase, Progress, Login Link, Gateway, Route, Machine | `internal/bridges`, the activation half of `internal/tui` | | 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` | @@ -40,7 +48,7 @@ nobody owning the question "what is this attempt waiting on". flowchart LR User([User]) subgraph Core - Connection[Connection
attempt, phase, progress
crossing, route, gateway] + Connection[Connection
attempt, phase, progress
machine, route, gateway] end Settings[Settings
endpoints, bridges] Launch[Client Launch
opencode, claude, gemini, codex] @@ -71,9 +79,10 @@ flowchart LR |---|---|---|---| | `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 `Crossing`. "The bridge is not logged in" currently cannot be read unambiguously. | +| 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 @@ -81,20 +90,16 @@ flowchart LR |---|---| | Endpoint list, active endpoint | stored, `settings.json` | | Bridge id, name, last tailnet | stored, `settings.json` | -| Crossing tailnet credentials | stored by tsnet under the bridge state dir, never by us | -| Crossing, Route | transient, process lifetime, keyed by bridge id | +| 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 -- `Crossing` is the one term chosen rather than agreed. It names what the live - node does for this program (puts us on a tailnet so we can reach the far - side) and avoids `Node`, which is the vendor's word for it and for every peer - in the netmap. Alternatives considered: `Link`, `Bridgehead`. -- Whether `Phase` should survive a Crossing being reused. A second Attempt over - an already-open Crossing skips five of the seven phases; today it silently +- 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 index 47e1144..8caf972 100644 --- a/docs/specs/connection-contracts.md +++ b/docs/specs/connection-contracts.md @@ -8,7 +8,7 @@ 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 `Crossing` port, which is covered by the domain model's behaviours. | +| 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. | @@ -23,9 +23,9 @@ 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` | Crossing | `Starting → NeedsLogin`, on the first `Notify.BrowseToURL` | `Link LoginLink` | ConnectionAttempt (`Authorize`), connect screen (footer, copy button, 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` | Crossing | `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` | Crossing | 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 | +| `LoginRequired` | Machine | `Starting → NeedsLogin`, on the first `Notify.BrowseToURL` | `Link LoginLink` | ConnectionAttempt (`Authorize`), connect screen (footer, copy button, 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. | @@ -35,8 +35,8 @@ 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 Crossing and Bridge: "the Bridge records the tailnet its -Crossing joined, so the picker can name it before the Crossing exists again". +`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 `Manager.Tailnet(bridgeID)` and then `g.SetBridgeTailnet`. The TUI is loading, calling and committing, which is orchestration, but it is also deciding the @@ -57,7 +57,7 @@ for this pass; that they are unowned is the finding. No DDL, but the schema rules still apply to `settings.json` and one of them bites. -`Bridge.Tailnet` is empty until a Crossing joins one, which is the JSON-document +`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, @@ -81,7 +81,7 @@ emits an event, or is recorded here as deliberately silent. | Transition | Event | Note | |---|---|---| -| ConnectionAttempt → `StartingCrossing` | `PhaseEntered` | | +| ConnectionAttempt → `StartingMachine` | `PhaseEntered` | | | → `AwaitingLoginLink` | `PhaseEntered` | the phase that did not exist | | → `AwaitingAuthorization` | `PhaseEntered`, preceded by `LoginRequired` | | | → `JoiningTailnet` | `PhaseEntered` | | @@ -90,12 +90,12 @@ emits an event, or is recorded here as deliberately silent. | → `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. | -| Crossing `Starting → NeedsLogin` | `LoginRequired` | | -| Crossing `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. | -| Crossing `NeedsLogin → Joining` | none | Same. The authorization that caused it is already on screen. | -| Crossing `Joining → Open` | `TailnetJoined` | | -| Crossing `→ Closed` via `Close` | none | Deliberately silent. Process teardown; there is no consumer left to react. | -| Crossing `→ 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. | +| 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: @@ -105,7 +105,7 @@ code, which the skill flags and no constraint layer here can fix: 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 Crossing per Bridge" is enforced by a map keyed on bridge id +- "At most one Machine per Bridge" is enforced by a map keyed on bridge id under a mutex. Same situation. ## Next pass diff --git a/docs/specs/connection-domain-model.md b/docs/specs/connection-domain-model.md index 2d2a14d..18b73da 100644 --- a/docs/specs/connection-domain-model.md +++ b/docs/specs/connection-domain-model.md @@ -18,7 +18,7 @@ Attempt. | `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 Crossing asks for authorization. Nil for a direct Endpoint or a Crossing already logged in. | +| `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. | @@ -38,31 +38,31 @@ Attempt. - `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 Crossing phase. +- A direct Endpoint (`BridgeID == ""`) never enters a Machine phase. ### States ```mermaid stateDiagram-v2 [*] --> AskingForModels: direct endpoint - [*] --> StartingCrossing: bridged endpoint + [*] --> StartingMachine: bridged endpoint - StartingCrossing --> AwaitingLoginLink: crossing needs login - StartingCrossing --> JoiningTailnet: credentials already on disk + 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 - StartingCrossing --> Failed + StartingMachine --> Failed AwaitingLoginLink --> Failed AwaitingAuthorization --> Failed JoiningTailnet --> Failed FindingEndpoint --> Failed AskingForModels --> Failed - StartingCrossing --> Cancelled + StartingMachine --> Cancelled AwaitingLoginLink --> Cancelled AwaitingAuthorization --> Cancelled JoiningTailnet --> Cancelled @@ -77,7 +77,7 @@ stateDiagram-v2 ### Relationships - 1 ConnectionAttempt → 1 Endpoint. -- 1 ConnectionAttempt → 0..1 Crossing, by bridge id, not by ownership. The Crossing outlives the Attempt. +- 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. @@ -87,7 +87,7 @@ Enumeration. Named for what the user is waiting for, not for `ipn.State`. | Phase | The user is waiting for | Signal it is entered | |---|---|---| -| `StartingCrossing` | the bridge to start | `tsnet` init returns a local client | +| `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 | `Notify.LoginFinished`, then `Notify.SelfChange` when the netmap lands | @@ -122,7 +122,7 @@ exactly once, when the next phase is entered. ## LoginLink -Value object. The URL that authorizes a Crossing. +Value object. The URL that authorizes a Machine. | Field | Type | |---|---| @@ -154,15 +154,16 @@ if and only if the URL is a Route's local end. Nothing outside the Connection context needs `ViaBridge`; it exists so a log or an error can say which of the two a URL is, which `ApertureHost` cannot. -## Crossing +## Machine -Entity, aggregate root. The live tailnet membership for one Bridge. Separate -aggregate from ConnectionAttempt because it is cached by bridge id and reused -across Attempts (`Manager.nodes`), so it cannot be owned by any one of them. +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 cached by bridge id and reused across +Attempts (`Manager.nodes`), so it cannot be owned by any one of them. | Field | Type | Note | |---|---|---| -| `BridgeID` | `string` | Identity. At most one Crossing per Bridge. | +| `BridgeID` | `string` | Identity. At most one Machine per Bridge. | | `Tailnet` | `string` | The network joined, empty until the netmap lands. | | `Routes` | `map[string]*Route` | Keyed by target URL. | @@ -170,10 +171,10 @@ Behaviors: `Open(ctx) (<-chan Event, error)`, `RouteTo(Endpoint) (Route, error)` `LeaveTailnet(ctx) error`, `Close() error`. Invariants: -- A Route can only be created through an open Crossing. -- `LeaveTailnet` destroys the Crossing: credentials live behind the node's own LocalAPI, so a close without a logout silently reuses them next time. +- A Route can only be created through an open Machine. +- `LeaveTailnet` destroys the Machine: credentials live behind the node's own LocalAPI, so a close without a logout silently reuses them next time. - Closing closes every Route first. -- Exactly one IPN bus watch per Crossing. Today there are two of ours plus one of tsnet's; see the ADR. +- Exactly one IPN bus watch per Machine. Today there are two of ours plus one of tsnet's; see the ADR. ### States @@ -194,7 +195,7 @@ stateDiagram-v2 ## Route -Entity, inside the Crossing aggregate. The local door to one Endpoint. +Entity, inside the Machine aggregate. The local door to one Endpoint. | Field | Type | |---|---| @@ -203,8 +204,8 @@ Entity, inside the Crossing aggregate. The local door to one Endpoint. Behaviors: `Gateway() Gateway`, `Close() error`. -Invariants: belongs to exactly one Crossing and one Endpoint. Its listener is -bound to loopback only. Resolves the target against the Crossing's own peer map +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. @@ -217,7 +218,7 @@ This replaces the `func(string)` log sink and the `chan bridgeLine`. |---|---|---| | `PhaseEntered` | `Phase`, `Progress` | The Attempt advanced. | | `LoginRequired` | `LoginLink` | Authorization is needed at this link. | -| `TailnetJoined` | `string` | The Crossing is on this network. | +| `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. | @@ -241,10 +242,10 @@ erDiagram ConnectionAttempt ||--o| LoginLink : "shows" ConnectionAttempt ||--o| Gateway : "yields" ConnectionAttempt ||--o{ Event : "publishes" - ConnectionAttempt }o--o| Crossing : "uses" + ConnectionAttempt }o--o| Machine : "uses" Endpoint }o--o| Bridge : "reached through" - Bridge ||--o| Crossing : "runs as" - Crossing ||--o{ Route : "carries" + Bridge ||--o| Machine : "runs as" + Machine ||--o{ Route : "carries" Route ||--|| Endpoint : "fronts" Route ||--|| Gateway : "is reached as" ``` @@ -271,7 +272,7 @@ classDiagram } class Phase { <> - StartingCrossing + StartingMachine AwaitingLoginLink AwaitingAuthorization JoiningTailnet @@ -296,7 +297,7 @@ classDiagram +bool ViaBridge +String() string } - class Crossing { + class Machine { +string BridgeID +string Tailnet +Open(ctx) chan Event @@ -314,25 +315,24 @@ classDiagram ConnectionAttempt --> Progress ConnectionAttempt --> LoginLink ConnectionAttempt --> Gateway - ConnectionAttempt ..> Crossing - Crossing --> Route + ConnectionAttempt ..> Machine + Machine --> Route Route --> Gateway ``` ## Open, not assumed - Two cross-aggregate reactions have no owning object, found by the - [contracts pass](connection-contracts.md): recording the tailnet a Crossing + [contracts pass](connection-contracts.md): recording the tailnet a Machine joined onto its Bridge, and deciding which Gateway is current for the next client launch. Both live in the TUI today, which orchestrates but should not decide. Needs resolving before the events are implemented. -- Whether a reused Crossing should replay its phases to a second Attempt or +- 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 `Crossing` keeps that name. See the context map. - Whether `Route` deserves a lifecycle of its own. It is currently created once - and closed with its Crossing, so it has no interesting states, and a state + and closed with its Machine, so it has no interesting states, and a state machine for it would be invented rather than observed. From 6edd30acb35d2d52b2462f052366fa8d738c46f0 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Thu, 17 Sep 2026 02:38:42 +0000 Subject: [PATCH 23/69] connection: name what a bridge is waiting on instead of logging prose A first bridge connection spent 29 seconds with nothing on screen but tsnet's "NeedsLogin", and three separate fixes have now been aimed at that wait without anyone knowing which part of it was slow. The cause is that ipn.NeedsLogin covers two waits that are not the same problem: before a BrowseToURL arrives the control plane has not answered and there is nothing the user can do, and after it arrives everything is waiting on them. Reporting the backend state cannot tell them apart, so the screen could not either. Two other defects came from the same place. The login link travelled as a string on a channel whose sink dropped whatever arrived on a full buffer, and under -debug the tsnet backend logger shares that buffer, so a burst of chatter could discard the one line the user cannot proceed without. And the browser opened on a line matching "or go to: ", a phrase from inside a vendored package, which an upstream reword would have broken silently. internal/connection carries the vocabulary now: six Phases, a LoginLink that validates at the boundary, and an Event the producer cannot reword. The sink drops only diagnostics; anything else waits for room, bounded by the attempt's cancellation. Phases land in the existing timestamped pane, so the screen reads where the time went with no view changes. The alternative was a second channel for the link alongside the log, which keeps the string matching for everything else and gives the TUI two orderings to reconcile. Revisit the package if it only ever holds these three Kinds; the Ready and Failed events the ADR names still travel on endpointActivationResult and are deliberately not here yet. tsnet's UserLogf is silent unless -debug: it is mostly printAuthURLLoop reprinting a link the footer already shows, every five seconds. It is a no-op func rather than nil because tsnet falls back to log.Printf when it is unset, which writes over the TUI. --- internal/bridges/manager.go | 193 ++++++++++++++++++++---------- internal/bridges/manager_test.go | 104 +++++++++++++--- internal/connection/event.go | 151 +++++++++++++++++++++++ internal/connection/event_test.go | 80 +++++++++++++ internal/tui/browser.go | 37 +----- internal/tui/tui.go | 109 ++++++++++++----- internal/tui/tui_test.go | 113 ++++++++++------- 7 files changed, 606 insertions(+), 181 deletions(-) create mode 100644 internal/connection/event.go create mode 100644 internal/connection/event_test.go diff --git a/internal/bridges/manager.go b/internal/bridges/manager.go index e3a393c..daca1d3 100644 --- a/internal/bridges/manager.go +++ b/internal/bridges/manager.go @@ -15,6 +15,7 @@ import ( "time" "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/connection" "tailscale.com/client/local" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" @@ -58,15 +59,29 @@ type tailnetNode interface { Up(context.Context) (*ipnstate.Status, error) Status(context.Context) (*ipnstate.Status, error) DialContext(context.Context, string, string) (net.Conn, error) - WatchLogin(context.Context, func(string)) + WatchLogin(context.Context, events) Logout(context.Context) error Close() error } -// AuthLogPrefix labels the login link in a bridge's activation log. Callers -// parse it back out of the log stream to open a browser, so the text is part -// of this package's API rather than a message that can be reworded freely. -const AuthLogPrefix = "Authorize this bridge in your browser: " +// events is where a bridge reports what it is doing. This package translates +// the tailnet's vocabulary into it and never publishes anything else: a caller +// that had to recover meaning by matching the prose in a log line was matching +// a phrase from inside a vendored package. +type events func(connection.Event) + +// sink returns a usable events, so callers that want none can pass nil. +func sink(emit func(connection.Event)) events { + if emit == nil { + return func(connection.Event) {} + } + return emit +} + +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) login(link connection.LoginLink) { e(connection.Login(link)) } type tsnetNode struct { server *tsnet.Server @@ -88,7 +103,8 @@ func (n *tsnetNode) DialContext(ctx context.Context, network, address string) (n return n.server.Dial(ctx, network, address) } -// WatchLogin logs what an interactive login is waiting on, until ctx is done. +// WatchLogin reports what an interactive login is waiting on, until ctx is +// done. // // tsnet surfaces the login link from a five second poll loop of its own, so a // link that lands just after a tick stays invisible for most of that window. @@ -96,12 +112,12 @@ func (n *tsnetNode) DialContext(ctx context.Context, network, address string) (n // "NeedsLogin" and got killed a few hundred milliseconds before the link would // have been printed. The IPN bus has the link the moment the control plane // answers, so watch that instead of waiting for tsnet to notice. -func (n *tsnetNode) WatchLogin(ctx context.Context, logf func(string)) { +func (n *tsnetNode) WatchLogin(ctx context.Context, ev events) { // A cancelled watch is how this returns on every connection that works, // so only a failure the caller did not ask for is worth a line. report := func(err error) { if err != nil && ctx.Err() == nil { - logf("Could not watch the bridge's login state: " + err.Error()) + ev.note("Could not watch the bridge's login state: " + err.Error()) } } @@ -118,26 +134,76 @@ func (n *tsnetNode) WatchLogin(ctx context.Context, logf func(string)) { return } defer watcher.Close() - report(reportLogin(watcher, logf)) + report(reportLogin(watcher, ev)) } -// reportLogin logs login progress from an IPN bus watch until it ends. -func reportLogin(watcher *local.IPNBusWatcher, logf func(string)) error { - announced := false +// reportLogin translates an IPN bus watch into phases until the watch ends. +func reportLogin(watcher *local.IPNBusWatcher, ev events) error { + reporter := loginReporter{ev: ev} for { notify, err := watcher.Next() if err != nil { return err } - if notify.State != nil && *notify.State == ipn.NeedsLogin && !announced { - // Otherwise the wait for the control plane to answer is silent, - // and the only thing on screen is tsnet's "NeedsLogin". - announced = true - logf("This bridge is not logged in to a tailnet yet. Waiting for a login link ...") + reporter.notify(¬ify) + } +} + +// loginReporter turns IPN bus notifications into the phases a connection +// attempt reports. It holds the phase it last reported because the bus repeats +// states, and the anti-corruption layer is the right place to absorb that. +// +// The mapping is the whole point of the exercise. ipn.NeedsLogin covers two +// waits that look identical on screen and are not the same problem: before a +// BrowseToURL arrives the control plane has not answered yet and there is +// nothing for the user to do, and after it arrives everything is waiting on +// them. Reporting the backend state is what made a 29 second registration +// indistinguishable from a user who had wandered off. +type loginReporter struct { + ev events + phase connection.Phase +} + +func (r *loginReporter) 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 *loginReporter) notify(n *ipn.Notify) { + if n == nil { + return + } + if n.State != nil { + switch *n.State { + case ipn.NeedsLogin: + r.enter(connection.AwaitingLoginLink) + case ipn.NeedsMachineAuth: + // 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 notify.BrowseToURL != nil { - logf(AuthLogPrefix + *notify.BrowseToURL) + } + if n.BrowseToURL != nil { + link, err := connection.ParseLoginLink(*n.BrowseToURL) + if err != nil { + // Not fatal to the login: tsnet keeps printing its own copy, and + // the user can still finish by hand. Worth saying, because 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.login(link) } } @@ -182,36 +248,39 @@ func NewManager(debug bool) *Manager { // 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) { +func (m *Manager) Activate(ctx context.Context, bridge config.Bridge, remoteURL string, emit func(connection.Event)) (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) {} - } + ev := sink(emit) target, err := parseTarget(remoteURL) if err != nil { return "", err } - rt, status, err := m.runningNode(ctx, bridge, logf) + rt, status, err := m.runningNode(ctx, bridge, ev) if err != nil { return "", err } + // Here rather than in runningNode, which SwitchTailnet also uses and which + // returns immediately for a node that is already up. A reused bridge skips + // every earlier phase and would otherwise report nothing at all while the + // first dial waits for the target to appear in its peer map. + ev.enter(connection.FindingEndpoint) 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()) + ev.note("Could not read bridge network status: " + err.Error()) } else { status = fullStatus } - logBridgeStatus(logf, status, target) + logBridgeStatus(ev, status, target) } m.mu.Lock() @@ -224,19 +293,19 @@ func (m *Manager) Activate(ctx context.Context, bridge config.Bridge, remoteURL return proxy.localURL, nil } - proxy, err := m.startProxy(rt.node, target, logf) + proxy, err := m.startProxy(rt.node, target, ev) if err != nil { return "", err } rt.proxies[key] = proxy - logf("Listening on " + proxy.localURL) + ev.note("Listening on " + proxy.localURL) return proxy.localURL, nil } // runningNode returns the bridge's node, starting it if this is the first use. // status is the login status Up reported, and is nil for a node that was // already running. Callers hold no lock. -func (m *Manager) runningNode(ctx context.Context, bridge config.Bridge, logf func(string)) (*nodeRuntime, *ipnstate.Status, error) { +func (m *Manager) runningNode(ctx context.Context, bridge config.Bridge, ev events) (*nodeRuntime, *ipnstate.Status, error) { m.mu.Lock() rt := m.nodes[bridge.ID] if rt != nil { @@ -248,14 +317,21 @@ func (m *Manager) runningNode(ctx context.Context, bridge config.Bridge, logf fu m.mu.Unlock() return nil, nil, err } - userLogf := func(format string, args ...any) { - logf(fmt.Sprintf(format, args...)) - } - debugLogf := func(format string, args ...any) { + // Both of tsnet's loggers are diagnostics now, and neither is on unless the + // user asked for them. Everything the attempt waits on is read off the IPN + // bus, where it is a fact rather than a sentence that can be reworded + // upstream, so tsnet's user-facing prose has nothing left to contribute: it + // is mostly printAuthURLLoop reprinting a link the footer already shows, + // once every five seconds, and it would push the phases off the screen. + // + // A no-op rather than nil: tsnet falls back to log.Printf when UserLogf is + // unset, which writes over the TUI. + logNotes := func(format string, args ...any) { if m.debug { - logf(fmt.Sprintf(format, args...)) + ev.notef(format, args...) } } + userLogf, debugLogf := logNotes, logNotes rt = &nodeRuntime{ node: m.newNode(bridge, stateDir, userLogf, debugLogf), proxies: make(map[string]*proxyRuntime), @@ -263,14 +339,14 @@ func (m *Manager) runningNode(ctx context.Context, bridge config.Bridge, logf fu m.nodes[bridge.ID] = rt m.mu.Unlock() - logf("Starting bridge " + bridge.Name + " (" + bridge.ID + ")") + ev.enter(connection.StartingMachine) // Up blocks until the node is Running, which for a bridge that has never // logged in means blocking until the user visits a link nothing has shown // them yet. The watch runs alongside it and ends with it. watchCtx, stopWatch := context.WithCancel(ctx) defer stopWatch() - go rt.node.WatchLogin(watchCtx, logf) + go rt.node.WatchLogin(watchCtx, ev) status, err := rt.node.Up(ctx) if err != nil { @@ -281,7 +357,6 @@ func (m *Manager) runningNode(ctx context.Context, bridge config.Bridge, logf fu m.mu.Unlock() return nil, nil, errors.Join(err, rt.node.Close()) } - logf("Bridge connected.") // Up returns the login status, so the tailnet this bridge reaches costs no // extra call. The connection picker names it on rows the user has not @@ -316,22 +391,20 @@ func (m *Manager) Tailnet(bridgeID string) string { // them on the next start. A node that was never started this session is // therefore brought up on the old tailnet first, which is also what leaves the // device removed from it rather than orphaned. -func (m *Manager) SwitchTailnet(ctx context.Context, bridge config.Bridge, logf func(string)) error { +func (m *Manager) SwitchTailnet(ctx context.Context, bridge config.Bridge, emit func(connection.Event)) 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) {} - } - rt, _, err := m.runningNode(ctx, bridge, logf) + ev := sink(emit) + rt, _, err := m.runningNode(ctx, bridge, ev) if err != nil { return err } - logf("Logging bridge " + bridge.Name + " out of its tailnet ...") + ev.note("Logging bridge " + bridge.Name + " out of its tailnet ...") logoutErr := rt.node.Logout(ctx) // Under the lock, as in Close: an Activate that took rt before the delete @@ -352,7 +425,7 @@ func (m *Manager) SwitchTailnet(ctx context.Context, bridge config.Bridge, logf if err := errors.Join(errs...); err != nil { return err } - logf("Bridge logged out. Log in to the tailnet you want next.") + ev.note("Bridge logged out. Log in to the tailnet you want next.") return nil } @@ -427,7 +500,7 @@ func parseTarget(raw string) (*url.URL, error) { return target, nil } -func (m *Manager) startProxy(node tailnetNode, target *url.URL, logf func(string)) (*proxyRuntime, error) { +func (m *Manager) startProxy(node tailnetNode, target *url.URL, ev events) (*proxyRuntime, error) { debug := m.debug ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -438,24 +511,24 @@ func (m *Manager) startProxy(node tailnetNode, target *url.URL, logf func(string 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)) + ev.notef("Bridge dialing network=%s address=%s", network, address) } conn, attempts, err := dialViaNode( ctx, node, network, address, - logf, + ev, m.peerWait, m.peerWaitInterval, ) 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)) + 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 { - logf(fmt.Sprintf("Bridge dial connected: address=%s remote=%s attempts=%d elapsed=%s", address, conn.RemoteAddr(), attempts, elapsed)) + ev.notef("Bridge dial connected: address=%s remote=%s attempts=%d elapsed=%s", address, conn.RemoteAddr(), attempts, elapsed) } return conn, nil } @@ -468,7 +541,7 @@ func (m *Manager) startProxy(node tailnetNode, target *url.URL, logf func(string } 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)) + ev.notef("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) } @@ -501,7 +574,7 @@ func dialViaNode( ctx context.Context, node tailnetNode, network, address string, - logf func(string), + ev events, peerWaitWindow, peerWaitInterval time.Duration, ) (net.Conn, int, error) { host, port, err := net.SplitHostPort(address) @@ -522,7 +595,7 @@ func dialViaNode( // own DNS can serve it. Those only resolve the way tsnet resolves, so // fall through and say so, since this is the path that can leave the // tailnet. - logf(fmt.Sprintf("Bridge target %s is not a node on this bridge's tailnet (%v); resolving it the usual way.", host, err)) + 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 } @@ -616,9 +689,9 @@ func preferIPv4(addrs []netip.Addr) (netip.Addr, bool) { return fallback, fallback.IsValid() } -func logBridgeStatus(logf func(string), status *ipnstate.Status, target *url.URL) { +func logBridgeStatus(ev events, status *ipnstate.Status, target *url.URL) { if status == nil { - logf("Bridge network status is unavailable.") + ev.note("Bridge network status is unavailable.") return } @@ -633,12 +706,12 @@ func logBridgeStatus(logf func(string), status *ipnstate.Status, target *url.URL if status.Self != nil { selfDNS = status.Self.DNSName } - logf(fmt.Sprintf( + 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 { - logf("Bridge health: " + strings.Join(status.Health, "; ")) + ev.note("Bridge health: " + strings.Join(status.Health, "; ")) } host := strings.ToLower(strings.TrimSuffix(target.Hostname(), ".")) @@ -649,12 +722,12 @@ func logBridgeStatus(logf func(string), status *ipnstate.Status, target *url.URL 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)) + ev.notef("Bridge target is visible: requested=%q peer=%q ips=%v", host, peer.DNSName, peer.TailscaleIPs) return } } - logf(fmt.Sprintf( + 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/manager_test.go b/internal/bridges/manager_test.go index fa81167..3cd5564 100644 --- a/internal/bridges/manager_test.go +++ b/internal/bridges/manager_test.go @@ -15,6 +15,8 @@ import ( "time" "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/connection" + "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" "tailscale.com/types/key" ) @@ -23,7 +25,7 @@ type fakeNode struct { backendAddr string status *ipnstate.Status statusFn func() (*ipnstate.Status, error) - watchFn func(logf func(string)) + watchFn func(ev events) upFn func() upErr error statusErr error @@ -69,13 +71,29 @@ func (n *fakeNode) DialContext(ctx context.Context, network, address string) (ne // WatchLogin stands in for the IPN bus watch: watchFn is what a test wants the // bus to report, and it runs until the manager cancels the watch. -func (n *fakeNode) WatchLogin(ctx context.Context, logf func(string)) { +func (n *fakeNode) WatchLogin(ctx context.Context, ev events) { if n.watchFn != nil { - n.watchFn(logf) + n.watchFn(ev) } <-ctx.Done() } +// 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() @@ -122,7 +140,7 @@ func TestActivateDebugDiagnostics(t *testing.T) { context.Background(), config.Bridge{ID: "bridge-abcdef", Name: "Work"}, "http://aperture", - func(line string) { logs = append(logs, line) }, + collect(&logs), ) if err != nil { t.Fatal(err) @@ -213,7 +231,7 @@ func TestActivateNormalLoggingOmitsDebugDiagnostics(t *testing.T) { context.Background(), config.Bridge{ID: "bridge-abcdef", Name: "Work"}, "http://aperture", - func(line string) { logs = append(logs, line) }, + collect(&logs), ) if err != nil { t.Fatal(err) @@ -262,7 +280,7 @@ func TestActivateWaitsForPeerMapBeforeDialing(t *testing.T) { context.Background(), config.Bridge{ID: "bridge-abcdef", Name: "Work"}, "http://ai", - func(line string) { logs = append(logs, line) }, + collect(&logs), ) if err != nil { t.Fatal(err) @@ -297,8 +315,13 @@ func TestActivateLogsLoginLinkWhileUpBlocks(t *testing.T) { backendAddr: backend.Listener.Addr().String(), status: tailnetStatus("ai.example.ts.net.", "100.64.0.2"), } - node.watchFn = func(logf func(string)) { - logf(AuthLogPrefix + url) + node.watchFn = func(ev events) { + link, err := connection.ParseLoginLink(url) + if err != nil { + t.Error(err) + return + } + ev.login(link) close(watched) } // Up stands in for the wait on an interactive login, and gives up so a @@ -322,11 +345,7 @@ func TestActivateLogsLoginLinkWhileUpBlocks(t *testing.T) { context.Background(), config.Bridge{ID: "bridge-abcdef", Name: "Work"}, "http://ai", - func(line string) { - mu.Lock() - defer mu.Unlock() - logs = append(logs, line) - }, + collectLocked(&mu, &logs), ); err != nil { t.Fatal(err) } @@ -334,7 +353,7 @@ func TestActivateLogsLoginLinkWhileUpBlocks(t *testing.T) { mu.Lock() defer mu.Unlock() for _, line := range logs { - if line == AuthLogPrefix+url { + if strings.Contains(line, url) { return } } @@ -345,7 +364,7 @@ func TestDialViaNode(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) defer backend.Close() backendAddr := backend.Listener.Addr().String() - discard := func(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")} @@ -397,7 +416,7 @@ func TestDialViaNode(t *testing.T) { conn, _, err := dialViaNode( context.Background(), node, "tcp", "ai:80", - func(line string) { logs = append(logs, line) }, + collect(&logs), 5*time.Millisecond, time.Millisecond, ) if err != nil { @@ -627,7 +646,7 @@ func activate(t *testing.T, backend *httptest.Server) activatedFixture { context.Background(), config.Bridge{ID: "bridge-abcdef", Name: "Work"}, "http://aperture.tailnet", - func(line string) { f.logs = append(f.logs, line) }, + collect(&f.logs), ) if err != nil { t.Fatal(err) @@ -773,3 +792,54 @@ func TestActivate(t *testing.T) { t.Run(tc.name, tc.run) } } + +// TestLoginReporterSplitsTheTwoNeedsLoginWaits is the diagnosis this whole +// change came from. A bridge took 29 seconds to come up and the screen said +// only that it needed a login, so there was no way to tell the control plane +// not having answered yet from a user who had not finished in the browser. +// Both are ipn.NeedsLogin; they are different phases here. +func TestLoginReporterSplitsTheTwoNeedsLoginWaits(t *testing.T) { + const url = "https://login.tailscale.com/a/28ba393017981" + var got []string + r := &loginReporter{ev: collect(&got)} + + state := func(s ipn.State) *ipn.Notify { return &ipn.Notify{State: &s} } + browse := func(u string) *ipn.Notify { return &ipn.Notify{BrowseToURL: &u} } + + 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(), + "Authorize this bridge at " + url, + "Authorize this bridge at " + url, + 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 := &loginReporter{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. + plaintext := "http://evil.example.com/a/x" + r.notify(&ipn.Notify{BrowseToURL: &plaintext}) + + 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], "Authorize this bridge at") { + t.Errorf("an http link was offered to the browser: %q", got[0]) + } +} diff --git a/internal/connection/event.go b/internal/connection/event.go new file mode 100644 index 0000000..47bb8f9 --- /dev/null +++ b/internal/connection/event.go @@ -0,0 +1,151 @@ +// Package connection carries what a connection attempt reports while it runs. +// +// It exists so the producer of these events does not own their vocabulary. +// Most of them come from the bridge manager, but the attempt is wider than the +// bridge: the model fetch that follows bring-up is part of the same wait, and +// the user does not know or care which half they are in. The types therefore +// sit below both. +// +// Nothing here may reference tsnet, ipn or ipnstate. Translating the tailnet's +// vocabulary into this one is the bridge manager's job, and this package is +// what it translates into. +package connection + +import ( + "fmt" + "net/url" + "strings" +) + +// Phase is what an attempt is waiting on, named for what the user is waiting +// for rather than for the backend state underneath it. +// +// AwaitingLoginLink and AwaitingAuthorization are the reason this type exists. +// Both are ipn.NeedsLogin, and they are completely different problems: one is +// the control plane not having answered yet, the other is the user not having +// finished in the browser. A bridge that took 29 seconds to come up spent them +// in the first and showed only "NeedsLogin", so there was nothing on screen to +// tell the two apart and three fixes were aimed at the wrong one. +type Phase int + +// Phases in the order an attempt passes through them. The order is load +// bearing: a phase only ever moves forward, and Entered compares them. +const ( + StartingMachine Phase = iota + AwaitingLoginLink + AwaitingAuthorization + JoiningTailnet + FindingEndpoint + AskingForModels +) + +// String is what the connect screen shows, so it names the wait from the +// user's side. The attempt's elapsed clock supplies the "how long". +func (p Phase) String() string { + switch p { + case StartingMachine: + return "Starting the bridge" + case AwaitingLoginLink: + return "Waiting for a login link" + case AwaitingAuthorization: + return "Waiting for you to authorize this bridge" + case JoiningTailnet: + return "Joining the tailnet" + case FindingEndpoint: + return "Looking for the Aperture on the tailnet" + case AskingForModels: + return "Asking the Aperture for its models" + } + return fmt.Sprintf("Phase(%d)", int(p)) +} + +// LoginLink is the URL that authorizes a machine on a tailnet. +type LoginLink struct { + url string +} + +func (l LoginLink) String() string { return l.url } + +// ParseLoginLink validates a login link and is the only way to make one. +// +// The rules are not cosmetic: the value is handed to a desktop opener and +// shown as something the user should click, so anything that is not an https +// URL is not a link we were asked to follow. Tailscale applies the same rules +// upstream in validPopBrowserURLLocked; this is the second gate, not the first. +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: %q", raw) + } + parsed, err := url.Parse(raw) + if err != nil { + return LoginLink{}, fmt.Errorf("login link is not a URL: %w", err) + } + if parsed.Scheme != "https" { + return LoginLink{}, fmt.Errorf("login link is not https: %q", raw) + } + if parsed.Host == "" { + return LoginLink{}, fmt.Errorf("login link has no host: %q", raw) + } + return LoginLink{url: raw}, nil +} + +// Kind distinguishes the events an attempt publishes. +type Kind int + +const ( + // Noted is diagnostics with no domain meaning: tsnet backend chatter, dial + // detail, a health warning. The only kind a consumer may drop. + Noted Kind = iota + // PhaseEntered is the attempt moving to a new wait. + PhaseEntered + // LoginRequired is a machine asking to be authorized at a link. + LoginRequired +) + +// Event is what an attempt publishes as it proceeds. It replaces a log sink of +// plain strings, which forced every consumer to recover meaning by matching +// prose: the TUI opened a browser on a phrase from inside a vendored package, +// so a reworded upstream log line would silently strand the user. +type Event struct { + Kind Kind + Phase Phase // Kind == PhaseEntered + Link LoginLink // Kind == LoginRequired + Text string // Kind == Noted +} + +// Note reports diagnostics. +func Note(text string) Event { return Event{Kind: Noted, Text: text} } + +// Notef reports diagnostics, formatted. +func Notef(format string, args ...any) Event { return Note(fmt.Sprintf(format, args...)) } + +// Entered reports that the attempt is now waiting on p. +func Entered(p Phase) Event { return Event{Kind: PhaseEntered, Phase: p} } + +// Login reports that the machine needs authorizing at link. +func Login(link LoginLink) Event { return Event{Kind: LoginRequired, Link: link} } + +// Droppable reports whether a consumer under backpressure may discard this +// event. Only diagnostics may go: losing a phase leaves a gap in the record of +// where the time went, and losing a login link leaves the user waiting on a +// browser tab that was never opened at a URL they were never shown. The old +// sink dropped whatever arrived on a full buffer, and under -debug the tsnet +// backend logger shared that buffer, so a burst of chatter could take the one +// line the user could not proceed without. +func (e Event) Droppable() bool { return e.Kind == Noted } + +// String renders the event as one line of the activation log. +func (e Event) String() string { + switch e.Kind { + case PhaseEntered: + return e.Phase.String() + case LoginRequired: + return "Authorize this bridge at " + e.Link.String() + default: + return e.Text + } +} diff --git a/internal/connection/event_test.go b/internal/connection/event_test.go new file mode 100644 index 0000000..cada9fc --- /dev/null +++ b/internal/connection/event_test.go @@ -0,0 +1,80 @@ +package connection + +import "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) + } + }) + } +} + +// 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}, + {Login(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 for the screen", int(p)) + } + } +} diff --git a/internal/tui/browser.go b/internal/tui/browser.go index 9cdedcc..eb01b1d 100644 --- a/internal/tui/browser.go +++ b/internal/tui/browser.go @@ -7,39 +7,8 @@ import ( "strings" "github.com/aymanbagabas/go-osc52/v2" - "github.com/tailscale/aperture-cli/internal/bridges" ) -// tsnetAuthURLMarker is what tsnet logs ahead of the login link while a bridge -// waits to be authorized ("... restart with TS_AUTHKEY set, or go to: "). -// It repeats the whole line every few seconds until login completes. -const tsnetAuthURLMarker = "or go to: " - -// authURLMarkers are the two phrasings that carry a login link. The bridge -// manager emits its line the moment the IPN bus has the link; tsnet's own -// line comes out of a five second poll, so it usually repeats one that is -// already on screen. -var authURLMarkers = []string{bridges.AuthLogPrefix, tsnetAuthURLMarker} - -// authURLFromLog returns the Tailscale login link a bridge log line carries, -// or "" when it carries none. The https:// requirement is not cosmetic: the -// result is handed to a desktop opener, and anything else (a file path, a -// leading dash) is not a link the user asked us to follow. -func authURLFromLog(line string) string { - for _, marker := range authURLMarkers { - _, rest, ok := strings.Cut(line, marker) - if !ok { - continue - } - url := strings.TrimSpace(rest) - if !strings.HasPrefix(url, "https://") || strings.ContainsAny(url, " \t") { - return "" - } - return url - } - return "" -} - // openURL asks the desktop to open a link. Start, not Run: the opener can // block for as long as the browser it launches lives, and a headless box // fails here by not having an opener at all, which Start already reports. @@ -65,9 +34,9 @@ var openURL = func(url string) error { // copyToClipboard puts s on the clipboard of whatever terminal is displaying // this TUI, over OSC 52. A local clipboard helper (xclip, pbcopy) would put it -// on the clipboard of the machine aperture runs on, which over SSH is the -// wrong machine and the one case where the user most needs the link: the -// escape sequence travels back up the SSH session to the terminal the user is +// on the clipboard of the host aperture runs on, which over SSH is the wrong +// computer and the one case where the user most needs the link: the escape +// sequence travels back up the SSH session to the terminal the user is // actually looking at. Overridable in tests, which have no terminal to write // escape sequences at. // diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 9d68d3a..debd2e8 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -22,6 +22,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" ) @@ -128,10 +129,13 @@ type activation struct { ephemeral bool logCh chan bridgeLine logCtx context.Context + // phase is the wait this attempt is in, and phaseSet distinguishes "not + // started" from StartingMachine, which is the zero value. + phase connection.Phase + phaseSet bool // authURL is the Tailscale login link already surfaced for this attempt. - // tsnet reprints its line every few seconds, so this is what keeps the - // log tail from filling with one repeated URL and the browser from being - // opened again on each repeat. + // The control plane can re-send it on the bus, so this is what keeps the + // browser from being opened 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 @@ -145,7 +149,22 @@ type activation struct { // 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), text: text} + return bridgeLine{elapsed: time.Since(a.started), event: connection.Note(text)} +} + +// entered records a phase the attempt moved into, and reports whether it moved. +// +// The attempt owns this rule, not the bridge: phases reach it from the IPN bus +// watch and from the manager's own progress, and only something that sees both +// can keep them in order. A phase that does not move forward is dropped rather +// than shown, because a bus that re-notifies NeedsLogin after the link is on +// screen would otherwise walk the user backwards through their own wait. +func (a *activation) entered(p connection.Phase) bool { + if a.phaseSet && p <= a.phase { + return false + } + a.phase, a.phaseSet = p, true + return true } // cancelable reports whether Esc can interrupt this attempt. @@ -216,20 +235,20 @@ type endpointActivationResult struct { err error } -// bridgeLine is one activation log line and how far into the attempt it was -// produced. The elapsed time is the reason this is a struct and not a string: -// a bridge that takes half a minute to come up spends that time in one of -// three places (the control plane answering with a login link, the user in the -// browser, the first dial), and an unstamped log cannot tell them apart. Three -// separate fixes have now been aimed at that wait without knowing which. +// bridgeLine is one thing the attempt reported and how far into the attempt it +// was reported. The elapsed time is the reason this is a struct and not a +// string: a bridge that takes half a minute to come up spends that time in one +// of three places (the control plane answering with a login link, the user in +// the browser, the first dial), and an unstamped log cannot tell them apart. +// Three separate fixes have now been aimed at that wait without knowing which. type bridgeLine struct { elapsed time.Duration - text string + event connection.Event } // String renders a log line the way the connect screen shows it. func (l bridgeLine) String() string { - return fmt.Sprintf("+%-6s %s", l.elapsed.Round(100*time.Millisecond), l.text) + return fmt.Sprintf("+%-6s %s", l.elapsed.Round(100*time.Millisecond), l.event) } type bridgeLogMsg struct { @@ -386,25 +405,27 @@ func (m *model) beginActivation(ep config.Endpoint, ephemeral, switchTailnet boo if switchTailnet { act.label = "Switching bridge " + bridge.Name + " to a different tailnet ..." } - bridgeLogf := bridgeLogSink(ctx, ch, act.started) + emit := bridgeLogSink(ctx, ch, act.started) activate := func() tea.Msg { defer cancel() - // Inside the attempt, so it shares the attempt's cancellation and log + // Inside the attempt, so it 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 switchTailnet { - if err := m.bridgeManager.SwitchTailnet(ctx, bridge, bridgeLogf); err != nil { + if err := m.bridgeManager.SwitchTailnet(ctx, bridge, emit); err != nil { return endpointActivationResult{id: act.id, endpoint: ep, host: ep.URL, err: err} } } - localURL, err := m.bridgeManager.Activate(ctx, bridge, ep.URL, bridgeLogf) + localURL, err := m.bridgeManager.Activate(ctx, bridge, ep.URL, emit) if err != nil { return endpointActivationResult{id: act.id, endpoint: ep, host: ep.URL, err: err} } // The request below is the longest silent stretch of the whole // attempt: the bridge is up, so tsnet has stopped logging, and - // nothing else names the host being waited on. - bridgeLogf("Asking " + ep.URL + " for its models ...") + // nothing else names the host being waited on. The phase comes from + // here and not from the bridge because asking an Aperture for its + // models is the attempt's own work, not the bridge's. + emit(connection.Entered(connection.AskingForModels)) provs, err := fetchProvidersContext(ctx, localURL, bridgeProviderFetchTimeout) if err != nil { err = fmt.Errorf("bridge %s could not reach %s: %w", bridge.Name, ep.URL, err) @@ -536,25 +557,44 @@ func (m *model) overrideActivationURL(value string) (tea.Model, tea.Cmd) { return m, m.activateEndpoint(next, ephemeral, false) } -func bridgeLogSink(ctx context.Context, ch chan<- bridgeLine, started time.Time) func(string) { - return func(text string) { - text = strings.TrimSpace(text) - if text == "" { - return +// bridgeLogSink is where the attempt's events land 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 own cancellation, because the events that +// are not diagnostics are the ones the user cannot proceed without: this sink +// used to drop whatever arrived on a full buffer, and under -debug the tsnet +// backend logger shares it, so a burst of chatter could take the login link +// with it and strand the attempt on a link nobody ever saw. +func bridgeLogSink(ctx context.Context, ch chan<- bridgeLine, started time.Time) func(connection.Event) { + return func(ev connection.Event) { + if ev.Kind == connection.Noted { + ev.Text = strings.TrimSpace(ev.Text) + if ev.Text == "" { + 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), text: text} + 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: } } } @@ -693,15 +733,21 @@ func (m *model) update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } next := waitBridgeLog(m.act.logCtx, m.act.logCh) - if url := authURLFromLog(msg.line.text); url != "" { + switch msg.line.event.Kind { + case connection.LoginRequired: + url := msg.line.event.Link.String() if url == m.act.authURL { - return m, next // tsnet reprinting the same link + 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 connection.PhaseEntered: + if !m.act.entered(msg.line.event.Phase) { + return m, next + } } m.bridgeLogs = appendBridgeLog(m.bridgeLogs, msg.line) return m, next @@ -823,7 +869,7 @@ func appendBridgeLog(logs []bridgeLine, line bridgeLine) []bridgeLine { for len(logs) > bridgeLogLimit { drop := 0 for i, line := range logs { - if !importantBridgeLog(line.text) { + if !line.important() { drop = i break } @@ -833,6 +879,13 @@ func appendBridgeLog(logs []bridgeLine, line bridgeLine) []bridgeLine { 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) important() bool { + return l.event.Kind != connection.Noted || importantBridgeLog(l.event.Text) +} + func importantBridgeLog(line string) bool { for _, prefix := range []string{ "Could not open a browser here", diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 682365e..bcbd203 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -13,9 +13,9 @@ 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" ) @@ -1208,12 +1208,12 @@ func TestActivationTickRunsOnlyWhileConnecting(t *testing.T) { // whether the gap between them was 200ms or 29s. func TestBridgeLogSinkStampsElapsed(t *testing.T) { ch := make(chan bridgeLine, 1) - logf := bridgeLogSink(context.Background(), ch, time.Now().Add(-12500*time.Millisecond)) - logf(" Bridge connected. ") + emit := bridgeLogSink(context.Background(), ch, time.Now().Add(-12500*time.Millisecond)) + emit(connection.Note(" Bridge connected. ")) line := <-ch - if line.text != "Bridge connected." { - t.Errorf("text = %q, want it trimmed", line.text) + if line.event.Text != "Bridge connected." { + t.Errorf("text = %q, want it trimmed", line.event.Text) } if line.elapsed < 12*time.Second { t.Errorf("elapsed = %s, want it measured from the attempt's start", line.elapsed) @@ -1226,19 +1226,22 @@ func TestBridgeLogSinkStampsElapsed(t *testing.T) { func TestBridgeLogSinkIgnoresLateLogsAfterCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) ch := make(chan bridgeLine, 1) - logf := bridgeLogSink(ctx, ch, time.Now()) + 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 bridgeLine, 1) - ch <- bridgeLine{text: "final dial error"} + ch <- bridgeLine{event: connection.Note("final dial error")} cancel() msg := waitBridgeLog(ctx, ch)() @@ -1246,56 +1249,36 @@ func TestWaitBridgeLogDrainsBufferedLogBeforeCancellation(t *testing.T) { if !ok { t.Fatalf("message = %T, want bridgeLogMsg", msg) } - if logMsg.line.text != "final dial error" { - t.Errorf("line = %q, want final dial error", logMsg.line.text) + if logMsg.line.event.Text != "final dial error" { + t.Errorf("line = %q, want final dial error", logMsg.line.event.Text) } } func TestAppendBridgeLogRetainsDiagnosticsOverTsnetNoise(t *testing.T) { logs := []bridgeLine{ - {text: `Bridge network: state=Running tailnet="example.com" peers=598`}, - {text: `Bridge target is visible: requested="aperture.example.ts.net"`}, + {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, bridgeLine{text: fmt.Sprintf("magicsock: noisy line %d", i)}) + logs = appendBridgeLog(logs, bridgeLine{event: connection.Notef("magicsock: noisy line %d", i)}) } - logs = appendBridgeLog(logs, bridgeLine{text: "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) } var got string for _, line := range logs { - got += line.text + "\n" + got += line.event.String() + "\n" } - for _, want := range []string{"Bridge network:", "Bridge target is visible:", "Bridge dial failed:"} { + 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 TestAuthURLFromLog(t *testing.T) { - const tsnetLine = "To start this tsnet server, restart with TS_AUTHKEY set, or go to: https://login.tailscale.com/a/17bceb7b0129ba" - for _, tt := range []struct { - line string - want string - }{ - {tsnetLine, "https://login.tailscale.com/a/17bceb7b0129ba"}, - // The manager's own line, which beats tsnet's by up to five seconds. - {bridges.AuthLogPrefix + "https://login.tailscale.com/a/17bceb7b0129ba", "https://login.tailscale.com/a/17bceb7b0129ba"}, - {bridges.AuthLogPrefix + "http://evil.example.com", ""}, - {"magicsock: home is derp-1", ""}, - {"or go to: http://evil.example.com", ""}, - {"or go to: --version", ""}, - {"or go to: https://login.tailscale.com/a/x --flag", ""}, - } { - if got := authURLFromLog(tt.line); got != tt.want { - t.Errorf("authURLFromLog(%q) = %q, want %q", tt.line, got, tt.want) - } - } -} - // 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) { @@ -1313,7 +1296,10 @@ func runCmd(t *testing.T, cmd tea.Cmd) { const testAuthURL = "https://login.tailscale.com/a/17bceb7b0129ba" func TestBridgeAuthURLIsShownOnceAndOpened(t *testing.T) { - const tsnetLine = "To start this tsnet server, restart with TS_AUTHKEY set, or go to: " + testAuthURL + link, err := connection.ParseLoginLink(testAuthURL) + if err != nil { + t.Fatal(err) + } var opened []string orig := openURL @@ -1334,12 +1320,12 @@ func TestBridgeAuthURLIsShownOnceAndOpened(t *testing.T) { act: &activation{id: 7, logCh: ch, logCtx: ctx}, } - _, cmd := m.Update(bridgeLogMsg{ch: ch, line: bridgeLine{text: tsnetLine}}) + _, cmd := m.Update(bridgeLogMsg{ch: ch, line: bridgeLine{event: connection.Login(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{text: tsnetLine}}) + _, cmd = m.Update(bridgeLogMsg{ch: ch, line: bridgeLine{event: connection.Login(link)}}) runCmd(t, cmd) if len(opened) != 1 { t.Errorf("repeated auth URL opened the browser again: %q", opened) @@ -1356,7 +1342,7 @@ func TestBridgeAuthURLIsShownOnceAndOpened(t *testing.T) { } m.Update(browserOpenMsg{id: 7, err: errors.New("exec: \"xdg-open\": not found")}) - if len(m.bridgeLogs) != 1 || !strings.Contains(m.bridgeLogs[0].text, "Use the link below") { + if len(m.bridgeLogs) != 1 || !strings.Contains(m.bridgeLogs[0].event.Text, "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")}) @@ -1554,7 +1540,7 @@ func TestFailureViewWrapsDiagnostics(t *testing.T) { 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: []bridgeLine{ - {text: `Bridge network: state=Running tailnet="example.com" dns_suffix="example.ts.net" peers=597`}, + {event: connection.Note(`Bridge network: state=Running tailnet="example.com" dns_suffix="example.ts.net" peers=597`)}, }, } m.resetStack(m.setupGuideMenu()) @@ -1596,3 +1582,46 @@ 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.Login(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.Kind == connection.LoginRequired { + <-sent + return + } + case <-deadline: + t.Fatal("the login link never arrived; a full buffer swallowed it") + } + } +} From 08c383e3ff771476aef14d42bb4aeb8d73a3e413 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Thu, 17 Sep 2026 02:39:31 +0000 Subject: [PATCH 24/69] docs: record which of the six events pass 1 actually built The contracts pass specified six domain events; three are in the code. A spec that reads as fully implemented when half of it is not is worse than no spec, because the next reader trusts it and goes looking for a TailnetJoined that does not exist. Each gap gets its reason rather than a status: two are blocked on ownership decisions this pass explicitly deferred, PhaseEntered dropped its Progress payload because the screen already computes elapsed time from one clock and a second copy can disagree with the first, and the three terminal Phases wait for the Attempt aggregate rather than ship as constants nothing writes. Also corrects the JoiningTailnet signal in the Phase table: ipn.Starting is one notification covering what the table described as LoginFinished then SelfChange. --- docs/specs/connection-contracts.md | 18 ++++++++++++++++++ docs/specs/connection-domain-model.md | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/specs/connection-contracts.md b/docs/specs/connection-contracts.md index 8caf972..861dc4d 100644 --- a/docs/specs/connection-contracts.md +++ b/docs/specs/connection-contracts.md @@ -52,6 +52,24 @@ 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 | Blocked on the Bridge/tailnet ownership decision above. `Manager.Tailnet` and `recordBridgeTailnet` still carry it. | +| `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 diff --git a/docs/specs/connection-domain-model.md b/docs/specs/connection-domain-model.md index 18b73da..5a254d8 100644 --- a/docs/specs/connection-domain-model.md +++ b/docs/specs/connection-domain-model.md @@ -90,7 +90,7 @@ Enumeration. Named for what the user is waiting for, not for `ipn.State`. | `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 | `Notify.LoginFinished`, then `Notify.SelfChange` when the netmap lands | +| `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 | From 75a2188b78380bb27b77d1d680b1ab8507181c8f Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Thu, 17 Sep 2026 15:49:47 +0000 Subject: [PATCH 25/69] bridges: name the register wait, which is ipn.NoState and not NeedsLogin A first bridge connection sat for over a minute showing "Starting the bridge" and nothing after it. The goroutine dump has controlclient parked in POST /machine/register with an empty loginOpt.URL, so this is the initial register, not a followup poll. The translation missed the state that covers it. A bridge that has never logged in is ipn.NoState for the whole register and only reaches NeedsLogin once control answers with a URL, because nextStateLocked returns NeedsLogin only when cc.AuthCantContinue() is true. Mapping NeedsLogin alone therefore named every wait except the long one. Tailscale's own comment on the state says UIs should print "Loading...", which is the same observation. Silence was survivable before because tsnet's UserLogf dribbled backend lines into the pane; the commit that took the vocabulary off prose also gated that behind -debug, so the gap became total. Mapping NoState to AwaitingLoginLink rather than adding a phase of its own is deliberate: it is the same wait for the same thing, the user cannot act in either, and the phase guard collapses the NoState-then-NeedsLogin pair to one line. ipn.Stopped and ipn.InUseOtherUser are still unmapped and still silent. Neither is reachable on the path this fixes. --- internal/bridges/manager.go | 8 ++++++- internal/bridges/manager_test.go | 41 ++++++++++++++++++++++++++++---- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/internal/bridges/manager.go b/internal/bridges/manager.go index daca1d3..abe1cd1 100644 --- a/internal/bridges/manager.go +++ b/internal/bridges/manager.go @@ -180,7 +180,13 @@ func (r *loginReporter) notify(n *ipn.Notify) { } if n.State != nil { switch *n.State { - case ipn.NeedsLogin: + case ipn.NoState, ipn.NeedsLogin: + // Both, and NoState is the one that matters. A bridge that has + // never logged in sits in NoState for the whole of + // POST /machine/register and only reaches NeedsLogin once control + // has answered with a URL, so NoState is the wait, not a + // not-started-yet. Tailscale's own comment on it reads "UIs should + // print Loading..." (ipnlocal/local.go, nextStateLocked). r.enter(connection.AwaitingLoginLink) case ipn.NeedsMachineAuth: // No phase of its own: we have never seen it, and inventing a wait diff --git a/internal/bridges/manager_test.go b/internal/bridges/manager_test.go index 3cd5564..2e8ec2e 100644 --- a/internal/bridges/manager_test.go +++ b/internal/bridges/manager_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "net/netip" + "slices" "strings" "sync" "sync/atomic" @@ -793,6 +794,12 @@ func TestActivate(t *testing.T) { } } +// 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 whole // change came from. A bridge took 29 seconds to come up and the screen said // only that it needed a login, so there was no way to tell the control plane @@ -803,9 +810,6 @@ func TestLoginReporterSplitsTheTwoNeedsLoginWaits(t *testing.T) { var got []string r := &loginReporter{ev: collect(&got)} - state := func(s ipn.State) *ipn.Notify { return &ipn.Notify{State: &s} } - browse := func(u string) *ipn.Notify { return &ipn.Notify{BrowseToURL: &u} } - r.notify(state(ipn.NeedsLogin)) r.notify(state(ipn.NeedsLogin)) // the bus repeats itself r.notify(browse(url)) @@ -833,8 +837,7 @@ func TestLoginReporterRejectsAnUnusableLink(t *testing.T) { // http, not https. The value is handed to a desktop opener, so this is the // one thing that must not pass through untouched. - plaintext := "http://evil.example.com/a/x" - r.notify(&ipn.Notify{BrowseToURL: &plaintext}) + 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) @@ -843,3 +846,31 @@ func TestLoginReporterRejectsAnUnusableLink(t *testing.T) { t.Errorf("an http link was offered to the browser: %q", got[0]) } } + +// TestLoginReporterNamesTheWaitBeforeTheControlPlaneAnswers is the state the +// first version of this missed. A bridge that has never logged in sits in +// ipn.NoState for the whole of POST /machine/register, and only reaches +// NeedsLogin once control has answered with a URL, so NoState is the entire +// wait this refactor exists to name. Untranslated it emits nothing, and a +// register that took over a minute put "Starting the bridge" on screen and +// then went silent. +func TestLoginReporterNamesTheWaitBeforeTheControlPlaneAnswers(t *testing.T) { + var lines []string + reporter := loginReporter{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) + } +} From cc525d2d553fd95d4cb71285bd8f217276d87236 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Thu, 17 Sep 2026 15:58:51 +0000 Subject: [PATCH 26/69] bridges: point a node's reporting at the connection using it now A node is cached in Manager.nodes and its proxies in nodeRuntime.proxies for the life of the process. The connection that built them ends with the connect screen. runningNode gave the node's UserLogf and DebugLogf a closure over that connection's sink, and startProxy did the same for the transport's DialContext and the proxy's ErrorHandler, so from the second connection onward every "Bridge dial failed" and every "Bridge proxy error" was written to a channel nobody had read since the first one finished. That is the output most worth having: a bridge that breaks mid-session breaks in the proxy, not during bring-up. It was silent, and silently, which is why nothing caught it. nodeRuntime gains one field rather than Manager gaining state: the sink belongs to the node that reports through it, and Manager already holds more than it should. Passing the sink down per call was the alternative and does not work, because the closures are installed once at construction and run on goroutines the caller does not own. Nothing clears the sink when a connection ends, so a node with no connection in progress still holds the last one's. Harmless: that sink discards what it is given once its context is cancelled, which is the behaviour this replaces. Clearing needs a lifecycle hook the Attempt aggregate will own. --- docs/adr/0001-connection-bounded-context.md | 25 +++++++++ internal/bridges/manager.go | 57 +++++++++++++++++++-- internal/bridges/manager_test.go | 48 +++++++++++++++++ 3 files changed, 127 insertions(+), 3 deletions(-) diff --git a/docs/adr/0001-connection-bounded-context.md b/docs/adr/0001-connection-bounded-context.md index e47ebe6..3c78ae0 100644 --- a/docs/adr/0001-connection-bounded-context.md +++ b/docs/adr/0001-connection-bounded-context.md @@ -155,6 +155,31 @@ Bad, and accepted: seconds while a login is outstanding. Nothing we can do from outside tsnet. - A migration: every `g.ApertureHost` reader changes. +## Deferred: the channel plumbing + +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, +and the plumbing has four knots that the shape this ADR describes removes as a +side effect. Recorded here rather than fixed piecemeal, because three of the +four are one change: the Machine owns a long-lived stream and each Attempt +subscribes to it for its own lifetime. + +| Knot | Where | What it costs today | +|---|---|---| +| Two identity mechanisms for "is this message from the current attempt" | `bridgeLogMsg` compares channel pointers (`tui.go:732`); `browserOpenMsg`, `clipboardMsg` and `activationTickMsg` compare `act.id` | `bridgeLogDoneMsg` exists only to unwire the pointer one. Same question, two answers, and a reader has to know which applies where. | +| One goroutine per log line | `waitBridgeLog` receives one value and re-arms itself through the event loop | A `--debug` burst is a spawn per line. Works, and is the documented bubbletea idiom for a channel, which is the argument for a subscription instead of a channel. | +| `WatchLogin` starts only when the node is created | `runningNode` (`manager.go:353`) returns early for a cached node | A re-login on an existing Machine reports no phases and surfaces no link. The `ev.enter(FindingEndpoint)` in `Activate` papers over the common case and nothing covers the rest. | +| The sink outlives the Attempt that made it | fixed ahead of the rework; see below | | + +The last one was a live defect rather than untidiness, so it is fixed now: +`runningNode` gave the node's `UserLogf`/`DebugLogf` a closure over the first +Attempt's sink, and `startProxy` did the same for `transport.DialContext` and +`proxy.ErrorHandler`. Nodes and proxies are cached in `Manager.nodes` and +`nodeRuntime.proxies` for the life of the process; Attempts are not. From the +second Attempt onward every dial diagnostic and every `Bridge proxy error` was +written to a cancelled channel and dropped, which is exactly the output wanted +when a bridge breaks mid-session. + ## Alternatives considered **Timestamp the log lines and stop there.** Already shipped (`53f2148`) and it diff --git a/internal/bridges/manager.go b/internal/bridges/manager.go index abe1cd1..17dcc86 100644 --- a/internal/bridges/manager.go +++ b/internal/bridges/manager.go @@ -47,6 +47,46 @@ const ( type nodeRuntime struct { node tailnetNode proxies map[string]*proxyRuntime + // ev is where this node and its proxies report, and it is an indirection + // rather than a captured sink because they outlive the connection that + // created them. See liveEvents. + ev *liveEvents +} + +// liveEvents points a node's long-lived reporting at whichever connection is +// using it now. +// +// A node is cached in Manager.nodes and its proxies in nodeRuntime.proxies for +// the life of the process; the connection that built them ends with the +// connect screen. Closures that captured that connection's own sink kept +// writing to it, so from the second connection onward every dial failure and +// every proxy error was handed to a channel nobody had read since the first +// one finished, which is exactly the output wanted when a bridge breaks +// mid-session. +// +// Nothing clears it when a connection ends. That is deliberate: the sink of a +// finished connection discards what it is given, so the worst case is the +// pre-existing behaviour, and a clear would need a lifecycle hook that only +// the Attempt aggregate can own. +type liveEvents struct { + mu sync.Mutex + ev events +} + +func (l *liveEvents) use(ev events) { + l.mu.Lock() + defer l.mu.Unlock() + l.ev = ev +} + +// emit has the events signature, so callers keep note and notef. +func (l *liveEvents) emit(e connection.Event) { + l.mu.Lock() + ev := l.ev + l.mu.Unlock() + if ev != nil { + ev(e) + } } type proxyRuntime struct { @@ -299,7 +339,7 @@ func (m *Manager) Activate(ctx context.Context, bridge config.Bridge, remoteURL return proxy.localURL, nil } - proxy, err := m.startProxy(rt.node, target, ev) + proxy, err := m.startProxy(rt, target) if err != nil { return "", err } @@ -316,6 +356,9 @@ func (m *Manager) runningNode(ctx context.Context, bridge config.Bridge, ev even rt := m.nodes[bridge.ID] if rt != nil { m.mu.Unlock() + // The node and its proxies were built by an earlier connection whose + // sink is long gone. Point them at this one before returning. + rt.ev.use(ev) return rt, nil, nil } stateDir, err := config.BridgeStateDir(bridge.ID) @@ -332,15 +375,18 @@ func (m *Manager) runningNode(ctx context.Context, bridge config.Bridge, ev even // // A no-op rather than nil: tsnet falls back to log.Printf when UserLogf is // unset, which writes over the TUI. + live := &liveEvents{} + live.use(ev) logNotes := func(format string, args ...any) { if m.debug { - ev.notef(format, args...) + events(live.emit).notef(format, args...) } } userLogf, debugLogf := logNotes, logNotes rt = &nodeRuntime{ node: m.newNode(bridge, stateDir, userLogf, debugLogf), proxies: make(map[string]*proxyRuntime), + ev: live, } m.nodes[bridge.ID] = rt m.mu.Unlock() @@ -506,7 +552,12 @@ func parseTarget(raw string) (*url.URL, error) { return target, nil } -func (m *Manager) startProxy(node tailnetNode, target *url.URL, ev events) (*proxyRuntime, error) { +// startProxy builds the reverse proxy for one target on rt's node. It reports +// through rt rather than through the connection that asked, because the proxy +// it returns is cached and will still be serving long after that connection +// has gone. +func (m *Manager) startProxy(rt *nodeRuntime, target *url.URL) (*proxyRuntime, error) { + node, ev := rt.node, events(rt.ev.emit) debug := m.debug ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { diff --git a/internal/bridges/manager_test.go b/internal/bridges/manager_test.go index 2e8ec2e..10c4392 100644 --- a/internal/bridges/manager_test.go +++ b/internal/bridges/manager_test.go @@ -874,3 +874,51 @@ func TestLoginReporterNamesTheWaitBeforeTheControlPlaneAnswers(t *testing.T) { t.Errorf("reported %q, want the wait named once", lines) } } + +// TestAProxyReportsToTheAttemptUsingItNow covers a defect the typed events +// introduced and the string logger had too: nodes and proxies are cached for +// the life of the process, attempts are not, and the closures inside +// startProxy captured whichever attempt happened to create the proxy. Every +// dial failure after the first connection went to a channel nobody had read +// since, which is precisely the output wanted when a bridge breaks mid-session. +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 := f.manager.Activate( + 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) + } +} From ff2c0fcd70de1e55964441ee9e698132c6a0f978 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Thu, 17 Sep 2026 17:21:20 +0000 Subject: [PATCH 27/69] tui: stop grabbing the mouse on the connect screen Mouse reporting was on for the whole time the login link showed, which is the one screen whose text people need to get out of the terminal. With reporting on the terminal forwards drags and ctrl-clicks to the app, so selection, copy and open-URL all died exactly where they mattered, and over SSH the click never arrived at all: tmux without `mouse on` and terminals with reporting off never send the events, leaving those users no copy path. The click was there because the override editor owns every printable key on that screen, so ctrl+y takes its place: `textField.insert` already drops control runes, so the chord costs the editor nothing. The link also had to come off the prose line. Bubble Tea's renderer truncates anything wider than the terminal, so a long URL wraps, and the old footer wrapped it with a "copy" label and the prose on the same lines, so a selection picked those up too. Alone on bare lines it pastes clean, browsers strip the newline. Each piece carries the same id-tagged OSC 8 hyperlink so the terminal rejoins them into one ctrl-click target. Keeping both was not an option: reporting on is what breaks selection, so the mouse had to go for the rest to work. Revisit if the override editor ever leaves this screen and the keyboard frees up. --- docs/specs/connection-contracts.md | 2 +- internal/tui/tui.go | 123 ++++++++++------------------- internal/tui/tui_test.go | 112 ++++++++++++++++---------- 3 files changed, 116 insertions(+), 121 deletions(-) diff --git a/docs/specs/connection-contracts.md b/docs/specs/connection-contracts.md index 861dc4d..4d2e41a 100644 --- a/docs/specs/connection-contracts.md +++ b/docs/specs/connection-contracts.md @@ -23,7 +23,7 @@ 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, copy button, browser open) | never dropped; this is the event whose loss strands the user | internal to Connection | none; `ConnectionAttempt.Authorize` is a single aggregate method | +| `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 | diff --git a/internal/tui/tui.go b/internal/tui/tui.go index debd2e8..f4db484 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -102,12 +102,6 @@ type model struct { bridgeLogs []bridgeLine failedEndpoint *config.Endpoint connected bool - - // mouseOn tracks whether mouse reporting is currently enabled. It is only - // on while the login link's copy button is on screen: with reporting on, - // the terminal's own click-drag selection needs a Shift the user has no - // reason to expect, and every other screen here is text worth selecting. - mouseOn bool } // activation is the connection attempt currently on screen. It owns the @@ -635,33 +629,7 @@ func (m *model) quitCmd() tea.Cmd { } } -// Update handles a message and then reconciles mouse reporting with what is on -// screen. Doing it here rather than at each transition is what keeps reporting -// from being left on by a path nobody thought about: every way off the connect -// screen goes through this function. func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - next, cmd := m.update(msg) - if mouse := m.syncMouse(); mouse != nil { - return next, tea.Batch(cmd, mouse) - } - return next, cmd -} - -// syncMouse returns the command that turns mouse reporting on or off, or nil -// when it already matches the screen. -func (m *model) syncMouse() tea.Cmd { - want := m.step == stepPreflight && m.act != nil && m.act.authURL != "" - if want == m.mouseOn { - return nil - } - m.mouseOn = want - if want { - return tea.EnableMouseCellMotion - } - return tea.DisableMouse -} - -func (m *model) update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width @@ -775,15 +743,12 @@ func (m *model) update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 with the mouse instead.")) + 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 tea.MouseMsg: - return m.updateMouse(msg) - case bridgeLogDoneMsg: if m.act != nil && m.act.logCh == msg.ch { m.act.logCh = nil @@ -1084,6 +1049,12 @@ 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.cancelable() { return m, nil } @@ -1109,64 +1080,56 @@ func (m *model) updatePreflight(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } -// authCopyLabel and authCopiedLabel are the copy button beside the login link, -// before and after a click. The glyph alone is not a word anyone reads as -// "clickable", so it carries one. +// authCopyHint and authCopiedHint are the line under the login link, before +// and after ctrl+y. The key has to be named on screen: nothing about a URL +// suggests which chord copies it. const ( - authCopyLabel = "⧉ copy" - authCopiedLabel = "✓ copied" + 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, -// and reports the terminal columns its copy button occupies. +// authFooter renders the login link pinned to the foot of the connect screen. // -// Only columns: a mouse click carries an absolute terminal row, and this TUI -// renders inline rather than in the alternate screen, so the row the footer -// landed on is not knowable from here. A click in the button's columns on some -// other row copies a link the user was asking for anyway. +// The link owns its lines outright, with no prose beside it and no indent +// under it. Bubble Tea's renderer truncates any line wider than the terminal, +// so a long URL has to wrap, and anything sharing those lines lands in the +// selection when the user drags across them. Split across bare lines it still +// pastes: browsers strip the newline out of a URL, they do not strip an +// indent or a trailing label. // -// ponytail: column-only hit test, row-accurate if this ever moves to altscreen. -func (m *model) authFooter() (text string, startCol, endCol int) { +// Every line carries the same OSC 8 hyperlink, id-tagged so terminals rejoin +// the halves into one target. That is what keeps ctrl-click working on a URL +// the screen had to break in two. +func (m *model) authFooter() string { act := m.act if act == nil || act.authURL == "" { - return "", 0, 0 + return "" } - button := authCopyLabel + hint := authCopyHint if act.copied { - button = authCopiedLabel - } - link := m.wrapText("", "Authorize this bridge in your browser: "+act.authURL) - lastLine := link[strings.LastIndex(link, "\n")+1:] - sep := " " - startCol = ansi.StringWidth(lastLine) + 1 - if m.width > 0 && startCol+ansi.StringWidth(button) > m.width { - sep = "\n" - startCol = 0 + 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. - lines := strings.Split(link+sep+button, "\n") - for i, line := range lines { - lines[i] = authStyle.Render(line) - } - return strings.Join(lines, "\n"), startCol, startCol + ansi.StringWidth(button) -} - -// updateMouse turns a click on the copy button into a clipboard write. Mouse -// reporting is only on while that button is showing, so there is nothing else -// on screen a click could mean. -func (m *model) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { - if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft { - return m, nil + for _, line := range strings.Split(m.wrapText("", authProse), "\n") { + sb.WriteString(authStyle.Render(line)) + sb.WriteString("\n") } - if m.step != stepPreflight || m.act == nil || m.act.authURL == "" { - return m, nil + 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") } - _, startCol, endCol := m.authFooter() - if msg.X < startCol || msg.X >= endCol { - return m, nil + for i, line := range strings.Split(m.wrapText("", hint), "\n") { + if i > 0 { + sb.WriteString("\n") + } + sb.WriteString(dimStyle.Render(line)) } - return m, copyURLCmd(m.act.id, m.act.authURL) + return sb.String() } // activationElapsed counts the attempt up on screen. It starts at 2s so a @@ -1208,7 +1171,7 @@ func (m *model) viewPreflight() string { sb.WriteString("\n") sb.WriteString(dimStyle.Render("Esc to cancel\n")) } - if footer, _, _ := m.authFooter(); footer != "" { + if footer := m.authFooter(); footer != "" { sb.WriteString("\n") sb.WriteString(footer) sb.WriteString("\n") diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index bcbd203..595572a 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1336,9 +1336,12 @@ func TestBridgeAuthURLIsShownOnceAndOpened(t *testing.T) { if len(m.bridgeLogs) != 0 { t.Errorf("bridge logs = %q, want the link only in the footer", m.bridgeLogs) } - footer, _, _ := m.authFooter() - if want := "Authorize this bridge in your browser: " + testAuthURL; !strings.Contains(ansi.Strip(footer), want) { - t.Errorf("footer = %q, want it to contain %q", ansi.Strip(footer), want) + 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")}) @@ -1351,9 +1354,11 @@ func TestBridgeAuthURLIsShownOnceAndOpened(t *testing.T) { } } -// TestAuthFooterCopyButton covers the SSH case: no browser opens there, so the -// only way to the link is the terminal's own clipboard, over OSC 52. -func TestAuthFooterCopyButton(t *testing.T) { +// 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 { @@ -1365,61 +1370,88 @@ func TestAuthFooterCopyButton(t *testing.T) { m := &model{ g: &config.Global{}, width: 100, - act: &activation{id: 3, authURL: testAuthURL}, + step: stepPreflight, + act: &activation{ + id: 3, + authURL: testAuthURL, + endpoint: config.Endpoint{BridgeID: "b1"}, + cancel: func() {}, + }, } - _, startCol, endCol := m.authFooter() - if startCol <= 0 || endCol <= startCol { - t.Fatalf("copy button columns = [%d,%d), want a range past the link", startCol, endCol) + if !m.act.overridable() { + t.Fatal("the override editor is inert here, so this does not test the collision it is about") } - click := func(x int) tea.Cmd { - _, cmd := m.Update(tea.MouseMsg{X: x, Action: tea.MouseActionPress, Button: tea.MouseButtonLeft}) - return cmd - } - runCmd(t, click(startCol-1)) - runCmd(t, click(endCol)) + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}) + runCmd(t, cmd) if len(copies) != 0 { - t.Errorf("a click beside the button copied: %q", copies) + t.Errorf("a printable key copied: %q", copies) } - if !m.mouseOn { - t.Error("mouse reporting is off, so no click can reach the copy button") + if m.act.override.value != "c" { + t.Errorf("override = %q, want the printable key to reach the editor", m.act.override.value) } - runCmd(t, click(startCol)) + _, 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) } - m.Update(clipboardMsg{id: 3}) - if footer, _, _ := m.authFooter(); !strings.Contains(footer, authCopiedLabel) { - t.Errorf("footer = %q, want it to confirm the copy", ansi.Strip(footer)) + if m.act.override.value != "c" { + t.Errorf("override = %q, want ctrl+y to leave the editor alone", m.act.override.value) } - // Off the connect screen the button is gone, and the terminal gets its own - // click-drag selection back. - m.step = stepMenu - m.Update(activationTickMsg{id: 3}) - if m.mouseOn { - t.Error("mouse reporting stayed on after the copy button left the screen") + 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) } } -// TestAuthFooterWrapsButtonToItsOwnLine keeps the click target on screen when -// the link alone fills the terminal. -func TestAuthFooterWrapsButtonToItsOwnLine(t *testing.T) { +// 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: len("Authorize this bridge in your browser: " + testAuthURL), + width: 30, act: &activation{id: 3, authURL: testAuthURL}, } - footer, startCol, endCol := m.authFooter() - if startCol != 0 { - t.Errorf("copy button starts at column %d, want the start of its own line", startCol) + 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}, } - if endCol > m.width { - t.Errorf("copy button ends at column %d, past the %d column terminal", endCol, m.width) + 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 last := ansi.Strip(footer[strings.LastIndex(footer, "\n")+1:]); last != authCopyLabel { - t.Errorf("last footer line = %q, want just the copy button", last) + 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) } } From cc368a18cfdd1b9646d1be1ebf79b7c7a7897ca1 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Thu, 17 Sep 2026 17:33:14 +0000 Subject: [PATCH 28/69] aperture: write every run's diagnostics to a file A connect attempt that gets killed leaves nothing to read. The phases and notes it produced went to the connect screen and died with the process, and a 22 second kill this week left only a SIGQUIT dump, which says what the goroutines were parked on and nothing about what the run had already tried. slog's default handler made it worse: it writes to stderr, which under a TUI that owns the terminal is a line painted over the screen. So slog now points at /aperture/aperture.log for every run, and `sink` tees every connection event into it on the way to the screen, including on the reuse path that passes no screen sink at all. Up is timed, because the number is what separates a slow control plane from a login link the user never saw. On for every run rather than behind -debug: the run worth reading back is the one that went wrong, and nobody knows to pass the flag before it does. -debug only raises the level to catch the tsnet backend chatter. The cost is a few hundred bytes per connect, capped by starting the file over at 2MB. Failures now print the error and the log path to stderr, since routing diagnostics to a file means a launch that dies would otherwise exit 1 in silence. --- cmd/aperture/main.go | 53 +++++++++++++++++++++++++++++ internal/bridges/manager.go | 33 ++++++++++++++++-- internal/bridges/manager_test.go | 42 +++++++++++++++++++++++ internal/config/runlog.go | 43 ++++++++++++++++++++++++ internal/config/runlog_test.go | 57 ++++++++++++++++++++++++++++++++ 5 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 internal/config/runlog.go create mode 100644 internal/config/runlog_test.go diff --git a/cmd/aperture/main.go b/cmd/aperture/main.go index 710f049..5fe60ab 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" @@ -108,6 +109,49 @@ func gitCommitHeightInDir(dir string) string { return height } +// startRunLog points slog at the run log and returns its closer. Records are +// written straight through, so the os.Exit paths that skip the close lose +// nothing; the close is there to be tidy, not to flush. +// +// A run that cannot open the file still runs: diagnostics are not worth +// refusing to start over. It falls back to discarding them rather than 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 puts a failure back in front of the user. Every diagnostic now +// goes to the run log, which is the right place for a running TUI and the +// wrong one for a run that just died: without this, a launch that fails 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) + } +} + func main() { flag.Parse() @@ -120,9 +164,16 @@ func main() { os.Exit(0) } + // Before anything that logs. slog's default handler writes to stderr, + // which on a TUI that owns the terminal means a line painted over the + // screen, so 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 @@ -136,10 +187,12 @@ func main() { 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 { slog.Error("shutting down bridges", "err", err) + reportFailure(err) exitCode = 1 } if exitCode != 0 { diff --git a/internal/bridges/manager.go b/internal/bridges/manager.go index 17dcc86..bb549a8 100644 --- a/internal/bridges/manager.go +++ b/internal/bridges/manager.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "log/slog" "net" "net/http" "net/http/httputil" @@ -112,10 +113,30 @@ type events func(connection.Event) // sink returns a usable events, so callers that want none can pass nil. func sink(emit func(connection.Event)) events { - if emit == nil { - return func(connection.Event) {} + return func(e connection.Event) { + logEvent(e) + if emit != nil { + emit(e) + } + } +} + +// logEvent copies a connection event into the run log. The connect screen +// already shows these, but the screen dies with the process, and the run +// anyone wants to read back is the one that was killed halfway through: what +// it was waiting on and for how long is only answerable from a file. +// +// Notes are debug because tsnet's backend logger arrives as notes under +// -debug, and a phase is worth reading without wading through that. +func logEvent(e connection.Event) { + switch e.Kind { + case connection.PhaseEntered: + slog.Info("bridge phase", "phase", e.Phase) + case connection.LoginRequired: + slog.Info("bridge needs login", "url", e.Link.String()) + default: + slog.Debug("bridge note", "text", e.Text) } - return emit } func (e events) note(text string) { e(connection.Note(text)) } @@ -400,8 +421,13 @@ func (m *Manager) runningNode(ctx context.Context, bridge config.Bridge, ev even defer stopWatch() go rt.node.WatchLogin(watchCtx, ev) + // Timed because this is the wait every "it just sat there" report is + // about, and the number is the difference between a slow control plane and + // a login link the user never saw. + start := time.Now() status, err := rt.node.Up(ctx) if err != nil { + slog.Error("bridge node did not come up", "bridge", bridge.ID, "after", time.Since(start), "err", err) m.mu.Lock() if m.nodes[bridge.ID] == rt { delete(m.nodes, bridge.ID) @@ -409,6 +435,7 @@ func (m *Manager) runningNode(ctx context.Context, bridge config.Bridge, ev even m.mu.Unlock() return nil, nil, errors.Join(err, rt.node.Close()) } + slog.Info("bridge node up", "bridge", bridge.ID, "after", time.Since(start)) // Up returns the login status, so the tailnet this bridge reaches costs no // extra call. The connection picker names it on rows the user has not diff --git a/internal/bridges/manager_test.go b/internal/bridges/manager_test.go index 10c4392..ca499ad 100644 --- a/internal/bridges/manager_test.go +++ b/internal/bridges/manager_test.go @@ -1,9 +1,11 @@ package bridges import ( + "bytes" "context" "errors" "io" + "log/slog" "net" "net/http" "net/http/httptest" @@ -922,3 +924,43 @@ func TestAProxyReportsToTheAttemptUsingItNow(t *testing.T) { 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.login(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(), + link.String(), + "dialing", + connection.FindingEndpoint.String(), + } { + if !strings.Contains(logged, want) { + t.Errorf("run log = %q, want it to record %q", logged, want) + } + } +} diff --git a/internal/config/runlog.go b/internal/config/runlog.go new file mode 100644 index 0000000..7dd2f6f --- /dev/null +++ b/internal/config/runlog.go @@ -0,0 +1,43 @@ +package config + +import ( + "os" + "path/filepath" +) + +// runLogCap is the size the run log is allowed to reach before the next run +// starts it over. A connect attempt writes a few hundred bytes, so this holds +// a long history of them and still cannot grow without bound on a box nobody +// prunes. +// +// ponytail: truncate at a cap, rotate if anyone ever needs the older runs. +const runLogCap = 2 << 20 + +// RunLogPath returns the file every run writes its diagnostics to. It sits +// beside the settings and bridge state rather than in a temp dir, because 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)) + } +} From acf443cfe5192366efc8ad32ba43bf708ba0e05c Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Thu, 17 Sep 2026 17:43:52 +0000 Subject: [PATCH 29/69] bridges: log the three silences the connect screen shows as one A run killed at 43 seconds logged "Waiting for a login link" and then nothing for 31 seconds. Three different failures produce exactly that trace and the log could not tell them apart: the IPN watch died, control sent a link that ParseLoginLink rejected, or control never answered the register. All three report through ev.note, and notes are logged at debug so the tsnet backend chatter stays out of a normal run, so all three were invisible on the run that hit one. The phase merge is deliberate and stays: NoState and NeedsLogin are one wait on screen because the user can do nothing about either. In a log they are the whole question, so the raw state goes to the file alongside the phase. A rejected link is logged with the URL, since "threw one away" and "never got one" want opposite fixes. A dead watch is an error, not a note: it leaves the attempt parked on its last phase forever. Also stamps the activation itself, so the gap before the first bridge line reads as what it is (someone choosing an endpoint) rather than startup. --- internal/bridges/manager.go | 14 ++++++++++++++ internal/bridges/manager_test.go | 32 ++++++++++++++++++++++++++++++++ internal/tui/tui.go | 6 ++++++ 3 files changed, 52 insertions(+) diff --git a/internal/bridges/manager.go b/internal/bridges/manager.go index bb549a8..c092088 100644 --- a/internal/bridges/manager.go +++ b/internal/bridges/manager.go @@ -178,6 +178,11 @@ func (n *tsnetNode) WatchLogin(ctx context.Context, ev events) { // so only a failure the caller did not ask for is worth a line. report := func(err error) { if err != nil && ctx.Err() == nil { + // Logged as well as noted: a watch that dies leaves the attempt + // sitting on whatever phase it last reported, forever and in + // silence, which is indistinguishable on screen from a control + // plane that is simply slow. + slog.Error("bridge login watch ended", "err", err) ev.note("Could not watch the bridge's login state: " + err.Error()) } } @@ -240,6 +245,11 @@ func (r *loginReporter) notify(n *ipn.Notify) { return } if n.State != nil { + // The raw state, not just the phase it maps to: NoState and NeedsLogin + // are one phase on screen on purpose, and they are the whole question + // in a log. NoState means control has not answered the register yet, + // NeedsLogin means it has and the link is the next thing due. + slog.Info("bridge ipn state", "state", n.State.String()) switch *n.State { case ipn.NoState, ipn.NeedsLogin: // Both, and NoState is the one that matters. A bridge that has @@ -263,6 +273,10 @@ func (r *loginReporter) notify(n *ipn.Notify) { if n.BrowseToURL != nil { link, err := connection.ParseLoginLink(*n.BrowseToURL) if err != nil { + // The URL itself, because "the control plane sent one and we threw + // it away" and "the control plane never sent one" are the same + // silence on screen and want opposite fixes. + slog.Error("unusable login link from the control plane", "url", *n.BrowseToURL, "err", err) // Not fatal to the login: tsnet keeps printing its own copy, and // the user can still finish by hand. Worth saying, because the // browser is not going to open. diff --git a/internal/bridges/manager_test.go b/internal/bridges/manager_test.go index ca499ad..b7af66c 100644 --- a/internal/bridges/manager_test.go +++ b/internal/bridges/manager_test.go @@ -964,3 +964,35 @@ func TestSinkLogsEveryEvent(t *testing.T) { } } } + +// 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 := &loginReporter{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, "http://evil.example.com/a/x") { + t.Errorf("run log = %q, want the link that was thrown away", logged) + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index f4db484..7291c77 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -10,6 +10,7 @@ import ( "context" "fmt" "io" + "log/slog" "net/http" "strings" "time" @@ -402,6 +403,11 @@ func (m *model) beginActivation(ep config.Endpoint, ephemeral, switchTailnet boo emit := bridgeLogSink(ctx, ch, act.started) activate := func() tea.Msg { defer cancel() + // Stamps the moment the user committed to this endpoint. Without it + // the first bridge line is the earliest thing in the log, and the gap + // in front of it reads as startup cost when it is usually someone + // reading the menu. + slog.Info("activating endpoint", "url", ep.URL, "bridge", bridge.ID, "switchTailnet", switchTailnet) // Inside the attempt, so it 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. From 6dd0a19dea116f40ce420c76ec1d11e1c58f75f6 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Thu, 17 Sep 2026 17:51:55 +0000 Subject: [PATCH 30/69] bridges: say when the tailnet is refusing the login, not just that it is waiting A bridge sat for 40 seconds showing "Waiting for a login link" while the control plane answered every register with `http 502: backend not found or not available; reqType=noise-register/machine-pubkey`. tsnet retried with growing backoff and a fresh nodekey each time, so registration never completed, no BrowseToURL was ever sent, and the screen's phase was accurate and useless: it named the wait without naming the reason the wait would not end. The reason reaches us only on ipn.Notify.Health, under the login-state warnable. ErrMessage stays nil because a register failure is not a vizerror, so watching it would have been the smaller change and would have caught nothing. Health is broadcast to every watcher on each change, which is why a passive read in notify is enough and no extra subscription is needed; NotifyInitialHealthState is added to the mask so a bridge that is already broken when we attach reports on the first notify rather than on the next change. Only login-state is surfaced. The other warnables fire for conditions the user cannot act on from this screen and cannot distinguish from noise mid-connect. Reporting is on the healthy to unhealthy transition, not on the text: the retry appends a fresh REQ id roughly once a second, so keying on the text would put a new line on the connect screen every second for the length of the outage. Note now flattens whitespace. That error arrives with its request ID on a second line, and Event.String promises one line of the activation log: the screen wraps and indents each line itself, so an embedded newline puts unindented text mid-block and miscounts the rows the renderer repaints. --- internal/bridges/manager.go | 43 +++++++++++++++++++++- internal/bridges/manager_test.go | 60 +++++++++++++++++++++++++++++++ internal/connection/event.go | 13 +++++-- internal/connection/event_test.go | 22 +++++++++++- 4 files changed, 134 insertions(+), 4 deletions(-) diff --git a/internal/bridges/manager.go b/internal/bridges/manager.go index c092088..13d0d23 100644 --- a/internal/bridges/manager.go +++ b/internal/bridges/manager.go @@ -18,6 +18,7 @@ import ( "github.com/tailscale/aperture-cli/internal/config" "github.com/tailscale/aperture-cli/internal/connection" "tailscale.com/client/local" + "tailscale.com/health" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" "tailscale.com/tsnet" @@ -194,7 +195,11 @@ func (n *tsnetNode) WatchLogin(ctx context.Context, ev events) { report(err) return } - watcher, err := lc.WatchIPNBus(ctx, ipn.NotifyInitialState) + // InitialHealthState as well as InitialState: health changes reach every + // watcher regardless of mask, but a login that was already broken before + // this watch started only shows up in the initial one, which is the reused + // node case. + watcher, err := lc.WatchIPNBus(ctx, ipn.NotifyInitialState|ipn.NotifyInitialHealthState) if err != nil { report(err) return @@ -228,6 +233,11 @@ func reportLogin(watcher *local.IPNBusWatcher, ev events) error { type loginReporter struct { ev events phase connection.Phase + // loginBroken is whether the login-state warning is currently up. Held + // because the health state is re-sent on every retry and the text carries + // a fresh request ID each time, so reporting on text would put a new line + // on screen roughly once a second for as long as the failure lasts. + loginBroken bool } func (r *loginReporter) enter(p connection.Phase) { @@ -286,6 +296,37 @@ func (r *loginReporter) notify(n *ipn.Notify) { r.enter(connection.AwaitingAuthorization) r.ev.login(link) } + r.health(n.Health) +} + +// health reports a login that is failing rather than merely slow. +// +// Without this the two are one screen: a register that control answers with a +// 502 leaves the node in NeedsLogin, sending no BrowseToURL, so the attempt +// sits on "Waiting for a login link" for as long as the user tolerates it +// while tsnet retries behind a backoff. The failure is published on the health +// state and nowhere else the bus exposes: the error is not a vizerror, so it +// never reaches Notify.ErrMessage. +// +// login-state specifically, not every warning. The others describe a node that +// is up and imperfect (no DERP home, an update available), which is not this +// attempt's business and would bury the one line that is. k8s-proxy watches +// the same warnable for the same reason. +func (r *loginReporter) 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", warning.Text) + r.ev.note("The tailnet will not log this bridge in: " + warning.Text) } // Logout drops the node's tailnet credentials. The node must be running: the diff --git a/internal/bridges/manager_test.go b/internal/bridges/manager_test.go index b7af66c..81afbd1 100644 --- a/internal/bridges/manager_test.go +++ b/internal/bridges/manager_test.go @@ -19,6 +19,7 @@ import ( "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" @@ -996,3 +997,62 @@ func TestNotifyLogsWhatTheScreenCollapses(t *testing.T) { t.Errorf("run log = %q, want the link that was thrown away", logged) } } + +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. Control +// answers the register with an error, so the node stays in NeedsLogin and never +// sends a BrowseToURL, and the attempt sits on "Waiting for a login link" while +// tsnet retries behind a backoff. Nothing else on the bus carries the reason: +// the error is not a vizerror, so ErrMessage stays nil and the health state is +// the only place it appears. +func TestLoginReporterReportsALoginThatIsFailing(t *testing.T) { + const text = "You are logged out. The last login error was: register request: http 502" + + var lines []string + r := &loginReporter{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 := &loginReporter{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/connection/event.go b/internal/connection/event.go index 47bb8f9..b108505 100644 --- a/internal/connection/event.go +++ b/internal/connection/event.go @@ -117,8 +117,17 @@ type Event struct { Text string // Kind == Noted } -// Note reports diagnostics. -func Note(text string) Event { return Event{Kind: Noted, Text: text} } +// Note reports diagnostics, flattened to one line. +// +// Flattened here rather than at each consumer because String promises one line +// of the activation log and the screen relies on it: the connect screen wraps +// and indents each line itself, and an embedded newline puts unindented text +// in the middle of the block and miscounts the rows the renderer has to +// repaint. Control plane errors arrive with the request ID on a second line, +// so this is the normal shape of a failure, not a malformed one. +func Note(text string) Event { + return Event{Kind: Noted, Text: strings.Join(strings.Fields(text), " ")} +} // Notef reports diagnostics, formatted. func Notef(format string, args ...any) Event { return Note(fmt.Sprintf(format, args...)) } diff --git a/internal/connection/event_test.go b/internal/connection/event_test.go index cada9fc..b13c6ac 100644 --- a/internal/connection/event_test.go +++ b/internal/connection/event_test.go @@ -1,6 +1,9 @@ package connection -import "testing" +import ( + "strings" + "testing" +) func TestParseLoginLink(t *testing.T) { for _, tt := range []struct { @@ -78,3 +81,20 @@ func TestPhasesAreOrdered(t *testing.T) { } } } + +// TestNoteIsOneLine covers the shape control plane errors actually arrive in. +// A register failure carries its request ID on a second line, and the connect +// screen wraps and indents each log line itself: an embedded newline puts +// unindented text mid-block and miscounts the rows the renderer repaints. +func TestNoteIsOneLine(t *testing.T) { + raw := "register request: http 502: backend not found; tn=0\nREQ-2026091717445499013f4d855ec3c0" + got := Note(raw).String() + if strings.Contains(got, "\n") { + t.Errorf("note = %q, want the newline flattened out", got) + } + for _, want := range []string{"http 502", "REQ-2026091717445499013f4d855ec3c0"} { + if !strings.Contains(got, want) { + t.Errorf("note = %q, want it to keep %q", got, want) + } + } +} From a5ddef41b38c6595b11bc2d9fad8ccd89fd674ab Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Thu, 17 Sep 2026 22:03:36 +0000 Subject: [PATCH 31/69] tui: remove the bridge when its last endpoint goes Deleting a bridge-backed connection took two presses. The picker draws one row per endpoint and then one per bridge no endpoint claims, so removing the endpoint left the bridge to be re-listed as a bare "Connect via" row at the bottom of the same list. Nothing said an object had been deleted and a different one had appeared, so it read as the row moving instead of going. The cascade is safe to make silent because RemoveBridge is config only: it drops the settings entry without logging the node out of the tailnet or touching its state directory. The alternative, leaving both objects and labelling the second row so the user could tell it apart, keeps a two-step delete for something presented as one thing. Only endpoints are cascaded from, and only when no other endpoint reaches through the bridge, so a shared bridge survives. Left config.RemoveEndpoint alone: discardActivation calls it to clean up a cancelled attempt, and a bridge the user made earlier should not disappear because they backed out of one connection through it. This overturns a deliberate choice. TestConnectionPicker_RemovesInactiveConnection asserted the bridge row coming back, with a comment saying so. --- internal/tui/menus.go | 25 +++++++++++++ internal/tui/tui_test.go | 76 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/internal/tui/menus.go b/internal/tui/menus.go index 367c687..6a8ec20 100644 --- a/internal/tui/menus.go +++ b/internal/tui/menus.go @@ -505,6 +505,9 @@ func (m *model) removeConnection(ep config.Endpoint) menu.Result { if err := m.g.RemoveEndpoint(i); err != nil { return errResult(err.Error()) } + if err := m.dropOrphanBridge(existing.BridgeID); err != nil { + return errResult(err.Error()) + } break } if m.failedEndpoint != nil && sameEndpoint(*m.failedEndpoint, ep) { @@ -516,6 +519,25 @@ func (m *model) removeConnection(ep config.Endpoint) menu.Result { return menu.Result{Cmd: tea.ClearScreen} } +// dropOrphanBridge removes a bridge once its last endpoint is gone. +// +// The picker shows a bridge-backed endpoint as one row, but settings hold two +// objects, and removing only the endpoint left the bridge to be re-listed by +// connectionRows as a bare "Connect via" row at the bottom. To the user that +// read as the row moving instead of going, and clearing it took a second +// press. A bridge two endpoints reach through is not an orphan and stays. +func (m *model) dropOrphanBridge(id string) error { + if id == "" { + return nil + } + for _, ep := range m.g.Settings.Endpoints { + if ep.BridgeID == id { + return nil + } + } + return m.g.RemoveBridge(id) +} + // 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. @@ -633,6 +655,9 @@ func (m *model) setupGuideMenu() *menu.Menu { if err := m.g.RemoveEndpoint(i); err != nil { return errResult(err.Error()) } + if err := m.dropOrphanBridge(ep.BridgeID); err != nil { + return errResult(err.Error()) + } break } m.clearEndpointFailure() diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 595572a..19df845 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -984,8 +984,18 @@ func TestConnectionPicker_RemovesInactiveConnection(t *testing.T) { if got := m.top().Title; got != endpointsTitle { t.Fatalf("menu title = %q, want to be back on %q", got, endpointsTitle) } - // Work has no endpoint now, so it comes back as a bridge row. - findItem(t, m.top().Items, "Connect via Work") + // 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 @@ -1657,3 +1667,65 @@ func TestBridgeLogSinkNeverDropsTheLoginLink(t *testing.T) { } } } + +// TestRemoveConnectionRowTakesTheBridgeWithIt covers what one press of "d" is +// supposed to mean. A bridge-backed endpoint is one row on the picker, but it +// is two objects in settings, and removing only the endpoint left the bridge +// behind to be re-listed as a bare "Connect via" row at the bottom. The row +// read as having moved rather than gone, and clearing it took a second press. +func TestRemoveConnectionRowTakesTheBridgeWithIt(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + m := &model{g: &config.Global{Settings: config.Settings{ + Endpoints: []config.Endpoint{ + {URL: "http://active"}, + {URL: "http://ai", BridgeID: "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.removeConnectionRow(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{ + {URL: "http://active"}, + {URL: "http://ai", BridgeID: "b1"}, + {URL: "http://other", BridgeID: "b1"}, + }, + Bridges: []config.Bridge{{ID: "b1", Name: "work"}}, + }}} + + m.removeConnectionRow(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) + } + } +} From 35746f7987060a297619771a4f2006f2f08abf59 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Fri, 18 Sep 2026 15:52:21 +0000 Subject: [PATCH 32/69] aperture: name the endpoint and bridge on the command line A bridge connection can only be started by hand: open the picker, pick or add a bridge, wait. Nothing about that is scriptable, so a machine image, a container or a dotfile cannot ship a working launcher, and every new user retypes the same two answers on first run. -endpoint and -bridge resolve to the endpoint Init opens on, with APERTURE_ENDPOINT and APERTURE_BRIDGE behind them for the places nobody types the invocation. -bridge takes a name and creates the bridge when there is none, because a flag that only worked after someone had made the bridge by hand would not help the first run, which is the run that needs it. Resolution happens in main before the TUI takes the terminal: a URL we cannot use is a line on stderr and exit 1, which a script can see, rather than a full-screen error it cannot. Init routes through connectVia rather than a new path, so a named endpoint is saved for the failure screen to name and taken back out if the attempt is abandoned, and the saved active endpoint keeps working exactly as before because it is already configured. The obvious alternative, SetActiveEndpoint before starting the TUI, is two lines shorter and makes an unreachable -endpoint displace the one that works. --- README.md | 24 ++++++-- cmd/aperture/main.go | 29 ++++++++- internal/config/global.go | 45 ++++++++++++++ internal/config/startup_test.go | 102 ++++++++++++++++++++++++++++++++ internal/tui/tui.go | 18 +++++- internal/tui/tui_test.go | 50 ++++++++++++++++ 6 files changed, 258 insertions(+), 10 deletions(-) create mode 100644 internal/config/startup_test.go diff --git a/README.md b/README.md index 5643bf5..f6ecbf1 100644 --- a/README.md +++ b/README.md @@ -80,10 +80,26 @@ If verification fails, the endpoint remains configured for retry or editing, and ### 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 cf479b1..e2cbb49 100644 --- a/cmd/aperture/main.go +++ b/cmd/aperture/main.go @@ -30,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" @@ -155,6 +157,17 @@ func reportFailure(err error) { } } +// orEnv falls back to the environment for a flag nobody passed, so the same +// selection works from a dotfile, a container or a systemd unit as from a +// typed invocation. The flag wins: a one-off run has to be able to override +// whatever the shell was started with. +func orEnv(value, key string) string { + if value != "" { + return value + } + return os.Getenv(key) +} + func main() { flag.Parse() @@ -184,8 +197,18 @@ func main() { // Register Claude Desktop on supported platforms (darwin, windows). profiles.RegisterIfSupported() + // Resolved before the TUI takes the terminal, so a URL it cannot use is a + // line on stderr and a non-zero exit rather than a full-screen error the + // script that passed it will never see. + start, err := g.StartupEndpoint(orEnv(*flagEndpoint, "APERTURE_ENDPOINT"), orEnv(*flagBridge, "APERTURE_BRIDGE")) + if err != nil { + slog.Error("resolving the endpoint to open on", "err", err) + reportFailure(err) + os.Exit(1) + } + bridgeManager := bridges.NewManager(g.Debug) - p := tea.NewProgram(tui.NewModel(g, buildVersion, bridgeManager)) + p := tea.NewProgram(tui.NewModel(g, buildVersion, bridgeManager, start)) var exitCode int if _, err := p.Run(); err != nil { diff --git a/internal/config/global.go b/internal/config/global.go index 1df6e00..f1fd87e 100644 --- a/internal/config/global.go +++ b/internal/config/global.go @@ -69,6 +69,51 @@ func (g *Global) ActiveEndpoint() Endpoint { return g.Settings.Endpoints[0] } +// StartupEndpoint is where the launcher opens: what the invocation named, or +// the saved active endpoint when it named nothing. +// +// Neither argument is required and neither implies the other. A URL on its own +// is a direct connection. A bridge on its own starts at DefaultLocation, the +// same guess the connection picker makes, because the point of naming a bridge +// is usually that you know how to get on the tailnet and not what is listening +// on it. Both together pin the URL behind the bridge. +// +// It is the caller's job to run this before the TUI takes the terminal: a +// rejected URL is worth a line on stderr, not a full-screen error. +func (g *Global) StartupEndpoint(endpointArg, bridgeArg string) (Endpoint, error) { + endpointArg = strings.TrimSpace(endpointArg) + bridgeArg = strings.TrimSpace(bridgeArg) + if endpointArg == "" && bridgeArg == "" { + return g.ActiveEndpoint(), nil + } + var bridgeID string + if bridgeArg != "" { + bridge, err := g.bridgeNamed(bridgeArg) + if err != nil { + return Endpoint{}, err + } + bridgeID = bridge.ID + } + if endpointArg == "" { + return Endpoint{URL: DefaultLocation, BridgeID: bridgeID}, nil + } + return ParseEndpoint(endpointArg, bridgeID) +} + +// bridgeNamed finds the bridge called name and creates it if there is none, +// which is what makes a first run scriptable: a flag that only worked once +// someone had already made the bridge by hand would not be worth having. +// Matching ignores case because the name is the user's own label and nothing +// keys off it. +func (g *Global) bridgeNamed(name string) (Bridge, error) { + for _, b := range g.Settings.Bridges { + if strings.EqualFold(b.Name, name) { + return b, nil + } + } + return g.AddBridge(name) +} + // 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. diff --git a/internal/config/startup_test.go b/internal/config/startup_test.go new file mode 100644 index 0000000..0e1f45b --- /dev/null +++ b/internal/config/startup_test.go @@ -0,0 +1,102 @@ +package config_test + +import ( + "path/filepath" + "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 StartupEndpoint'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 TestStartupEndpointFallsBackToTheSavedOne(t *testing.T) { + g := loadInto(t, config.Settings{Endpoints: []config.Endpoint{{URL: "http://saved"}}}) + + ep, err := g.StartupEndpoint("", "") + if err != nil { + t.Fatalf("StartupEndpoint: %v", err) + } + if ep != (config.Endpoint{URL: "http://saved"}) { + t.Errorf("endpoint = %+v, want the saved one", ep) + } +} + +func TestStartupEndpointTakesABareHost(t *testing.T) { + g := loadInto(t, config.Settings{Endpoints: []config.Endpoint{{URL: "http://saved"}}}) + + ep, err := g.StartupEndpoint("aperture.example.com", "") + if err != nil { + t.Fatalf("StartupEndpoint: %v", err) + } + if ep != (config.Endpoint{URL: "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 TestStartupEndpointGuessesTheLocationForANamedBridge(t *testing.T) { + g := loadInto(t, config.Settings{Bridges: []config.Bridge{{ID: "bridge-abc123", Name: "Work"}}}) + + ep, err := g.StartupEndpoint("", "work") + if err != nil { + t.Fatalf("StartupEndpoint: %v", err) + } + if ep != (config.Endpoint{URL: config.DefaultLocation, BridgeID: "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 TestStartupEndpointCreatesAnUnknownBridge(t *testing.T) { + g := loadInto(t, config.Settings{}) + + ep, err := g.StartupEndpoint("http://aperture.example.com", "Work") + if err != nil { + t.Fatalf("StartupEndpoint: %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.BridgeID != g.Settings.Bridges[0].ID || ep.URL != "http://aperture.example.com" { + 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 TestStartupEndpointRejectsAUnusableURL(t *testing.T) { + g := loadInto(t, config.Settings{}) + + if _, err := g.StartupEndpoint("ftp://aperture.example.com", ""); err == nil { + t.Error("StartupEndpoint accepted an ftp URL, want it refused before the TUI takes the terminal") + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 7291c77..cab97e8 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -59,12 +59,14 @@ const ( // 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 { +// of the client picker. 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, bridgeManager *bridges.Manager, start config.Endpoint) tea.Model { return &model{ g: g, buildVersion: buildVersion, bridgeManager: bridgeManager, + start: start, step: stepPreflight, } } @@ -73,6 +75,11 @@ type model struct { g *config.Global buildVersion string bridgeManager *bridges.Manager + // start is where Init connects. It is not necessarily in settings yet: + // an endpoint named on the command line is saved on the way in and taken + // back out again if the attempt is abandoned, the same as one typed into + // the connection picker. + start config.Endpoint step step @@ -208,8 +215,13 @@ func (f *textField) backspace() { func (f *textField) reset() { *f = textField{} } +// Init opens on m.start. connectVia rather than activateEndpointCmd because +// the endpoint may have come off the command line and so may not be in +// settings: connectVia writes it there for the failure screen to name and +// marks it ephemeral, and for the saved active endpoint, which is already +// configured, the two are the same call. func (m *model) Init() tea.Cmd { - return m.activateEndpointCmd(m.g.ActiveEndpoint()) + return m.connectVia(m.start, false) } // preflightResult is emitted when the /v1/models check completes. diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 19df845..30fd17d 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -614,6 +614,56 @@ func TestEndpointBridgeMenu_ConnectsExistingBridgeWithoutPrompting(t *testing.T) } } +// 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.Endpoint{URL: "http://saved"} + named := config.Endpoint{URL: "http://named", BridgeID: 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 || !sameEndpoint(m.act.endpoint, named) { + t.Fatalf("activation = %+v, want %+v", m.act, named) + } + if !m.act.ephemeral { + t.Error("an endpoint named on the command line should come back out if the attempt is abandoned") + } + if got := m.g.ActiveEndpoint(); !sameEndpoint(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.Endpoint{URL: "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 || !sameEndpoint(m.act.endpoint, saved) { + t.Fatalf("activation = %+v, want %+v", m.act, saved) + } + if m.act.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) From ed5112ac9ee1a5e100302879c68ef689a4e10de9 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Fri, 18 Sep 2026 15:43:45 +0000 Subject: [PATCH 33/69] docs: record that deleting a bridge leaves the machine behind Two dead aperture-cli-bridge-* machines are in the maintainer's tailnet because RemoveBridge drops the settings entry and nothing else: the node is not ephemeral, so the control plane keeps the device, and nothing in the repo removes the tsnet state dir either. SwitchTailnet already works out that closing a node without logging out orphans the device; that reasoning was never applied to deleting the bridge outright. Writing it down now rather than fixing it because b2a6bc3 changed the odds, not the bug: before it, orphaning took a deliberate second press on a bridge-named row, and now deleting an endpoint cascades into it. The fix is not small, since logout needs the node running and a control-plane round trip that was hanging past 90s yesterday, so delete stops being instant and infallible and needs a bounded wait and an escape. The obvious cheap alternative, making bridges ephemeral, would close the leak and cost a new device and a new interactive login on every run, which is the thing persistent bridges exist to avoid. Revisit when tsnet can deregister a node without bringing it up first. --- ...002-bridge-removal-destroys-the-machine.md | 123 ++++++++++++++++ docs/specs/bridge-resource-lifecycle.md | 139 ++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 docs/adr/0002-bridge-removal-destroys-the-machine.md create mode 100644 docs/specs/bridge-resource-lifecycle.md 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..a7efd1b --- /dev/null +++ b/docs/adr/0002-bridge-removal-destroys-the-machine.md @@ -0,0 +1,123 @@ +# 0002. Removing a bridge destroys the machine it registered + +Status: proposed +Date: 2026-09-18 +Change size: medium. One new method on `bridges.Manager`, a confirmation menu, +and a rework of the six removal sites in `internal/tui` so they route through +it. + +## Context + +There are two dead `aperture-cli-bridge-*` machines in the maintainer's tailnet +right now. Neither was deliberately abandoned. Both are what is left of a +bridge that was created in the CLI, connected once, and then deleted from the +CLI, which removed the settings entry and nothing else. + +`config.RemoveBridge` (`global.go:219-239`) rewrites `Settings.Bridges` and +calls `SaveSettings`. The node it registered is not ephemeral +(`manager.go:357-367` sets `Dir` and `Hostname` and no `Ephemeral`), so the +control plane keeps the device; tsnet mkdirs +`$UserConfigDir/aperture/bridges/` and nothing in the repo removes it. +`Manager.Close` never logs out. `SwitchTailnet` (`manager.go:528-566`) is the +only code path that has ever called `Logout`, and it is there because `e22c442` +worked out that closing a node without logging it out leaves the device +orphaned rather than removed. That reasoning was applied to switching tailnets +and not to deleting the bridge outright, which is the larger case. + +The forcing reason is not that this is untidy. It is that the leak became +ordinary. Before `b2a6bc3` a bridge was orphaned only by a deliberate second +`d` on a bridge-named row, which few users would reach. After it, deleting an +endpoint takes its bridge with it, so the common path now silently registers a +machine on the user's tailnet and silently abandons it. `b2a6bc3` was the right +fix for the row that moved to the bottom instead of disappearing; it also +turned a rare leak into the default one. + +The full inventory of resources and removal sites is in +[the bridge resource lifecycle spec](../specs/bridge-resource-lifecycle.md). + +A note on evidence. The specs under `docs/specs/` landed on 2026-09-17 +(`dceb2aa`) and describe the code as it already was, so this ADR does not treat +them as prior constraint. What it rests on is `manager.go` as written in +`e22c442` on 2026-09-16, and two machines that exist. + +## Decision + +Removing the last reference to a bridge destroys the machine it registered. + +1. `bridges.Manager` grows `Forget(ctx, bridge, emit)`, shaped like + `SwitchTailnet`: bring the node up if it is not already, log out, close it, + evict it from `nodes` and `tailnets`, then `RemoveAll` the state directory. +2. The settings entry goes last, after `Forget` returns. Settings is the only + record that the device exists, so dropping it first and then failing the + logout leaves a registered machine the CLI can no longer name. +3. A bridge with no recorded tailnet (`Bridge.Tailnet == ""`) skips the logout. + It has never registered, so there is no device, and bringing it up to delete + it would make the user authorize a machine in order to destroy it. +4. Removal confirms first, in the `switchTailnetMenu` shape + (`menus.go:544-576`), and the confirmation names the device and the tailnet + it will be removed from. +5. The wait is bounded. On timeout or error the local records go anyway and the + user is told, by name, which device is still in their tailnet. +6. All six removal sites route through one path. The parallel `d` on the + Bridges settings menu (`menus.go:213-227`) stops being a second way to + delete a bridge without confirmation. + +## Consequences + +Good: + +- Deleting a bridge leaves nothing behind, which is what the user already + believes is happening. +- The admin console stops accumulating a machine per abandoned first login. +- One removal path instead of six near-copies, and one confirmation covering + all of them. + +Bad, and accepted: + +- Delete becomes slow and failable where it is currently instant and + infallible. Logout is a control-plane round trip on the same infrastructure + where `/machine/register` was observed hanging past 90 seconds on + 2026-09-17. Point 5 is the concession, and it means "removed" sometimes means + "removed locally, go finish this in the admin console". +- Removal needs a `context.Context` and an event sink at call sites that today + are synchronous `menu.Result` functions. That is the bulk of the diff. +- Destruction is now irreversible from the CLI. A user who deletes a bridge and + wants it back logs in again as a new machine, with a new device name and + whatever ACL grants applied to the old name no longer matching. +- Leftover devices are unpublished behaviour that someone may have come to + depend on: an ACL rule naming `aperture-cli-bridge-abc123`, a route, a + tagged group. That is an argument for confirming loudly, not for continuing + to leak. It is why point 4 names the device rather than asking "remove this + bridge?". + +## Alternatives considered + +**Leave it.** The status quo. Cheapest, and defensible while the leak was rare. +It is not rare now, and the failure is invisible at the point it happens and +visible only later, in a different product, to a user who has no way to tell +which of their `aperture-cli-*` machines are live. + +**Go back to the two-step delete.** Revert `b2a6bc3`, so deleting an endpoint +never cascades and a bridge can only be removed deliberately. That restores +"when I delete an endpoint, it just moves to the bottom, I have to do it +twice" for something the picker presents as one row, and it does not stop the +leak, it only makes the user press `d` twice to cause it. + +**Make bridges ephemeral.** Set `Ephemeral: true` on the `tsnet.Server` and let +the control plane reap the device when the node disconnects. It solves the leak +completely and costs the thing bridges exist for: every reconnect becomes a new +device and a new interactive login. Trading a cleanup bug for a login on every +run is a worse trade, particularly given how slow that login currently is. + +**Delete the settings entry and the state directory, skip the logout.** Avoids +the control-plane round trip, so delete stays fast. It leaves exactly the +orphan `SwitchTailnet` was written to avoid, and it destroys the credentials +that would let us clean up later, so it converts a recoverable leak into a +permanent one. + +## Revisit when + +tsnet offers a way to deregister a node without bringing it up first, which +would remove the only reason `Forget` is slow and would let removal go back to +being synchronous. Or if bridges stop being long-lived, in which case the +ephemeral option becomes the right answer instead. diff --git a/docs/specs/bridge-resource-lifecycle.md b/docs/specs/bridge-resource-lifecycle.md new file mode 100644 index 0000000..2289509 --- /dev/null +++ b/docs/specs/bridge-resource-lifecycle.md @@ -0,0 +1,139 @@ +# Bridge resource lifecycle + +What a bridge creates, what removes it, and what is left behind today. + +Creating a bridge produces three things. Removing one destroys one of them. +The other two are a tailnet device the user can see in their admin console and +a directory on their disk, and nothing in this repo has ever deleted either. + +## The three resources + +| Resource | Created by | First exists | Removed by | +|---|---|---|---| +| Tailnet device `aperture-cli-` | `tsnet.Server` registering with the control plane | first successful `Activate` | nothing | +| `$UserConfigDir/aperture/bridges/` | tsnet, lazily, from `Server.Dir` | first `Activate`, successful or not | nothing | +| `config.Bridge` in settings | `Global.AddBridge` (`global.go:178-196`) | the moment the user types a name | `Global.RemoveBridge` (`global.go:219-239`) | + +The device is persistent because the node is not ephemeral. `newNode` +(`manager.go:357-367`) builds: + +```go +s := &tsnet.Server{ + Dir: stateDir, + Hostname: "aperture-cli-" + bridge.ID, + UserLogf: userLogf, +} +``` + +There is no `Ephemeral` field set anywhere in `internal/bridges`, so the +control plane keeps the machine after the process exits, which is the point: +the same bridge reconnects next run without a login. The state directory is +the other half of that. `config.BridgeStateDir` (`settings.go:90-101`) returns +`$UserConfigDir/aperture/bridges/`, and +its only non-test caller is `runningNode` (`manager.go:440`), which hands it to +`Server.Dir`. tsnet mkdirs it on start, so a bridge that has never been +activated has no directory. + +`RemoveBridge` rewrites `Settings.Bridges` and calls `SaveSettings`. That is +all it does. `os.RemoveAll` appears four times in the repo, all of it client +installer cleanup, none of it bridge related. `Manager.Close` +(`manager.go:567-587`) closes proxies and nodes and never logs out, which is +correct for shutdown and is why nothing else has to be. + +The one place that does log out is `SwitchTailnet` (`manager.go:528-566`), and +its comment already names the failure mode this document is about: + +> A node that was never started this session is therefore brought up on the +> old tailnet first, which is also what leaves the device removed from it +> rather than orphaned. + +That reasoning applies to removal at least as strongly as it applies to +switching. Removal skipped it. + +## Where a bridge can be removed + +Six places, all in `internal/tui`, none of them confirming, none of them +touching anything but settings. + +| Site | What it removes | Note | +|---|---|---| +| `bridgesMenu` hidden `d` (`menus.go:213-227`) | the bridge | a second bridge-deleting UI, parallel to the picker's | +| `removeConnectionRow` default arm (`menus.go:482-495`) | the bridge, for a row with no endpoint | the "Remove bridge" the user sees | +| `removeConnection` (`menus.go:497-520`) | the endpoint, then cascades | | +| `dropOrphanBridge` (`menus.go:529-539`) | the bridge, once its last endpoint is gone | added in `b2a6bc3` | +| setup guide "Remove endpoint" (`menus.go:647-667`) | the endpoint, then cascades | duplicates `removeConnection`'s loop | +| `discardActivation` (`tui.go:471-494`) | the ephemeral endpoint only | leaves the bridge | + +The last one is worth reading as a cause rather than a symptom. Cancelling a +connection to a freshly created bridge takes the endpoint back out and leaves +the bridge, which is exactly what makes a bare "Connect via" row appear in the +picker with no endpoint attached. So the row that `removeConnectionRow`'s +default arm deletes is usually the residue of an abandoned first login, and +deleting it is the one case where there is no device to clean up: the bridge +may never have registered at all. + +`b2a6bc3` did not create the leak. It made it reachable from a single delete of +an endpoint, where before the user had to press `d` a second time on a +bridge-named row to get there. + +## What cleanup needs + +One operation on `bridges.Manager`, shaped like `SwitchTailnet` because it is +the same work minus the restart: + +```go +func (m *Manager) Forget(ctx context.Context, bridge config.Bridge, emit func(connection.Event)) error +``` + +Order matters, and it is not the obvious one. Logout, close the node, evict it +from `nodes` and `tailnets`, `RemoveAll` the state directory, and only then let +the caller drop the settings entry. Settings last 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, which is strictly worse +than the leak we have now. + +## Constraints that decide the design + +**Logout needs a running node.** `(*tsnetNode).Logout` (`manager.go:335-341`) +goes through `server.LocalClient()`, so the credentials it clears live behind +the in-process LocalAPI. Closing a node without logging out reuses them next +start. There is no way to log out a bridge that is not up. + +**Starting a node that never registered performs a full interactive login.** +`runningNode`'s own comment (`manager.go:471-474`) says `Up` blocks until the +node is Running, which for a bridge that has never logged in means blocking +until the user visits a link nothing has shown them yet. Routing removal +through `runningNode` unconditionally would create a device in order to delete +it, and would do it by asking the user to authorize a machine they just asked +to destroy. `Bridge.Tailnet != ""` is the persisted signal for "has ever +joined" (`SetBridgeTailnet`, `global.go:201-216`); a bridge without it skips +the logout entirely, and has no state directory to remove either. + +**Removal is slow and failable.** `/machine/register` was observed hanging past +90 seconds on 2026-09-17, and logout is a control-plane round trip on the same +infrastructure. A delete that blocks the UI indefinitely is not shippable. The +operation needs a bounded wait and an escape that removes the local records +anyway and tells the user, in words, that a device named +`aperture-cli-` is still in their tailnet and where to delete it. + +**No removal site has a context or an event sink.** All six return +`menu.Result` synchronously. The house pattern for slow work is the one +`connectVia` uses (`menus.go:793-801`): do the fast fallible part inline, +return a `tea.Cmd` for the rest. The house pattern for showing progress +without a full connection attempt is the post-launch recheck +(`tui.go:773-782`), which reuses `stepPreflight` with a bare `activation` for +the label and clock plus its own result message. + +**No removal path confirms today.** Every deletion above is one keypress. The +house confirm shape is `switchTailnetMenu` (`menus.go:544-576`): a menu whose +title is a question naming the subject, a preamble stating the current state +and the consequence, two items `{verb, y}` and `{Cancel, n}`, pushed with +`Next` so Esc also backs out. + +## Out of scope + +The cascade rule from `b2a6bc3` does not change: a bridge two endpoints reach +through is not an orphan and survives the removal of either one. + +Deduplicating the six removal sites is not required to fix the leak, though +whatever lands should not make it seven. From 0da0f06cc51b4231aeba03647aa02f73d2383dd6 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Fri, 18 Sep 2026 16:17:33 +0000 Subject: [PATCH 34/69] docs: put the decision on one page and the detail in the spec A 200 line ADR is a spec that did not get written. 0001 carried its own context map, ubiquitous language, ACL research and a deferred-work table, so the decision it exists to record was six screens in and nobody reading it for the decision got there. Nygard's four sections with the reason named as "Why?", capped at a page, recorded in AGENTS.md so the next one starts there. Detail moves to the spec that owns it rather than being cut: the Up table and the printAuthURLLoop finding to the context map's ACL section, the plumbing knots to the contracts spec. 0002 loses the same way and links out. 0002's decision also changes shape: it proposed Manager.Forget, which contradicts 0001 decision 6 and grows the pile Manager already is. Destruction belongs on Machine, the aggregate that owns the node, as Destroy alongside the LeaveTailnet the domain model already has. --- AGENTS.md | 22 ++ docs/adr/0001-connection-bounded-context.md | 241 +++++------------- ...002-bridge-removal-destroys-the-machine.md | 165 ++++-------- docs/specs/bridge-resource-lifecycle.md | 193 +++++--------- docs/specs/connection-context-map.md | 31 +++ docs/specs/connection-contracts.md | 21 ++ 6 files changed, 258 insertions(+), 415 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 704e2ab..f2508db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,28 @@ Artifacts, written before the code: 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 diff --git a/docs/adr/0001-connection-bounded-context.md b/docs/adr/0001-connection-bounded-context.md index 3c78ae0..65ddfba 100644 --- a/docs/adr/0001-connection-bounded-context.md +++ b/docs/adr/0001-connection-bounded-context.md @@ -5,203 +5,82 @@ Date: 2026-09-17 Change size: large. Touches `internal/bridges`, the activation half of `internal/tui`, and the `ApertureHost` boundary into `internal/clients`. -## Context +## 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 the -node inside the control plane's first `POST /machine/register` -(`controlclient/direct.go:853`) with an empty 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 now 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 instead of tsnet's 5s poll. -- `82fab61` resolves the target against the node's own peer map before dialing. - -Each is correct. None could have shortened this particular wait, because none -of them is in the phase the wait was in. The forcing reason for this ADR is not -that the code is untidy: it is that four separate mechanisms carry progress -(a `func(string)` sink, a `chan bridgeLine`, `tsnet`'s own prose, an -`*ipnstate.Status` return) and none of them names what the attempt is waiting -on, so a fourth fix would be aimed the same way. - -Two concrete defects fall out of the same shape: - -- The TUI recovers the login link by string-matching two markers, one of which - is a phrase inside tsnet's log text (`"or go to: "`, `browser.go:16`). A - reworded upstream log line silently stops the browser from opening. -- The link travels on a 32-slot channel whose sink drops on overflow - (`tui.go:557`). Under `--debug` the tsnet backend logger shares that channel, - so a burst of chatter can discard the one line the user cannot proceed - without. - -Three IPN bus watchers run against one backend: tsnet's inside `Up`, ours -inside `WatchLogin`, and tsnet's `printAuthURLLoop`. `LocalBackend.sendToLocked` -iterates every watcher while holding `b.mu`, and the code comments there assume -one. - -## Bounded contexts - -One: **Connection**. It spans the whole flow, from the user picking an endpoint -to a client knowing where to send requests, including the `/v1/models` fetch -that follows bring-up. Splitting bring-up from the fetch is the reason nobody -owns "what is this attempt waiting on"; the user experiences one wait. - -Neighbours and patterns are in -[the context map](../specs/connection-context-map.md). - -## Ubiquitous language - -Connection Attempt, Endpoint, Gateway, Route, Bridge, Machine, Login Link, -Phase, Progress. Defined in the context map. Three of these resolve words that -currently mean two things: `Gateway` versus `Endpoint` splits `ApertureHost`, -`Machine` versus `Bridge` splits the running node from the saved record, and -`Phase` takes over from `Status`. - -## Domain objects and invariants - -Full model in [the domain model](../specs/connection-domain-model.md). The -invariants this ADR is accountable for: - -- An Attempt's `Trail` accounts for its whole wall clock with no gaps, so - "where did 29 seconds go" has an answer. -- `AwaitingLoginLink` and `AwaitingAuthorization` are different phases despite - being the same `ipn.State`. -- A `LoginLink` is parsed once, at the boundary, and is `https` with no - whitespace. Nothing downstream re-derives it from text. -- Only `Noted` events may be dropped under backpressure. -- No vendor type crosses the context boundary. - -## 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` through `Up` and `Status`; the replacement port speaks -Connection's own types and publishes `Event`. - -Taking ownership of 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, we already have `Status` on the port | -| `resetServeStateOnce`: clear serve config and advertised services | skipped | - -Skipping `resetServeStateOnce` is deliberate. It exists to clear serve config -and service advertisements persisted by an earlier run of a differently -configured program, and we call neither `SetServeConfig` nor set -`AdvertiseServices`, so there is nothing of ours in the bridge state dir for it -to clear. Both halves are reachable from exported API if that changes: -`lc.SetServeConfig` and `local.Client.EditPrefs` with `AdvertiseServicesSet` -(the unexported `s.lb.EditPrefs` that `Up` uses is equivalent). Revisit if the -CLI ever serves anything over a bridge. - -`printAuthURLLoop` cannot be switched off: `go s.printAuthURLLoop()` is -unconditional in `start()` and no field or envknob guards it. Setting -`Server.UserLogf` to a no-op is the only way to stop its prose reaching us, and -that is what we do, because with a typed `LoginRequired` event its output is -not a source any more. So the watcher count goes three to two while a login is -outstanding, and to one after: `printAuthURLLoop` exits when the state leaves -`NeedsLogin`. +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. + fetch. The user experiences one wait. 2. The boundary out of `internal/bridges` becomes a typed `Event` stream. - Delete `bridges.AuthLogPrefix`, `tsnetAuthURLMarker` and the marker scraping - in `authURLFromLog`; keep its URL rules as `ParseLoginLink`. + 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`, and silence `UserLogf`. + `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, which is - most of what `Manager` holds today. - -Artifacts land in `docs/specs/` and `docs/adr/`, and the conventions they -follow are recorded in the repo's `AGENTS.md` rather than in any one person's -tooling. + grow fields; `Machine` takes the node, its routes and its tailnet. ## Consequences -Good: - -- A wait has a name and a duration, so the next report of a slow connection is - diagnosable from the screen rather than from a `SIGQUIT` dump. -- The browser opens because a `LoginRequired` event arrived, not because a log - line matched a phrase in a vendored package. -- The login link cannot be dropped by debug chatter. -- One watcher instead of two inside `LocalBackend`'s lock. -- `internal/clients` stops receiving a field that means two things. - -Bad, and accepted: - -- We now own the bring-up loop, including `Notify.ErrMessage` handling and the - `TailscaleIPs` check. If tsnet adds a step to `Up`, we will not get it. -- Phase detection reads several `Notify` fields (`State`, `BrowseToURL`, - `LoginFinished`, `SelfChange`) whose exact ordering is upstream behaviour, not - contract. `WatchIPNBus` is documented as unstable. -- `printAuthURLLoop` still runs and still calls `StatusWithoutPeers` every five - seconds while a login is outstanding. Nothing we can do from outside tsnet. -- A migration: every `g.ApertureHost` reader changes. - -## Deferred: the channel plumbing - -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, -and the plumbing has four knots that the shape this ADR describes removes as a -side effect. Recorded here rather than fixed piecemeal, because three of the -four are one change: the Machine owns a long-lived stream and each Attempt -subscribes to it for its own lifetime. - -| Knot | Where | What it costs today | -|---|---|---| -| Two identity mechanisms for "is this message from the current attempt" | `bridgeLogMsg` compares channel pointers (`tui.go:732`); `browserOpenMsg`, `clipboardMsg` and `activationTickMsg` compare `act.id` | `bridgeLogDoneMsg` exists only to unwire the pointer one. Same question, two answers, and a reader has to know which applies where. | -| One goroutine per log line | `waitBridgeLog` receives one value and re-arms itself through the event loop | A `--debug` burst is a spawn per line. Works, and is the documented bubbletea idiom for a channel, which is the argument for a subscription instead of a channel. | -| `WatchLogin` starts only when the node is created | `runningNode` (`manager.go:353`) returns early for a cached node | A re-login on an existing Machine reports no phases and surfaces no link. The `ev.enter(FindingEndpoint)` in `Activate` papers over the common case and nothing covers the rest. | -| The sink outlives the Attempt that made it | fixed ahead of the rework; see below | | - -The last one was a live defect rather than untidiness, so it is fixed now: -`runningNode` gave the node's `UserLogf`/`DebugLogf` a closure over the first -Attempt's sink, and `startProxy` did the same for `transport.DialContext` and -`proxy.ErrorHandler`. Nodes and proxies are cached in `Manager.nodes` and -`nodeRuntime.proxies` for the life of the process; Attempts are not. From the -second Attempt onward every dial diagnostic and every `Bridge proxy error` was -written to a cancelled channel and dropped, which is exactly the output wanted -when a bridge breaks mid-session. - -## Alternatives considered - -**Timestamp the log lines and stop there.** Already shipped (`53f2148`) and it -is what made the phases visible enough to name. It is not enough on its own: -the TUI still parses prose to decide to open a browser, the link still shares a -lossy channel, and an elapsed time against an unnamed line still does not say -which of two `NeedsLogin` waits you are in. - -**Keep `tsnet.Server.Up` and add phases from the existing second watcher.** -Smaller diff, and it avoids owning the bring-up loop. Rejected because it keeps -two LocalAPI watchers on a backend that assumes one, and because a lagging -watcher is evicted with a terminal `ErrMessage` -(`closeLaggingWatchSessionLocked`) that `Up` converts into -`tsnet.Up: backend: IPN bus consumer fell behind`: a bridge failure with no -relationship to anything the user did. - -**Patch tsnet upstream to publish phases.** The right long-term answer for -`printAuthURLLoop` and for phase signals generally, and worth raising. It does -not unblock this, and the ACL is what makes adopting it later a change in one -package. +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 starts serving anything -over a bridge (which puts `resetServeStateOnce` back in scope). +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 index a7efd1b..7e8ecdf 100644 --- a/docs/adr/0002-bridge-removal-destroys-the-machine.md +++ b/docs/adr/0002-bridge-removal-destroys-the-machine.md @@ -1,123 +1,66 @@ -# 0002. Removing a bridge destroys the machine it registered +# 0002. Removing a bridge destroys its Machine Status: proposed Date: 2026-09-18 -Change size: medium. One new method on `bridges.Manager`, a confirmation menu, -and a rework of the six removal sites in `internal/tui` so they route through -it. - -## Context - -There are two dead `aperture-cli-bridge-*` machines in the maintainer's tailnet -right now. Neither was deliberately abandoned. Both are what is left of a -bridge that was created in the CLI, connected once, and then deleted from the -CLI, which removed the settings entry and nothing else. - -`config.RemoveBridge` (`global.go:219-239`) rewrites `Settings.Bridges` and -calls `SaveSettings`. The node it registered is not ephemeral -(`manager.go:357-367` sets `Dir` and `Hostname` and no `Ephemeral`), so the -control plane keeps the device; tsnet mkdirs -`$UserConfigDir/aperture/bridges/` and nothing in the repo removes it. -`Manager.Close` never logs out. `SwitchTailnet` (`manager.go:528-566`) is the -only code path that has ever called `Logout`, and it is there because `e22c442` -worked out that closing a node without logging it out leaves the device -orphaned rather than removed. That reasoning was applied to switching tailnets -and not to deleting the bridge outright, which is the larger case. - -The forcing reason is not that this is untidy. It is that the leak became -ordinary. Before `b2a6bc3` a bridge was orphaned only by a deliberate second -`d` on a bridge-named row, which few users would reach. After it, deleting an -endpoint takes its bridge with it, so the common path now silently registers a -machine on the user's tailnet and silently abandons it. `b2a6bc3` was the right -fix for the row that moved to the bottom instead of disappearing; it also -turned a rare leak into the default one. - -The full inventory of resources and removal sites is in -[the bridge resource lifecycle spec](../specs/bridge-resource-lifecycle.md). - -A note on evidence. The specs under `docs/specs/` landed on 2026-09-17 -(`dceb2aa`) and describe the code as it already was, so this ADR does not treat -them as prior constraint. What it rests on is `manager.go` as written in -`e22c442` on 2026-09-16, and two machines that exist. + +## 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 -Removing the last reference to a bridge destroys the machine it registered. - -1. `bridges.Manager` grows `Forget(ctx, bridge, emit)`, shaped like - `SwitchTailnet`: bring the node up if it is not already, log out, close it, - evict it from `nodes` and `tailnets`, then `RemoveAll` the state directory. -2. The settings entry goes last, after `Forget` returns. Settings is the only - record that the device exists, so dropping it first and then failing the - logout leaves a registered machine the CLI can no longer name. -3. A bridge with no recorded tailnet (`Bridge.Tailnet == ""`) skips the logout. - It has never registered, so there is no device, and bringing it up to delete - it would make the user authorize a machine in order to destroy it. -4. Removal confirms first, in the `switchTailnetMenu` shape - (`menus.go:544-576`), and the confirmation names the device and the tailnet - it will be removed from. -5. The wait is bounded. On timeout or error the local records go anyway and the - user is told, by name, which device is still in their tailnet. -6. All six removal sites route through one path. The parallel `d` on the - Bridges settings menu (`menus.go:213-227`) stops being a second way to - delete a bridge without confirmation. +Destroying the last Bridge reference destroys its Machine. + +1. `Machine` grows `Destroy(ctx) error`: `LeaveTailnet`, `Close`, then discard + the state directory, which is the Machine's own persistence. No new + `Manager` method. `Manager` caches Machines; it does not own their + lifecycle (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 `Tailnet` never registered. It has no Machine to destroy + and must not start one to find out. +5. Destruction confirms, naming the device and the tailnet. +6. The wait is bounded, and on timeout the local records go anyway and the + user is told which device is still theirs to delete. ## Consequences -Good: - -- Deleting a bridge leaves nothing behind, which is what the user already - believes is happening. -- The admin console stops accumulating a machine per abandoned first login. -- One removal path instead of six near-copies, and one confirmation covering - all of them. - -Bad, and accepted: - -- Delete becomes slow and failable where it is currently instant and - infallible. Logout is a control-plane round trip on the same infrastructure - where `/machine/register` was observed hanging past 90 seconds on - 2026-09-17. Point 5 is the concession, and it means "removed" sometimes means - "removed locally, go finish this in the admin console". -- Removal needs a `context.Context` and an event sink at call sites that today - are synchronous `menu.Result` functions. That is the bulk of the diff. -- Destruction is now irreversible from the CLI. A user who deletes a bridge and - wants it back logs in again as a new machine, with a new device name and - whatever ACL grants applied to the old name no longer matching. -- Leftover devices are unpublished behaviour that someone may have come to - depend on: an ACL rule naming `aperture-cli-bridge-abc123`, a route, a - tagged group. That is an argument for confirming loudly, not for continuing - to leak. It is why point 4 names the device rather than asking "remove this - bridge?". - -## Alternatives considered - -**Leave it.** The status quo. Cheapest, and defensible while the leak was rare. -It is not rare now, and the failure is invisible at the point it happens and -visible only later, in a different product, to a user who has no way to tell -which of their `aperture-cli-*` machines are live. - -**Go back to the two-step delete.** Revert `b2a6bc3`, so deleting an endpoint -never cascades and a bridge can only be removed deliberately. That restores -"when I delete an endpoint, it just moves to the bottom, I have to do it -twice" for something the picker presents as one row, and it does not stop the -leak, it only makes the user press `d` twice to cause it. - -**Make bridges ephemeral.** Set `Ephemeral: true` on the `tsnet.Server` and let -the control plane reap the device when the node disconnects. It solves the leak -completely and costs the thing bridges exist for: every reconnect becomes a new -device and a new interactive login. Trading a cleanup bug for a login on every -run is a worse trade, particularly given how slow that login currently is. - -**Delete the settings entry and the state directory, skip the logout.** Avoids -the control-plane round trip, so delete stays fast. It leaves exactly the -orphan `SwitchTailnet` was written to avoid, and it destroys the credentials -that would let us clean up later, so it converts a recoverable leak into a -permanent one. +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, so "removed" will sometimes mean "removed locally". + +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 offers a way to deregister a node without bringing it up first, which -would remove the only reason `Forget` is slow and would let removal go back to -being synchronous. Or if bridges stop being long-lived, in which case the -ephemeral option becomes the right answer instead. +tsnet can deregister a node without bringing it up, which is the only reason +`Destroy` is slow. diff --git a/docs/specs/bridge-resource-lifecycle.md b/docs/specs/bridge-resource-lifecycle.md index 2289509..21ea99d 100644 --- a/docs/specs/bridge-resource-lifecycle.md +++ b/docs/specs/bridge-resource-lifecycle.md @@ -1,139 +1,86 @@ # Bridge resource lifecycle -What a bridge creates, what removes it, and what is left behind today. - Creating a bridge produces three things. Removing one destroys one of them. -The other two are a tailnet device the user can see in their admin console and -a directory on their disk, and nothing in this repo has ever deleted either. +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). -## The three resources +## What a bridge creates | Resource | Created by | First exists | Removed by | |---|---|---|---| -| Tailnet device `aperture-cli-` | `tsnet.Server` registering with the control plane | first successful `Activate` | nothing | -| `$UserConfigDir/aperture/bridges/` | tsnet, lazily, from `Server.Dir` | first `Activate`, successful or not | nothing | -| `config.Bridge` in settings | `Global.AddBridge` (`global.go:178-196`) | the moment the user types a name | `Global.RemoveBridge` (`global.go:219-239`) | - -The device is persistent because the node is not ephemeral. `newNode` -(`manager.go:357-367`) builds: - -```go -s := &tsnet.Server{ - Dir: stateDir, - Hostname: "aperture-cli-" + bridge.ID, - UserLogf: userLogf, -} -``` - -There is no `Ephemeral` field set anywhere in `internal/bridges`, so the -control plane keeps the machine after the process exits, which is the point: -the same bridge reconnects next run without a login. The state directory is -the other half of that. `config.BridgeStateDir` (`settings.go:90-101`) returns -`$UserConfigDir/aperture/bridges/`, and -its only non-test caller is `runningNode` (`manager.go:440`), which hands it to -`Server.Dir`. tsnet mkdirs it on start, so a bridge that has never been -activated has no directory. - -`RemoveBridge` rewrites `Settings.Bridges` and calls `SaveSettings`. That is -all it does. `os.RemoveAll` appears four times in the repo, all of it client -installer cleanup, none of it bridge related. `Manager.Close` -(`manager.go:567-587`) closes proxies and nodes and never logs out, which is -correct for shutdown and is why nothing else has to be. - -The one place that does log out is `SwitchTailnet` (`manager.go:528-566`), and -its comment already names the failure mode this document is about: - -> A node that was never started this session is therefore brought up on the -> old tailnet first, which is also what leaves the device removed from it -> rather than orphaned. - -That reasoning applies to removal at least as strongly as it applies to -switching. Removal skipped it. +| 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 `newNode` (`manager.go:357`) 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. + +`SwitchTailnet` (`manager.go:528`) is the only caller 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 places, all in `internal/tui`, none of them confirming, none of them -touching anything but settings. - -| Site | What it removes | Note | -|---|---|---| -| `bridgesMenu` hidden `d` (`menus.go:213-227`) | the bridge | a second bridge-deleting UI, parallel to the picker's | -| `removeConnectionRow` default arm (`menus.go:482-495`) | the bridge, for a row with no endpoint | the "Remove bridge" the user sees | -| `removeConnection` (`menus.go:497-520`) | the endpoint, then cascades | | -| `dropOrphanBridge` (`menus.go:529-539`) | the bridge, once its last endpoint is gone | added in `b2a6bc3` | -| setup guide "Remove endpoint" (`menus.go:647-667`) | the endpoint, then cascades | duplicates `removeConnection`'s loop | -| `discardActivation` (`tui.go:471-494`) | the ephemeral endpoint only | leaves the bridge | - -The last one is worth reading as a cause rather than a symptom. Cancelling a -connection to a freshly created bridge takes the endpoint back out and leaves -the bridge, which is exactly what makes a bare "Connect via" row appear in the -picker with no endpoint attached. So the row that `removeConnectionRow`'s -default arm deletes is usually the residue of an abandoned first login, and -deleting it is the one case where there is no device to clean up: the bridge -may never have registered at all. - -`b2a6bc3` did not create the leak. It made it reachable from a single delete of -an endpoint, where before the user had to press `d` a second time on a -bridge-named row to get there. - -## What cleanup needs - -One operation on `bridges.Manager`, shaped like `SwitchTailnet` because it is -the same work minus the restart: - -```go -func (m *Manager) Forget(ctx context.Context, bridge config.Bridge, emit func(connection.Event)) error -``` - -Order matters, and it is not the obvious one. Logout, close the node, evict it -from `nodes` and `tailnets`, `RemoveAll` the state directory, and only then let -the caller drop the settings entry. Settings last 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, which is strictly worse -than the leak we have now. - -## Constraints that decide the design - -**Logout needs a running node.** `(*tsnetNode).Logout` (`manager.go:335-341`) -goes through `server.LocalClient()`, so the credentials it clears live behind -the in-process LocalAPI. Closing a node without logging out reuses them next -start. There is no way to log out a bridge that is not up. - -**Starting a node that never registered performs a full interactive login.** -`runningNode`'s own comment (`manager.go:471-474`) says `Up` blocks until the -node is Running, which for a bridge that has never logged in means blocking -until the user visits a link nothing has shown them yet. Routing removal -through `runningNode` unconditionally would create a device in order to delete -it, and would do it by asking the user to authorize a machine they just asked -to destroy. `Bridge.Tailnet != ""` is the persisted signal for "has ever -joined" (`SetBridgeTailnet`, `global.go:201-216`); a bridge without it skips -the logout entirely, and has no state directory to remove either. - -**Removal is slow and failable.** `/machine/register` was observed hanging past -90 seconds on 2026-09-17, and logout is a control-plane round trip on the same -infrastructure. A delete that blocks the UI indefinitely is not shippable. The -operation needs a bounded wait and an escape that removes the local records -anyway and tells the user, in words, that a device named -`aperture-cli-` is still in their tailnet and where to delete it. +Six sites, all in `internal/tui`, none confirming, none touching anything but +settings. + +| Site | Removes | +|---|---| +| `bridgesMenu` hidden `d` (`menus.go:213`) | the bridge, from a second delete UI parallel to the picker's | +| `removeConnectionRow` default arm (`menus.go:482`) | the bridge, for a row with no endpoint | +| `removeConnection` (`menus.go:497`) | the endpoint, then cascades | +| `dropOrphanBridge` (`menus.go:529`) | the bridge, once its last endpoint goes (`b2a6bc3`) | +| setup guide "Remove endpoint" (`menus.go:647`) | the endpoint, then cascades | +| `discardActivation` (`tui.go:471`) | the ephemeral endpoint, leaving the bridge | + +The last 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(ctx) error`, on the aggregate that owns the node +([domain model](connection-domain-model.md#machine)), not a new method on +`Manager`: `LeaveTailnet`, `Close`, then discard the state directory, which is +the Machine's own persistence. + +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 a running node.** `Logout` (`manager.go:335`) goes through +`server.LocalClient()`, so the credentials live behind the in-process LocalAPI +and a closed node keeps them. + +**Starting a node that never registered performs a full interactive login** +(`manager.go:471`). Destroying through `runningNode` unconditionally would +create a device in order to delete it, and would ask the user to authorize a +machine they just asked to destroy. `Bridge.Tailnet != ""` is the persisted +"has ever joined". + +**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 has a context or an event sink.** All six return -`menu.Result` synchronously. The house pattern for slow work is the one -`connectVia` uses (`menus.go:793-801`): do the fast fallible part inline, -return a `tea.Cmd` for the rest. The house pattern for showing progress -without a full connection attempt is the post-launch recheck -(`tui.go:773-782`), which reuses `stepPreflight` with a bare `activation` for -the label and clock plus its own result message. - -**No removal path confirms today.** Every deletion above is one keypress. The -house confirm shape is `switchTailnetMenu` (`menus.go:544-576`): a menu whose -title is a question naming the subject, a preamble stating the current state -and the consequence, two items `{verb, y}` and `{Cancel, n}`, pushed with -`Next` so Esc also backs out. +`menu.Result` synchronously. The house pattern for slow work is `connectVia` +(`menus.go:793`); for progress without a connection attempt it is the +post-launch recheck (`tui.go:773`), which reuses `stepPreflight` with its own +result message. -## Out of scope +**No removal path confirms today.** The house confirm shape is +`switchTailnetMenu` (`menus.go:544`). -The cascade rule from `b2a6bc3` does not change: a bridge two endpoints reach -through is not an orphan and survives the removal of either one. +## Out of scope -Deduplicating the six removal sites is not required to fix the leak, though +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 index 5276f62..53fe271 100644 --- a/docs/specs/connection-context-map.md +++ b/docs/specs/connection-context-map.md @@ -73,6 +73,37 @@ flowchart LR | 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 | diff --git a/docs/specs/connection-contracts.md b/docs/specs/connection-contracts.md index 4d2e41a..b7aa4c8 100644 --- a/docs/specs/connection-contracts.md +++ b/docs/specs/connection-contracts.md @@ -126,6 +126,27 @@ code, which the skill flags and no constraint layer here can fix: - "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. | +| `WatchLogin` starts only when the node is created | `runningNode` (`manager.go:353`) 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 From bfa70db11d94359ee73583d88da4741089312f92 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Fri, 18 Sep 2026 16:26:51 +0000 Subject: [PATCH 35/69] config: give the startup selection its own type StartupEndpoint and bridgeNamed went onto Global because Global was already threaded everywhere the flags had to reach. Global is the pile every client package reads: five unrelated fields, twenty methods, and now two more that touch none of the existing ones. Hanging the next thing off whatever is nearest is how it got that way. Startup owns what the invocation asked for and nothing else, so the resolution rules are testable against a value rather than against the whole loaded config, and Global loses two methods that were never about live app state. The alternative was leaving it: one more pair of methods on a struct that already has twenty is invisible in review, which is the cost. Revisit if Startup grows a second reason to exist, at which point it wants the bridge lookup as a real repository rather than a *Global. --- cmd/aperture/main.go | 17 ++++++----- internal/config/global.go | 45 ---------------------------- internal/config/startup.go | 52 +++++++++++++++++++++++++++++++++ internal/config/startup_test.go | 33 +++++++++++---------- 4 files changed, 78 insertions(+), 69 deletions(-) create mode 100644 internal/config/startup.go diff --git a/cmd/aperture/main.go b/cmd/aperture/main.go index e2cbb49..8a69f64 100644 --- a/cmd/aperture/main.go +++ b/cmd/aperture/main.go @@ -157,10 +157,9 @@ func reportFailure(err error) { } } -// orEnv falls back to the environment for a flag nobody passed, so the same -// selection works from a dotfile, a container or a systemd unit as from a -// typed invocation. The flag wins: a one-off run has to be able to override -// whatever the shell was started with. +// orEnv 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. func orEnv(value, key string) string { if value != "" { return value @@ -197,10 +196,12 @@ func main() { // Register Claude Desktop on supported platforms (darwin, windows). profiles.RegisterIfSupported() - // Resolved before the TUI takes the terminal, so a URL it cannot use is a - // line on stderr and a non-zero exit rather than a full-screen error the - // script that passed it will never see. - start, err := g.StartupEndpoint(orEnv(*flagEndpoint, "APERTURE_ENDPOINT"), orEnv(*flagBridge, "APERTURE_BRIDGE")) + // Before the TUI takes the terminal, so a URL we cannot use exits non-zero + // instead of painting an error the script that passed it will never see. + start, err := config.Startup{ + URL: orEnv(*flagEndpoint, "APERTURE_ENDPOINT"), + BridgeName: orEnv(*flagBridge, "APERTURE_BRIDGE"), + }.Resolve(g) if err != nil { slog.Error("resolving the endpoint to open on", "err", err) reportFailure(err) diff --git a/internal/config/global.go b/internal/config/global.go index f1fd87e..1df6e00 100644 --- a/internal/config/global.go +++ b/internal/config/global.go @@ -69,51 +69,6 @@ func (g *Global) ActiveEndpoint() Endpoint { return g.Settings.Endpoints[0] } -// StartupEndpoint is where the launcher opens: what the invocation named, or -// the saved active endpoint when it named nothing. -// -// Neither argument is required and neither implies the other. A URL on its own -// is a direct connection. A bridge on its own starts at DefaultLocation, the -// same guess the connection picker makes, because the point of naming a bridge -// is usually that you know how to get on the tailnet and not what is listening -// on it. Both together pin the URL behind the bridge. -// -// It is the caller's job to run this before the TUI takes the terminal: a -// rejected URL is worth a line on stderr, not a full-screen error. -func (g *Global) StartupEndpoint(endpointArg, bridgeArg string) (Endpoint, error) { - endpointArg = strings.TrimSpace(endpointArg) - bridgeArg = strings.TrimSpace(bridgeArg) - if endpointArg == "" && bridgeArg == "" { - return g.ActiveEndpoint(), nil - } - var bridgeID string - if bridgeArg != "" { - bridge, err := g.bridgeNamed(bridgeArg) - if err != nil { - return Endpoint{}, err - } - bridgeID = bridge.ID - } - if endpointArg == "" { - return Endpoint{URL: DefaultLocation, BridgeID: bridgeID}, nil - } - return ParseEndpoint(endpointArg, bridgeID) -} - -// bridgeNamed finds the bridge called name and creates it if there is none, -// which is what makes a first run scriptable: a flag that only worked once -// someone had already made the bridge by hand would not be worth having. -// Matching ignores case because the name is the user's own label and nothing -// keys off it. -func (g *Global) bridgeNamed(name string) (Bridge, error) { - for _, b := range g.Settings.Bridges { - if strings.EqualFold(b.Name, name) { - return b, nil - } - } - return g.AddBridge(name) -} - // 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. diff --git a/internal/config/startup.go b/internal/config/startup.go new file mode 100644 index 0000000..b6821d8 --- /dev/null +++ b/internal/config/startup.go @@ -0,0 +1,52 @@ +package config + +import "strings" + +// Startup is what the invocation asked the launcher to open. The zero value +// means it asked for nothing. +type Startup struct { + URL string + BridgeName string +} + +// Resolve returns the endpoint to open on, falling back to the saved active +// one when the invocation named nothing. +// +// A URL alone is 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 before the TUI takes the terminal, so a URL we cannot use is +// a line on stderr rather than a full-screen error. +func (s Startup) Resolve(g *Global) (Endpoint, error) { + url := strings.TrimSpace(s.URL) + name := strings.TrimSpace(s.BridgeName) + if url == "" && name == "" { + return g.ActiveEndpoint(), nil + } + var bridgeID string + if name != "" { + bridge, err := s.bridge(g, name) + if err != nil { + return Endpoint{}, err + } + bridgeID = bridge.ID + } + if url == "" { + return Endpoint{URL: DefaultLocation, BridgeID: bridgeID}, nil + } + return ParseEndpoint(url, bridgeID) +} + +// bridge creates the named bridge if there is none, which is what makes a first +// run scriptable. Matching ignores case: the name is the user's own label and +// nothing keys off it. +func (s Startup) bridge(g *Global, name string) (Bridge, error) { + for _, b := range g.Settings.Bridges { + if strings.EqualFold(b.Name, name) { + return b, nil + } + } + return g.AddBridge(name) +} diff --git a/internal/config/startup_test.go b/internal/config/startup_test.go index 0e1f45b..4e32d8e 100644 --- a/internal/config/startup_test.go +++ b/internal/config/startup_test.go @@ -8,7 +8,7 @@ import ( ) // loadInto points config at a scratch directory and returns a Global holding -// the given settings, saved, so StartupEndpoint's writes have somewhere to go. +// 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() @@ -24,24 +24,24 @@ func loadInto(t *testing.T, s config.Settings) *config.Global { return g } -func TestStartupEndpointFallsBackToTheSavedOne(t *testing.T) { +func TestResolveFallsBackToTheSavedOne(t *testing.T) { g := loadInto(t, config.Settings{Endpoints: []config.Endpoint{{URL: "http://saved"}}}) - ep, err := g.StartupEndpoint("", "") + ep, err := config.Startup{}.Resolve(g) if err != nil { - t.Fatalf("StartupEndpoint: %v", err) + t.Fatalf("Resolve: %v", err) } if ep != (config.Endpoint{URL: "http://saved"}) { t.Errorf("endpoint = %+v, want the saved one", ep) } } -func TestStartupEndpointTakesABareHost(t *testing.T) { +func TestResolveTakesABareHost(t *testing.T) { g := loadInto(t, config.Settings{Endpoints: []config.Endpoint{{URL: "http://saved"}}}) - ep, err := g.StartupEndpoint("aperture.example.com", "") + ep, err := config.Startup{URL: "aperture.example.com"}.Resolve(g) if err != nil { - t.Fatalf("StartupEndpoint: %v", err) + t.Fatalf("Resolve: %v", err) } if ep != (config.Endpoint{URL: "http://aperture.example.com"}) { t.Errorf("endpoint = %+v, want the named one, schemed", ep) @@ -51,12 +51,12 @@ func TestStartupEndpointTakesABareHost(t *testing.T) { // 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 TestStartupEndpointGuessesTheLocationForANamedBridge(t *testing.T) { +func TestResolveGuessesTheLocationForANamedBridge(t *testing.T) { g := loadInto(t, config.Settings{Bridges: []config.Bridge{{ID: "bridge-abc123", Name: "Work"}}}) - ep, err := g.StartupEndpoint("", "work") + ep, err := config.Startup{BridgeName: "work"}.Resolve(g) if err != nil { - t.Fatalf("StartupEndpoint: %v", err) + t.Fatalf("Resolve: %v", err) } if ep != (config.Endpoint{URL: config.DefaultLocation, BridgeID: "bridge-abc123"}) { t.Errorf("endpoint = %+v, want %s through the existing bridge", ep, config.DefaultLocation) @@ -68,12 +68,12 @@ func TestStartupEndpointGuessesTheLocationForANamedBridge(t *testing.T) { // 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 TestStartupEndpointCreatesAnUnknownBridge(t *testing.T) { +func TestResolveCreatesAnUnknownBridge(t *testing.T) { g := loadInto(t, config.Settings{}) - ep, err := g.StartupEndpoint("http://aperture.example.com", "Work") + ep, err := config.Startup{URL: "http://aperture.example.com", BridgeName: "Work"}.Resolve(g) if err != nil { - t.Fatalf("StartupEndpoint: %v", err) + 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) @@ -93,10 +93,11 @@ func TestStartupEndpointCreatesAnUnknownBridge(t *testing.T) { } } -func TestStartupEndpointRejectsAUnusableURL(t *testing.T) { +func TestResolveRejectsAUnusableURL(t *testing.T) { g := loadInto(t, config.Settings{}) - if _, err := g.StartupEndpoint("ftp://aperture.example.com", ""); err == nil { - t.Error("StartupEndpoint accepted an ftp URL, want it refused before the TUI takes the terminal") + s := config.Startup{URL: "ftp://aperture.example.com"} + if _, err := s.Resolve(g); err == nil { + t.Error("Resolve accepted an ftp URL, want it refused before the TUI takes the terminal") } } From ee192eab2c94c403c8f1f43b915f7f1acc1e1a70 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Fri, 18 Sep 2026 16:26:58 +0000 Subject: [PATCH 36/69] tui,bridges,connection: cut the comments back to what the code cannot say Every block here was written to justify a decision and then kept explaining it: fifteen lines on liveEvents, thirteen on health, twelve on authFooter. A comment that long stops being read, and the reason it holds gets skipped with it. Each keeps its forcing fact (the 29 second register, the 502 loop, the dropped login link, Bubble Tea truncating an over-width line) and loses the retelling. Nothing that explains a non-obvious choice was removed; what went was narration of what the code already shows. Comments in main and unchanged files are left alone: trimming them would grow a diff this was meant to shrink. --- internal/bridges/manager.go | 171 ++++++++++++------------------- internal/bridges/manager_test.go | 38 +++---- internal/connection/event.go | 62 +++++------ internal/tui/browser.go | 21 ++-- internal/tui/menus.go | 46 ++++----- internal/tui/tui.go | 123 +++++++++------------- internal/tui/tui_test.go | 13 +-- 7 files changed, 182 insertions(+), 292 deletions(-) diff --git a/internal/bridges/manager.go b/internal/bridges/manager.go index 13d0d23..c73e66d 100644 --- a/internal/bridges/manager.go +++ b/internal/bridges/manager.go @@ -56,20 +56,12 @@ type nodeRuntime struct { } // liveEvents points a node's long-lived reporting at whichever connection is -// using it now. +// using it now. Nodes and proxies outlive the connection that built them, and +// closures that captured that connection's sink went on writing to a channel +// nobody read, losing every later dial failure and proxy error. // -// A node is cached in Manager.nodes and its proxies in nodeRuntime.proxies for -// the life of the process; the connection that built them ends with the -// connect screen. Closures that captured that connection's own sink kept -// writing to it, so from the second connection onward every dial failure and -// every proxy error was handed to a channel nobody had read since the first -// one finished, which is exactly the output wanted when a bridge breaks -// mid-session. -// -// Nothing clears it when a connection ends. That is deliberate: the sink of a -// finished connection discards what it is given, so the worst case is the -// pre-existing behaviour, and a clear would need a lifecycle hook that only -// the Attempt aggregate can own. +// Nothing clears it when a connection ends: a finished sink discards what it is +// given, and a clear needs a lifecycle hook only the Attempt can own. type liveEvents struct { mu sync.Mutex ev events @@ -107,9 +99,8 @@ type tailnetNode interface { } // events is where a bridge reports what it is doing. This package translates -// the tailnet's vocabulary into it and never publishes anything else: a caller -// that had to recover meaning by matching the prose in a log line was matching -// a phrase from inside a vendored package. +// the tailnet's vocabulary into it and publishes nothing else, so no caller has +// to recover meaning by matching prose from inside a vendored package. type events func(connection.Event) // sink returns a usable events, so callers that want none can pass nil. @@ -122,13 +113,10 @@ func sink(emit func(connection.Event)) events { } } -// logEvent copies a connection event into the run log. The connect screen -// already shows these, but the screen dies with the process, and the run -// anyone wants to read back is the one that was killed halfway through: what -// it was waiting on and for how long is only answerable from a file. -// -// Notes are debug because tsnet's backend logger arrives as notes under -// -debug, and a phase is worth reading without wading through that. +// logEvent copies a connection event into 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 are 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 e.Kind { case connection.PhaseEntered: @@ -165,24 +153,20 @@ func (n *tsnetNode) DialContext(ctx context.Context, network, address string) (n return n.server.Dial(ctx, network, address) } -// WatchLogin reports what an interactive login is waiting on, until ctx is -// done. +// WatchLogin reports what an interactive login is waiting on, until ctx is done. // -// tsnet surfaces the login link from a five second poll loop of its own, so a -// link that lands just after a tick stays invisible for most of that window. -// A bridge that took sixteen seconds to register showed the user nothing but -// "NeedsLogin" and got killed a few hundred milliseconds before the link would -// have been printed. The IPN bus has the link the moment the control plane -// answers, so watch that instead of waiting for tsnet to notice. +// tsnet surfaces the link from a five second poll loop of its own, so a link +// landing just after a tick stays invisible for most of that window: one bridge +// was killed a few hundred milliseconds before its link would have printed. The +// IPN bus has it the moment the control plane answers. func (n *tsnetNode) WatchLogin(ctx context.Context, ev events) { // A cancelled watch is how this returns on every connection that works, // so only a failure the caller did not ask for is worth a line. report := func(err error) { if err != nil && ctx.Err() == nil { - // Logged as well as noted: a watch that dies leaves the attempt - // sitting on whatever phase it last reported, forever and in - // silence, which is indistinguishable on screen from a control - // plane that is simply slow. + // Logged as well as noted: a dead watch leaves the attempt on its + // last phase forever, which on screen is indistinguishable from a + // control plane that is simply slow. slog.Error("bridge login watch ended", "err", err) ev.note("Could not watch the bridge's login state: " + err.Error()) } @@ -195,10 +179,9 @@ func (n *tsnetNode) WatchLogin(ctx context.Context, ev events) { report(err) return } - // InitialHealthState as well as InitialState: health changes reach every - // watcher regardless of mask, but a login that was already broken before - // this watch started only shows up in the initial one, which is the reused - // node case. + // InitialHealthState too: health changes reach every watcher regardless of + // mask, but a login already broken before this watch started shows up only + // in the initial one, which is the reused node case. watcher, err := lc.WatchIPNBus(ctx, ipn.NotifyInitialState|ipn.NotifyInitialHealthState) if err != nil { report(err) @@ -221,22 +204,18 @@ func reportLogin(watcher *local.IPNBusWatcher, ev events) error { } // loginReporter turns IPN bus notifications into the phases a connection -// attempt reports. It holds the phase it last reported because the bus repeats -// states, and the anti-corruption layer is the right place to absorb that. +// attempt reports, holding the last one because the bus repeats states. // -// The mapping is the whole point of the exercise. ipn.NeedsLogin covers two -// waits that look identical on screen and are not the same problem: before a -// BrowseToURL arrives the control plane has not answered yet and there is -// nothing for the user to do, and after it arrives everything is waiting on -// them. Reporting the backend state is what made a 29 second registration -// indistinguishable from a user who had wandered off. +// ipn.NeedsLogin covers two waits that look identical and are not: before a +// BrowseToURL the control plane has not answered and there is nothing to do, +// after it everything is waiting on the user. Reporting the backend state made +// a 29 second registration indistinguishable from someone who wandered off. type loginReporter struct { ev events phase connection.Phase - // loginBroken is whether the login-state warning is currently up. Held - // because the health state is re-sent on every retry and the text carries - // a fresh request ID each time, so reporting on text would put a new line - // on screen roughly once a second for as long as the failure lasts. + // loginBroken is 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 } @@ -255,19 +234,16 @@ func (r *loginReporter) notify(n *ipn.Notify) { return } if n.State != nil { - // The raw state, not just the phase it maps to: NoState and NeedsLogin - // are one phase on screen on purpose, and they are the whole question - // in a log. NoState means control has not answered the register yet, - // NeedsLogin means it has and the link is the next thing due. + // The raw state, not just the phase: NoState and NeedsLogin are one + // phase on screen on purpose and 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, and NoState is the one that matters. A bridge that has - // never logged in sits in NoState for the whole of - // POST /machine/register and only reaches NeedsLogin once control - // has answered with a URL, so NoState is the wait, not a - // not-started-yet. Tailscale's own comment on it reads "UIs should - // print Loading..." (ipnlocal/local.go, nextStateLocked). + // Both, and NoState is the one that matters: a bridge that never + // logged in sits there for the whole of POST /machine/register, so + // it is the wait and not a not-started-yet. Tailscale's own comment + // reads "UIs should print Loading..." (ipnlocal/local.go). r.enter(connection.AwaitingLoginLink) case ipn.NeedsMachineAuth: // No phase of its own: we have never seen it, and inventing a wait @@ -299,19 +275,13 @@ func (r *loginReporter) notify(n *ipn.Notify) { r.health(n.Health) } -// health reports a login that is failing rather than merely slow. -// -// Without this the two are one screen: a register that control answers with a -// 502 leaves the node in NeedsLogin, sending no BrowseToURL, so the attempt -// sits on "Waiting for a login link" for as long as the user tolerates it -// while tsnet retries behind a backoff. The failure is published on the health -// state and nowhere else the bus exposes: the error is not a vizerror, so it -// never reaches Notify.ErrMessage. +// health reports a login that is failing rather than merely slow. A register +// answered with a 502 leaves the node in NeedsLogin sending 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. // -// login-state specifically, not every warning. The others describe a node that -// is up and imperfect (no DERP home, an update available), which is not this -// attempt's business and would bury the one line that is. k8s-proxy watches -// the same warnable for the same reason. +// login-state only. The other warnables describe a node that is up and +// imperfect, and would bury the one line that is this attempt's business. func (r *loginReporter) health(state *health.State) { if state == nil { return @@ -387,16 +357,14 @@ func (m *Manager) Activate(ctx context.Context, bridge config.Bridge, remoteURL if err != nil { return "", err } - // Here rather than in runningNode, which SwitchTailnet also uses and which - // returns immediately for a node that is already up. A reused bridge skips - // every earlier phase and would otherwise report nothing at all while the + // Here rather than in runningNode, which returns immediately for a node + // already up: a reused bridge would otherwise report nothing while the // first dial waits for the target to appear in its peer map. ev.enter(connection.FindingEndpoint) 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. + // Up deliberately returns status without peers. Full status 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. if fullStatus, err := rt.node.Status(ctx); err != nil { ev.note("Could not read bridge network status: " + err.Error()) } else { @@ -442,15 +410,10 @@ func (m *Manager) runningNode(ctx context.Context, bridge config.Bridge, ev even m.mu.Unlock() return nil, nil, err } - // Both of tsnet's loggers are diagnostics now, and neither is on unless the - // user asked for them. Everything the attempt waits on is read off the IPN - // bus, where it is a fact rather than a sentence that can be reworded - // upstream, so tsnet's user-facing prose has nothing left to contribute: it - // is mostly printAuthURLLoop reprinting a link the footer already shows, - // once every five seconds, and it would push the phases off the screen. - // - // A no-op rather than nil: tsnet falls back to log.Printf when UserLogf is - // unset, which writes over the TUI. + // 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. A no-op rather than nil, + // because tsnet falls back to log.Printf, which writes over the TUI. live := &liveEvents{} live.use(ev) logNotes := func(format string, args ...any) { @@ -518,13 +481,11 @@ func (m *Manager) Tailnet(bridgeID string) string { } // SwitchTailnet logs the bridge out of the tailnet it is on and discards its -// node, so the next Activate starts a fresh one and asks for a new login. +// node, so the next Activate asks for a new login. // // The node has to be running to be logged out: its credentials live behind the -// in-process LocalAPI, and closing the node without logging out would reuse -// them on the next start. A node that was never started this session is -// therefore brought up on the old tailnet first, which is also what leaves the -// device removed from it rather than orphaned. +// in-process LocalAPI. A node not started this session is brought up on the old +// tailnet first, which is what leaves the device removed rather than orphaned. func (m *Manager) SwitchTailnet(ctx context.Context, bridge config.Bridge, emit func(connection.Event)) error { if m == nil { return fmt.Errorf("bridge manager is not configured") @@ -635,9 +596,8 @@ func parseTarget(raw string) (*url.URL, error) { } // startProxy builds the reverse proxy for one target on rt's node. It reports -// through rt rather than through the connection that asked, because the proxy -// it returns is cached and will still be serving long after that connection -// has gone. +// through rt because the proxy is cached and will still be serving long after +// the connection that asked for it has gone. func (m *Manager) startProxy(rt *nodeRuntime, target *url.URL) (*proxyRuntime, error) { node, ev := rt.node, events(rt.ev.emit) debug := m.debug @@ -701,14 +661,10 @@ type bridgeDialFunc func(context.Context, string, string) (net.Conn, error) // dialViaNode dials address over the bridge's node, resolving a name against // the node's own peer map first and dialing the IP it finds. // -// Handing the name straight to tsnet is what made a first connection hang for -// 30s: until the node's netmap lands, tsnet's resolver falls through to the -// host resolver, and on a machine that is itself on a tailnet that answers -// with a same-named node on the *host's* tailnet. tsnet then sees an address -// it has no route for and system-dials it, so the bridge either blackholes -// until the fetch times out or, worse, proxies to the wrong tailnet's node. -// Resolving through the node cannot leave the bridge's tailnet, and waiting -// for the peer to appear is the same wait the old DNS retry was aiming at. +// Handing the name to tsnet is what made a first connection hang for 30s: until +// the netmap lands its resolver falls through to the host resolver, which on a +// machine already on a tailnet answers with a same-named node on the wrong one. +// Resolving through the node cannot leave the bridge's tailnet. func dialViaNode( ctx context.Context, node tailnetNode, @@ -731,9 +687,8 @@ func dialViaNode( return nil, attempts, err } // Not every target is a tailnet node: a subnet router or the tailnet's - // own DNS can serve it. Those only resolve the way tsnet resolves, so - // fall through and say so, since this is the path that can leave the - // tailnet. + // own DNS can serve it. Those resolve only the way tsnet resolves, so + // fall through and say so, since 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 diff --git a/internal/bridges/manager_test.go b/internal/bridges/manager_test.go index 81afbd1..0c6e391 100644 --- a/internal/bridges/manager_test.go +++ b/internal/bridges/manager_test.go @@ -803,11 +803,10 @@ func TestActivate(t *testing.T) { 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 whole -// change came from. A bridge took 29 seconds to come up and the screen said -// only that it needed a login, so there was no way to tell the control plane -// not having answered yet from a user who had not finished in the browser. -// Both are ipn.NeedsLogin; they are different phases here. +// 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 @@ -851,12 +850,9 @@ func TestLoginReporterRejectsAnUnusableLink(t *testing.T) { } // TestLoginReporterNamesTheWaitBeforeTheControlPlaneAnswers is the state the -// first version of this missed. A bridge that has never logged in sits in -// ipn.NoState for the whole of POST /machine/register, and only reaches -// NeedsLogin once control has answered with a URL, so NoState is the entire -// wait this refactor exists to name. Untranslated it emits nothing, and a -// register that took over a minute put "Starting the bridge" on screen and -// then went silent. +// 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 := loginReporter{ev: collect(&lines)} @@ -878,12 +874,10 @@ func TestLoginReporterNamesTheWaitBeforeTheControlPlaneAnswers(t *testing.T) { } } -// TestAProxyReportsToTheAttemptUsingItNow covers a defect the typed events -// introduced and the string logger had too: nodes and proxies are cached for -// the life of the process, attempts are not, and the closures inside -// startProxy captured whichever attempt happened to create the proxy. Every -// dial failure after the first connection went to a channel nobody had read -// since, which is precisely the output wanted when a bridge breaks mid-session. +// 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() @@ -1006,12 +1000,10 @@ func unhealthyLogin(text string) *ipn.Notify { }} } -// TestLoginReporterReportsALoginThatIsFailing is the 502 register loop. Control -// answers the register with an error, so the node stays in NeedsLogin and never -// sends a BrowseToURL, and the attempt sits on "Waiting for a login link" while -// tsnet retries behind a backoff. Nothing else on the bus carries the reason: -// the error is not a vizerror, so ErrMessage stays nil and the health state is -// the only place it appears. +// 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" diff --git a/internal/connection/event.go b/internal/connection/event.go index b108505..03beda7 100644 --- a/internal/connection/event.go +++ b/internal/connection/event.go @@ -1,14 +1,11 @@ -// Package connection carries what a connection attempt reports while it runs. -// -// It exists so the producer of these events does not own their vocabulary. -// Most of them come from the bridge manager, but the attempt is wider than the -// bridge: the model fetch that follows bring-up is part of the same wait, and -// the user does not know or care which half they are in. The types therefore -// sit below both. +// Package connection carries what a connection attempt reports while it runs, +// so the producers of those events do not own their vocabulary. Most come from +// the bridge manager, but the attempt is wider than the bridge: the model fetch +// after bring-up is part of the same wait, and the user cannot tell the halves +// apart. // // Nothing here may reference tsnet, ipn or ipnstate. Translating the tailnet's -// vocabulary into this one is the bridge manager's job, and this package is -// what it translates into. +// vocabulary into this one is the bridge manager's job. package connection import ( @@ -20,12 +17,10 @@ import ( // Phase is what an attempt is waiting on, named for what the user is waiting // for rather than for the backend state underneath it. // -// AwaitingLoginLink and AwaitingAuthorization are the reason this type exists. -// Both are ipn.NeedsLogin, and they are completely different problems: one is -// the control plane not having answered yet, the other is the user not having -// finished in the browser. A bridge that took 29 seconds to come up spent them -// in the first and showed only "NeedsLogin", so there was nothing on screen to -// tell the two apart and three fixes were aimed at the wrong one. +// 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 is load @@ -66,12 +61,10 @@ type LoginLink struct { func (l LoginLink) String() string { return l.url } -// ParseLoginLink validates a login link and is the only way to make one. -// -// The rules are not cosmetic: the value is handed to a desktop opener and -// shown as something the user should click, so anything that is not an https -// URL is not a link we were asked to follow. Tailscale applies the same rules -// upstream in validPopBrowserURLLocked; this is the second gate, not the first. +// ParseLoginLink validates a login link and is the only way to make one. The +// value is handed to a desktop opener and shown as something to click, so +// anything that is not an https URL is not a link we were asked to follow. +// Tailscale applies the same rules in validPopBrowserURLLocked. func ParseLoginLink(raw string) (LoginLink, error) { raw = strings.TrimSpace(raw) if raw == "" { @@ -106,10 +99,9 @@ const ( LoginRequired ) -// Event is what an attempt publishes as it proceeds. It replaces a log sink of -// plain strings, which forced every consumer to recover meaning by matching -// prose: the TUI opened a browser on a phrase from inside a vendored package, -// so a reworded upstream log line would silently strand the user. +// Event is what an attempt publishes as it proceeds. It replaces a sink of +// plain strings that had the TUI opening a browser on a phrase from inside a +// vendored package, where a reworded log line silently stranded the user. type Event struct { Kind Kind Phase Phase // Kind == PhaseEntered @@ -119,12 +111,10 @@ type Event struct { // Note reports diagnostics, flattened to one line. // -// Flattened here rather than at each consumer because String promises one line -// of the activation log and the screen relies on it: the connect screen wraps -// and indents each line itself, and an embedded newline puts unindented text -// in the middle of the block and miscounts the rows the renderer has to -// repaint. Control plane errors arrive with the request ID on a second line, -// so this is the normal shape of a failure, not a malformed one. +// Flattened here because String promises one line and the connect screen wraps +// and indents each itself: an embedded newline lands unindented and miscounts +// the rows to repaint. Control plane errors carry their request ID on a second +// line, so this is the normal shape of a failure. func Note(text string) Event { return Event{Kind: Noted, Text: strings.Join(strings.Fields(text), " ")} } @@ -139,12 +129,10 @@ func Entered(p Phase) Event { return Event{Kind: PhaseEntered, Phase: p} } func Login(link LoginLink) Event { return Event{Kind: LoginRequired, Link: link} } // Droppable reports whether a consumer under backpressure may discard this -// event. Only diagnostics may go: losing a phase leaves a gap in the record of -// where the time went, and losing a login link leaves the user waiting on a -// browser tab that was never opened at a URL they were never shown. The old -// sink dropped whatever arrived on a full buffer, and under -debug the tsnet -// backend logger shared that buffer, so a burst of chatter could take the one -// line the user could not proceed without. +// event. Only diagnostics may go: 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.Kind == Noted } // String renders the event as one line of the activation log. diff --git a/internal/tui/browser.go b/internal/tui/browser.go index eb01b1d..37b7acb 100644 --- a/internal/tui/browser.go +++ b/internal/tui/browser.go @@ -9,10 +9,9 @@ import ( "github.com/aymanbagabas/go-osc52/v2" ) -// openURL asks the desktop to open a link. Start, not Run: the opener can -// block for as long as the browser it launches lives, and a headless box -// fails here by not having an opener at all, which Start already reports. -// Overridable in tests, which must not launch a browser. +// openURL asks the desktop to open a link. Start, not Run: the opener can block +// for as long as the browser it launches lives, and a headless box fails here +// by having no opener, which Start already reports. Overridable in tests. var openURL = func(url string) error { var cmd *exec.Cmd switch runtime.GOOS { @@ -33,16 +32,12 @@ var openURL = func(url string) error { } // copyToClipboard puts s on the clipboard of whatever terminal is displaying -// this TUI, over OSC 52. A local clipboard helper (xclip, pbcopy) would put it -// on the clipboard of the host aperture runs on, which over SSH is the wrong -// computer and the one case where the user most needs the link: the escape -// sequence travels back up the SSH session to the terminal the user is -// actually looking at. Overridable in tests, which have no terminal to write -// escape sequences at. +// this TUI, over OSC 52. A local helper (xclip, pbcopy) writes to the clipboard +// of the host aperture runs on, which over SSH is the wrong computer and the +// case where the user most needs the link. Overridable in tests. // -// Terminals that don't implement OSC 52 (or have it off, which some do by -// default for paste-injection reasons) drop the sequence silently, so a nil -// error here means sent, not pasted. +// 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, so the diff --git a/internal/tui/menus.go b/internal/tui/menus.go index 6a8ec20..5482e46 100644 --- a/internal/tui/menus.go +++ b/internal/tui/menus.go @@ -242,10 +242,9 @@ func (m *model) bridgeRowDescription(bridge config.Bridge) string { } // endpointsMenu is the connection picker: every Aperture this launcher can -// reach, one row each, whether it is a saved endpoint or a bridge that has no -// endpoint yet. Selecting a row opens its page rather than connecting straight -// away, so connecting, retargeting, switching tailnet and removing are all on -// screen instead of behind remembered keys. +// reach, one row each, saved endpoint or 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 { rows := m.connectionRows() items := make([]menu.MenuItem, 0, len(rows)+4) @@ -316,10 +315,9 @@ func (m *model) endpointsMenu() *menu.Menu { } } -// connectionRow is one line on the connection picker. A saved endpoint is one -// row; so is a bridge nothing points at yet, described by the endpoint it would -// create, because a bridge with no endpoint is still a connection the user has -// to be able to pick. That is how a second tailnet gets reached the first time. +// connectionRow is one line on the connection picker. A bridge nothing points +// at yet is a row too, described by the endpoint it would create: that is how a +// second tailnet gets reached the first time. type connectionRow struct { ep config.Endpoint bridge config.Bridge @@ -353,9 +351,8 @@ func (m *model) connectionRows() []connectionRow { } // connectionAtCursor resolves the picker row the cursor is on. The hidden "e" -// and "d" aliases act through it rather than indexing Settings.Endpoints, so -// they see the same rows the user does: a bridge with no endpoint is a row too, -// and indexing past the endpoints made those keys silently do nothing. +// 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() @@ -519,13 +516,10 @@ func (m *model) removeConnection(ep config.Endpoint) menu.Result { return menu.Result{Cmd: tea.ClearScreen} } -// dropOrphanBridge removes a bridge once its last endpoint is gone. -// -// The picker shows a bridge-backed endpoint as one row, but settings hold two -// objects, and removing only the endpoint left the bridge to be re-listed by -// connectionRows as a bare "Connect via" row at the bottom. To the user that -// read as the row moving instead of going, and clearing it took a second -// press. A bridge two endpoints reach through is not an orphan and stays. +// dropOrphanBridge removes a bridge once its last endpoint is gone. Settings +// hold two objects where the picker shows one row, so removing the endpoint +// alone left the bridge re-listed as a bare "Connect via" row: to the user the +// row moved instead of going. A bridge two endpoints reach through stays. func (m *model) dropOrphanBridge(id string) error { if id == "" { return nil @@ -684,10 +678,9 @@ func (m *model) setupGuideMenu() *menu.Menu { } // promptEditEndpoint edits ep's URL in place and connects to what the user -// typed, keeping whichever bridge ep is reached through. It is the only way to -// retarget an endpoint that connects successfully: the guessed default answers -// on any tailnet with a host called "ai", and a success shows neither the -// connect screen's inline override nor the setup guide's editor. +// typed, 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 { next, err := config.ParseEndpoint(v, ep.BridgeID) @@ -779,17 +772,16 @@ func (m *model) endpointBridgeMenu() *menu.Menu { } // 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 a -// different URL while the guess runs, so knowing it costs no waiting. +// 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.Endpoint{URL: config.DefaultLocation, BridgeID: bridge.ID}, false) } // connectVia connects to ep, saving it first when it is not in settings yet so // the failure screen has something to name, retry and edit. switchTailnet logs -// the bridge out on the way, which is what makes the next connection ask for a -// login instead of reusing the tailnet it is already on. +// the bridge out on the way, so the connection asks for a login. func (m *model) connectVia(ep config.Endpoint, switchTailnet bool) tea.Cmd { ephemeral := !m.endpointConfigured(ep) if ephemeral { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index cab97e8..f934f7a 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -57,10 +57,8 @@ const ( 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. start is the endpoint to open on, which is the saved -// active one unless the invocation named another. +// 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, bridgeManager *bridges.Manager, start config.Endpoint) tea.Model { return &model{ g: g, @@ -75,10 +73,9 @@ type model struct { g *config.Global buildVersion string bridgeManager *bridges.Manager - // start is where Init connects. It is not necessarily in settings yet: - // an endpoint named on the command line is saved on the way in and taken - // back out again if the attempt is abandoned, the same as one typed into - // the connection picker. + // 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 @@ -112,10 +109,9 @@ type model struct { connected bool } -// activation is the connection attempt currently on screen. It owns the -// attempt's identity and cancellation handle, and the URL the user can type -// over the top of it while it runs; the log tail it produces stays on the -// model because the failure screen still renders it after the attempt ends. +// activation is the connection attempt currently 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 attempts that cannot be interrupted (the post-launch // re-check), which is what makes Esc and the inline override inert there. @@ -155,12 +151,9 @@ func (a *activation) logLine(text string) bridgeLine { } // entered records a phase the attempt moved into, and reports whether it moved. -// -// The attempt owns this rule, not the bridge: phases reach it from the IPN bus -// watch and from the manager's own progress, and only something that sees both -// can keep them in order. A phase that does not move forward is dropped rather -// than shown, because a bus that re-notifies NeedsLogin after the link is on -// screen would otherwise walk the user backwards through their own wait. +// 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 @@ -183,11 +176,10 @@ type textField struct { err string } -// insert appends the text a key press carries. A typed character arrives as -// one rune and a pasted URL as many in a single message; both are text, and -// dropping the paste would leave the user retyping an endpoint by hand. Named -// keys and Alt chords carry no text and are ignored, as are control runes: -// matching on the key's String() would append "up" when someone presses Up. +// 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 @@ -215,11 +207,9 @@ func (f *textField) backspace() { func (f *textField) reset() { *f = textField{} } -// Init opens on m.start. connectVia rather than activateEndpointCmd because -// the endpoint may have come off the command line and so may not be in -// settings: connectVia writes it there for the failure screen to name and -// marks it ephemeral, and for the saved active endpoint, which is already -// configured, the two are the same call. +// Init opens on m.start. connectVia because an endpoint off the command line +// may not be in settings yet, and it writes it there for the failure screen to +// name; for the saved endpoint the two calls are the same. func (m *model) Init() tea.Cmd { return m.connectVia(m.start, false) } @@ -243,11 +233,9 @@ type endpointActivationResult struct { } // bridgeLine is one thing the attempt reported and how far into the attempt it -// was reported. The elapsed time is the reason this is a struct and not a -// string: a bridge that takes half a minute to come up spends that time in one -// of three places (the control plane answering with a login link, the user in -// the browser, the first dial), and an unstamped log cannot tell them apart. -// Three separate fixes have now been aimed at that wait without knowing which. +// was. The elapsed time is why 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 @@ -286,10 +274,9 @@ 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. Bringing a bridge up and then asking -// Aperture for its models can take tens of seconds during which nothing is -// logged, and a frozen screen is indistinguishable from a hang. +// 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 { @@ -348,10 +335,9 @@ func (m *model) activateEndpointCmd(ep config.Endpoint) tea.Cmd { } // activateEndpoint starts a cancellable attempt to connect to ep. ephemeral -// marks an endpoint this flow just wrote to settings on the user's behalf, so -// cancelling or overriding the attempt can take it back out again. -// switchTailnet logs the bridge out before connecting, so the attempt starts -// from a login prompt rather than the tailnet the node is already on. +// marks an endpoint this flow wrote to settings on the user's behalf, so +// cancelling can take it back out. switchTailnet logs the bridge out first, so +// the attempt starts from a login prompt rather than the tailnet it is on. func (m *model) activateEndpoint(ep config.Endpoint, ephemeral, switchTailnet bool) tea.Cmd { cmd := m.beginActivation(ep, ephemeral, switchTailnet) return tea.Batch(cmd, activationTick(m.act.id)) @@ -415,10 +401,9 @@ func (m *model) beginActivation(ep config.Endpoint, ephemeral, switchTailnet boo emit := bridgeLogSink(ctx, ch, act.started) activate := func() tea.Msg { defer cancel() - // Stamps the moment the user committed to this endpoint. Without it - // the first bridge line is the earliest thing in the log, and the gap - // in front of it reads as startup cost when it is usually someone - // reading the menu. + // Stamps 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", ep.URL, "bridge", bridge.ID, "switchTailnet", switchTailnet) // Inside the attempt, so it shares the attempt's cancellation and event // sink: the new login link is what the user needs on screen, and Esc @@ -432,11 +417,9 @@ func (m *model) beginActivation(ep config.Endpoint, ephemeral, switchTailnet boo if err != nil { return endpointActivationResult{id: act.id, endpoint: ep, host: ep.URL, err: err} } - // The request below is the longest silent stretch of the whole - // attempt: the bridge is up, so tsnet has stopped logging, and - // nothing else names the host being waited on. The phase comes from - // here and not from the bridge because asking an Aperture for its - // models is the attempt's own work, not the bridge's. + // 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. + // The phase is the attempt's own work, not the bridge's. emit(connection.Entered(connection.AskingForModels)) provs, err := fetchProvidersContext(ctx, localURL, bridgeProviderFetchTimeout) if err != nil { @@ -447,10 +430,9 @@ func (m *model) beginActivation(ep config.Endpoint, ephemeral, switchTailnet boo return tea.Batch(activate, waitBridgeLog(ctx, ch)) } -// recordBridgeTailnet saves the tailnet a bridge just connected through, so -// the connection picker can name it on a later run before the bridge is -// started again. A failed write is not worth interrupting a connection that -// worked: the picker falls back to saying the tailnet is not known yet. +// recordBridgeTailnet saves the tailnet a bridge connected through so the +// picker can name it before the bridge is started again. A failed write is not +// worth interrupting a connection that worked. func (m *model) recordBridgeTailnet(ep config.Endpoint) { if ep.BridgeID == "" { return @@ -570,14 +552,10 @@ func (m *model) overrideActivationURL(value string) (tea.Model, tea.Cmd) { } // bridgeLogSink is where the attempt's events land 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 own cancellation, because the events that -// are not diagnostics are the ones the user cannot proceed without: this sink -// used to drop whatever arrived on a full buffer, and under -debug the tsnet -// backend logger shares it, so a burst of chatter could take the login link -// with it and strand the attempt on a link nobody ever saw. +// 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.Kind == connection.Noted { @@ -746,10 +724,8 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case browserOpenMsg: // Only the failure is worth a line: a browser that opened is on the - // user's screen, and the link itself is already at the foot of this - // one. Over SSH this is the common case, not an edge case: the remote - // box has an opener that exits "no method available" a moment after it - // starts, or none at all. + // 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 } @@ -1109,16 +1085,11 @@ const ( // authFooter renders the login link pinned to the foot of the connect screen. // -// The link owns its lines outright, with no prose beside it and no indent -// under it. Bubble Tea's renderer truncates any line wider than the terminal, -// so a long URL has to wrap, and anything sharing those lines lands in the -// selection when the user drags across them. Split across bare lines it still -// pastes: browsers strip the newline out of a URL, they do not strip an -// indent or a trailing label. -// -// Every line carries the same OSC 8 hyperlink, id-tagged so terminals rejoin -// the halves into one target. That is what keeps ctrl-click working on a URL -// the screen had to break in two. +// The link owns its lines outright. Bubble Tea truncates any line wider than +// the terminal, so a long URL has to wrap, and prose sharing those lines lands +// in the selection when the user drags across them; a browser strips a newline +// out of a URL but not an indent or a label. Every line carries the same OSC 8 +// hyperlink, id-tagged so terminals rejoin the halves and ctrl-click survives. func (m *model) authFooter() string { act := m.act if act == nil || act.authURL == "" { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 30fd17d..58d1ca8 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -830,10 +830,8 @@ func TestSetupGuideEditPrefillsFailedURL(t *testing.T) { } // A guessed URL that answers is not necessarily the Aperture the user wanted: -// on a tailnet that already has a host called "ai", both a direct connection -// and a new bridge land there and succeed, and nothing fails to open the setup -// guide's editor. The endpoints menu has to be able to retarget a working -// endpoint, or that first success is the only one reachable. +// 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) @@ -1719,10 +1717,9 @@ func TestBridgeLogSinkNeverDropsTheLoginLink(t *testing.T) { } // TestRemoveConnectionRowTakesTheBridgeWithIt covers what one press of "d" is -// supposed to mean. A bridge-backed endpoint is one row on the picker, but it -// is two objects in settings, and removing only the endpoint left the bridge -// behind to be re-listed as a bare "Connect via" row at the bottom. The row -// read as having moved rather than gone, and clearing it took a second press. +// 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{ From 11c27da7ffaad822a02847680ec7022e8839eef5 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Fri, 18 Sep 2026 16:29:16 +0000 Subject: [PATCH 37/69] docs: keep the rules this repo can act on The appended block came from a cross-project rule set and a third-party "lazy senior developer" persona. Two problems with it here. Most of it has nothing to act on: this is a single-binary Go CLI with no jobs, no schedules, no relational store and no GPUs, so event-driven and durable-by-default, the JSON-in-Postgres rule and the multi-card parallelism rule are instructions about infrastructure that does not exist. Rules that never fire train readers to skim the ones that do. What survives is rewritten against what this repo has: tea.Cmd instead of "async", the bridge state directory instead of "secrets", make check instead of "the project gate". The persona text was also copied verbatim from outside with no license attached, which is not something to carry in a Tailscale repo. Its substance is kept and reworded; the copied prose is gone, along with the web, hardware and Python examples that came with it. Revisit the cut rules if any of that infrastructure shows up. --- AGENTS.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index f2508db..aa90aa2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,3 +66,60 @@ Current: [Connection](docs/adr/0001-connection-bounded-context.md). - Commit prefixes match the package touched: `tui:`, `bridges:`, `config:`. - `make test` is the gate. + +## 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. From 608b85cf9601ed3e5c4e2e525bbd3a24e6423ddc Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Fri, 18 Sep 2026 20:23:36 +0000 Subject: [PATCH 38/69] adversarial review fixes --- docs/specs/bridge-resource-lifecycle.md | 20 ++- docs/specs/connection-context-map.md | 14 +- docs/specs/connection-contracts.md | 58 ++++++- docs/specs/connection-domain-model.md | 64 ++++++- go.mod | 2 +- internal/bridges/manager.go | 218 ++++++++++++------------ internal/bridges/manager_test.go | 18 +- internal/config/global.go | 9 +- internal/config/state_test.go | 2 +- internal/connection/event.go | 8 +- internal/connection/event_test.go | 17 ++ internal/tui/browser.go | 25 +-- internal/tui/menus.go | 28 +-- internal/tui/tui.go | 46 ++++- internal/tui/tui_test.go | 14 +- 15 files changed, 358 insertions(+), 185 deletions(-) diff --git a/docs/specs/bridge-resource-lifecycle.md b/docs/specs/bridge-resource-lifecycle.md index 21ea99d..1bb9a83 100644 --- a/docs/specs/bridge-resource-lifecycle.md +++ b/docs/specs/bridge-resource-lifecycle.md @@ -56,15 +56,17 @@ longer name. ## Constraints -**Logout needs a running node.** `Logout` (`manager.go:335`) goes through -`server.LocalClient()`, so the credentials live behind the in-process LocalAPI -and a closed node keeps them. - -**Starting a node that never registered performs a full interactive login** -(`manager.go:471`). Destroying through `runningNode` unconditionally would -create a device in order to delete it, and would ask the user to authorize a -machine they just asked to destroy. `Bridge.Tailnet != ""` is the persisted -"has ever joined". +**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.** A future destroy +operation must not require an interactive login to remove a bridge. +`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. **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 diff --git a/docs/specs/connection-context-map.md b/docs/specs/connection-context-map.md index 53fe271..9cd4c9d 100644 --- a/docs/specs/connection-context-map.md +++ b/docs/specs/connection-context-map.md @@ -1,9 +1,15 @@ # Connection context map -Scope: everything between the user picking an endpoint and a client having -somewhere to send requests. Written before the refactor that replaces the -string log channel between `internal/bridges` and `internal/tui` with typed -events. +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 diff --git a/docs/specs/connection-contracts.md b/docs/specs/connection-contracts.md index b7aa4c8..df28ea5 100644 --- a/docs/specs/connection-contracts.md +++ b/docs/specs/connection-contracts.md @@ -1,7 +1,61 @@ # Connection contracts -Pass 1. Contracts for the objects in -[the domain model](connection-domain-model.md). +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 `Manager.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 optional original, +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. + +`Manager.Activate` and `Manager.SwitchTailnet` keep their public signatures. +Both acquire a cancellable turn on the Machine identified by Bridge ID. No +proxy may be created before `Up` succeeds, and no new node may use its state +directory before the prior node finishes closing. `SwitchTailnet` calls the +node's `Logout`, whose LocalAPI initialization does not wait for `Running`. +`Close` cancels operations, waits for cleanup and permanently closes the manager. + +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: diff --git a/docs/specs/connection-domain-model.md b/docs/specs/connection-domain-model.md index 5a254d8..ac03b3a 100644 --- a/docs/specs/connection-domain-model.md +++ b/docs/specs/connection-domain-model.md @@ -1,8 +1,70 @@ # Connection domain model -Objects in the Connection context. Language is fixed by +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. + +Manager's shutdown is one session-lifetime operation. Its transient +`shutdown func() error` uses the standard library's once-result primitive to +join concurrent callers and retain the same error. The first caller stops new +acquisitions and cancels operations; 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. + +`activation` remains the ConnectionAttempt entity. Its fields are `id int`, +`endpoint config.Endpoint`, `label string`, `started time.Time`, +`cancel context.CancelFunc`, `ephemeral bool`, `replaces *config.Endpoint`, +`logCh chan bridgeLine`, `logCtx context.Context`, `phase connection.Phase`, +`phaseSet bool`, `authURL string`, `copied bool`, and `override textField`. +`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. + +`Machine` is an entity, identified by the Bridge ID key in `Manager.nodes`. +It owns `node tailnetNode`, `proxies map[string]*proxyRuntime`, `ev *liveEvents`, +`turn chan struct{}`, and `cancel context.CancelFunc`. The first three are the +existing runtime; `turn` grants one operation at a time and `cancel` allows +manager shutdown to interrupt that operation. These adapter fields remain in +`internal/bridges`; no vendor type enters a public signature. + +States are idle (no node), starting, open, and closing. Activation holds the +Machine's turn through startup and proxy creation. A failed startup closes the +node before releasing the turn. Logout initializes the LocalAPI without waiting +for authorization, then closes the node and all proxies. The Machine returns +to idle and may create a new node on the next activation. Manager shutdown +cancels current operations, waits for their turns, closes Machines, and rejects +new operations. A waiting operation can cancel without affecting the owner. + +Before dispatching a tailnet switch, the TUI marks the active destination +unverified if it shares that Bridge ID. 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 diff --git a/go.mod b/go.mod index c7b7def..3a697de 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( 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 ) @@ -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/manager.go b/internal/bridges/manager.go index c73e66d..62ad3c0 100644 --- a/internal/bridges/manager.go +++ b/internal/bridges/manager.go @@ -11,6 +11,7 @@ import ( "net/http/httputil" "net/netip" "net/url" + "regexp" "strings" "sync" "time" @@ -33,10 +34,11 @@ type Manager struct { // node's peer map before giving up and resolving it the way tsnet would. peerWait time.Duration peerWaitInterval time.Duration - nodes map[string]*nodeRuntime + nodes map[string]*Machine // tailnets is the network each running node logged in to, keyed by bridge // ID. Read back by the TUI to label a bridge with the tailnet it reaches. tailnets map[string]string + shutdown func() error newNode func(bridge config.Bridge, stateDir string, userLogf, debugLogf func(string, ...any)) tailnetNode } @@ -46,15 +48,6 @@ const ( bridgePeerWaitInterval = 250 * time.Millisecond ) -type nodeRuntime struct { - node tailnetNode - proxies map[string]*proxyRuntime - // ev is where this node and its proxies report, and it is an indirection - // rather than a captured sink because they outlive the connection that - // created them. See liveEvents. - ev *liveEvents -} - // liveEvents points a node's long-lived reporting at whichever connection is // using it now. Nodes and proxies outlive the connection that built them, and // closures that captured that connection's sink went on writing to a channel @@ -122,12 +115,20 @@ func logEvent(e connection.Event) { case connection.PhaseEntered: slog.Info("bridge phase", "phase", e.Phase) case connection.LoginRequired: - slog.Info("bridge needs login", "url", e.Link.String()) + slog.Info("bridge needs login") default: - slog.Debug("bridge note", "text", e.Text) + slog.Debug("bridge note", "text", redactDiagnostic(e.Text)) } } +// Backend diagnostics can repeat authorization capabilities. Keep the link in +// the interactive event only; even debug logs are routinely 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)) } @@ -167,7 +168,7 @@ func (n *tsnetNode) WatchLogin(ctx context.Context, ev events) { // Logged as well as noted: a dead watch leaves the attempt on its // last phase forever, which on screen is indistinguishable from a // control plane that is simply slow. - slog.Error("bridge login watch ended", "err", err) + slog.Error("bridge login watch ended", "err", redactDiagnostic(err.Error())) ev.note("Could not watch the bridge's login state: " + err.Error()) } } @@ -259,10 +260,8 @@ func (r *loginReporter) notify(n *ipn.Notify) { if n.BrowseToURL != nil { link, err := connection.ParseLoginLink(*n.BrowseToURL) if err != nil { - // The URL itself, because "the control plane sent one and we threw - // it away" and "the control plane never sent one" are the same - // silence on screen and want opposite fixes. - slog.Error("unusable login link from the control plane", "url", *n.BrowseToURL, "err", err) + // Record the rejection reason, never the authorization capability. + slog.Error("unusable login link from the control plane", "err", err) // Not fatal to the login: tsnet keeps printing its own copy, and // the user can still finish by hand. Worth saying, because the // browser is not going to open. @@ -295,13 +294,12 @@ func (r *loginReporter) health(state *health.State) { slog.Info("bridge login recovered") return } - slog.Error("bridge login is failing", "text", warning.Text) + slog.Error("bridge login is failing", "text", redactDiagnostic(warning.Text)) r.ev.note("The tailnet will not log this bridge in: " + warning.Text) } -// Logout drops the node's tailnet credentials. The node must be running: the -// login state lives behind its in-process LocalAPI, so logging out is how the -// node leaves the tailnet it is on rather than reusing it on the next start. +// 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 { @@ -321,7 +319,7 @@ func NewManager(debug bool) *Manager { debug: debug, peerWait: bridgePeerWaitWindow, peerWaitInterval: bridgePeerWaitInterval, - nodes: make(map[string]*nodeRuntime), + nodes: make(map[string]*Machine), tailnets: make(map[string]string), } m.newNode = func(bridge config.Bridge, stateDir string, userLogf, debugLogf func(string, ...any)) tailnetNode { @@ -335,6 +333,7 @@ func NewManager(debug bool) *Manager { } return &tsnetNode{server: s} } + m.shutdown = sync.OnceValue(m.close) return m } @@ -353,7 +352,12 @@ func (m *Manager) Activate(ctx context.Context, bridge config.Bridge, remoteURL return "", err } - rt, status, err := m.runningNode(ctx, bridge, ev) + ctx, rt, err := m.acquire(ctx, bridge.ID) + if err != nil { + return "", err + } + defer m.release(rt) + status, err := m.runningNode(ctx, bridge, rt, ev) if err != nil { return "", err } @@ -373,10 +377,8 @@ func (m *Manager) Activate(ctx context.Context, bridge config.Bridge, remoteURL logBridgeStatus(ev, status, target) } - m.mu.Lock() - defer m.mu.Unlock() - if m.nodes[bridge.ID] != rt { - return "", fmt.Errorf("bridge stopped before activation completed") + if err := ctx.Err(); err != nil { + return "", err } key := target.String() if proxy := rt.proxies[key]; proxy != nil { @@ -392,43 +394,44 @@ func (m *Manager) Activate(ctx context.Context, bridge config.Bridge, remoteURL return proxy.localURL, nil } -// runningNode returns the bridge's node, starting it if this is the first use. -// status is the login status Up reported, and is nil for a node that was -// already running. Callers hold no lock. -func (m *Manager) runningNode(ctx context.Context, bridge config.Bridge, ev events) (*nodeRuntime, *ipnstate.Status, error) { - m.mu.Lock() - rt := m.nodes[bridge.ID] - if rt != nil { - m.mu.Unlock() - // The node and its proxies were built by an earlier connection whose - // sink is long gone. Point them at this one before returning. - rt.ev.use(ev) - return rt, nil, nil +// initNode constructs a node without waiting for login. The Machine's turn is +// held by the caller; only Activate follows initialization with Up. +func (m *Manager) initNode(bridge config.Bridge, rt *Machine, ev events) error { + rt.ev.use(ev) + if rt.node != nil { + return nil } stateDir, err := config.BridgeStateDir(bridge.ID) if err != nil { - m.mu.Unlock() - return nil, nil, err + 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. A no-op rather than nil, // because tsnet falls back to log.Printf, which writes over the TUI. - live := &liveEvents{} - live.use(ev) logNotes := func(format string, args ...any) { if m.debug { - events(live.emit).notef(format, args...) + events(rt.ev.emit).notef(format, args...) } } userLogf, debugLogf := logNotes, logNotes - rt = &nodeRuntime{ - node: m.newNode(bridge, stateDir, userLogf, debugLogf), - proxies: make(map[string]*proxyRuntime), - ev: live, + rt.node = m.newNode(bridge, stateDir, userLogf, debugLogf) + if rt.node == nil { + return fmt.Errorf("bridge node is not configured") + } + return nil +} + +// runningNode waits for an uncached node to become usable while holding its +// Machine's turn. A failed Up finishes cleanup before another attempt enters. +func (m *Manager) runningNode(ctx context.Context, bridge config.Bridge, rt *Machine, ev events) (*ipnstate.Status, error) { + if rt.node != nil { + rt.ev.use(ev) + return nil, nil + } + if err := m.initNode(bridge, rt, ev); err != nil { + return nil, err } - m.nodes[bridge.ID] = rt - m.mu.Unlock() ev.enter(connection.StartingMachine) @@ -436,22 +439,22 @@ func (m *Manager) runningNode(ctx context.Context, bridge config.Bridge, ev even // logged in means blocking until the user visits a link nothing has shown // them yet. The watch runs alongside it and ends with it. watchCtx, stopWatch := context.WithCancel(ctx) - defer stopWatch() - go rt.node.WatchLogin(watchCtx, ev) + watchDone := make(chan struct{}) + go func() { + defer close(watchDone) + rt.node.WatchLogin(watchCtx, ev) + }() // Timed because this is the wait every "it just sat there" report is // about, and the number is the difference between a slow control plane and // a login link the user never saw. start := time.Now() status, err := rt.node.Up(ctx) + stopWatch() + <-watchDone if err != nil { - slog.Error("bridge node did not come up", "bridge", bridge.ID, "after", time.Since(start), "err", err) - m.mu.Lock() - if m.nodes[bridge.ID] == rt { - delete(m.nodes, bridge.ID) - } - m.mu.Unlock() - return nil, nil, errors.Join(err, rt.node.Close()) + slog.Error("bridge node did not come up", "bridge", bridge.ID, "after", time.Since(start), "err", redactDiagnostic(err.Error())) + return nil, errors.Join(err, rt.close()) } slog.Info("bridge node up", "bridge", bridge.ID, "after", time.Since(start)) @@ -466,7 +469,7 @@ func (m *Manager) runningNode(ctx context.Context, bridge config.Bridge, ev even m.tailnets[bridge.ID] = status.CurrentTailnet.Name m.mu.Unlock() } - return rt, status, nil + return status, nil } // Tailnet returns the network the bridge's node logged in to during this @@ -483,9 +486,8 @@ func (m *Manager) Tailnet(bridgeID string) string { // SwitchTailnet logs the bridge out of the tailnet it is on and discards its // node, so the next Activate asks for a new login. // -// The node has to be running to be logged out: its credentials live behind the -// in-process LocalAPI. A node not started this session is brought up on the old -// tailnet first, which is what leaves the device removed rather than orphaned. +// Logout only needs an initialized LocalAPI. Waiting for Running first would +// demand authorization of an expired or unapproved identity just to leave it. func (m *Manager) SwitchTailnet(ctx context.Context, bridge config.Bridge, emit func(connection.Event)) error { if m == nil { return fmt.Errorf("bridge manager is not configured") @@ -494,56 +496,59 @@ func (m *Manager) SwitchTailnet(ctx context.Context, bridge config.Bridge, emit return err } ev := sink(emit) - rt, _, err := m.runningNode(ctx, bridge, ev) + ctx, rt, err := m.acquire(ctx, bridge.ID) if err != nil { return err } + defer m.release(rt) + if err := m.initNode(bridge, rt, ev); err != nil { + return err + } ev.note("Logging bridge " + bridge.Name + " out of its tailnet ...") logoutErr := rt.node.Logout(ctx) - // Under the lock, as in Close: an Activate that took rt before the delete - // may still be adding a proxy to it. + closeErr := rt.close() m.mu.Lock() - if m.nodes[bridge.ID] == rt { - delete(m.nodes, bridge.ID) - } delete(m.tailnets, bridge.ID) - errs := []error{logoutErr} - for key, proxy := range rt.proxies { - errs = append(errs, closeProxy(proxy)) - delete(rt.proxies, key) - } - errs = append(errs, rt.node.Close()) m.mu.Unlock() - if err := errors.Join(errs...); err != nil { + 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 } -// Close shuts down all active reverse proxies and tsnet nodes. +// Close shuts down all active reverse proxies and tsnet nodes. Concurrent and +// subsequent callers wait for the same cleanup and receive the same result. func (m *Manager) Close() error { - if m == nil { + if m == nil || m.shutdown == nil { return nil } + return m.shutdown() +} + +func (m *Manager) close() error { m.mu.Lock() - defer m.mu.Unlock() + nodes := m.nodes + m.nodes = nil + for _, rt := range nodes { + if rt.cancel != nil { + rt.cancel() + } + } + m.mu.Unlock() var errs []error - for id, rt := range m.nodes { - for key, proxy := range rt.proxies { - errs = append(errs, closeProxy(proxy)) - delete(rt.proxies, key) - } - if err := rt.node.Close(); err != nil { - errs = append(errs, err) - } - delete(m.nodes, id) - delete(m.tailnets, id) + for _, rt := range nodes { + rt.turn <- struct{}{} + errs = append(errs, rt.close()) + <-rt.turn } + m.mu.Lock() + clear(m.tailnets) + m.mu.Unlock() return errors.Join(errs...) } @@ -598,7 +603,7 @@ func parseTarget(raw string) (*url.URL, error) { // startProxy builds the reverse proxy for one target on rt's node. It reports // through rt because the proxy is cached and will still be serving long after // the connection that asked for it has gone. -func (m *Manager) startProxy(rt *nodeRuntime, target *url.URL) (*proxyRuntime, error) { +func (m *Manager) startProxy(rt *Machine, target *url.URL) (*proxyRuntime, error) { node, ev := rt.node, events(rt.ev.emit) debug := m.debug ln, err := net.Listen("tcp", "127.0.0.1:0") @@ -664,7 +669,8 @@ type bridgeDialFunc func(context.Context, string, string) (net.Conn, error) // Handing the name to tsnet is what made a first connection hang for 30s: until // the netmap lands its resolver falls through to the host resolver, which on a // machine already on a tailnet answers with a same-named node on the wrong one. -// Resolving through the node cannot leave the bridge's tailnet. +// Short aliases use this node's current tailnet suffix; a shared peer requires +// its full name. func dialViaNode( ctx context.Context, node tailnetNode, @@ -739,16 +745,26 @@ func waitForPeerAddr( } } -// peerAddr returns the tailnet address the node has for host, matching either a -// peer's full MagicDNS name or its first label, the short form endpoint URLs -// usually carry. +// peerAddr resolves short names only within the current tailnet's MagicDNS +// suffix. A shared-in peer can have the same first label but belongs to another +// tailnet; reaching it requires its explicit 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 || !magicDNSNameMatches(peer.DNSName, want) { + if peer == nil || strings.ToLower(strings.TrimSuffix(peer.DNSName, ".")) != want { continue } if ip, ok := preferIPv4(peer.TailscaleIPs); ok { @@ -758,18 +774,6 @@ func peerAddr(status *ipnstate.Status, host string) (netip.Addr, bool) { return netip.Addr{}, false } -func magicDNSNameMatches(dnsName, host string) bool { - name := strings.ToLower(strings.TrimSuffix(dnsName, ".")) - if name == "" { - return false - } - if name == host { - return true - } - label, _, _ := strings.Cut(name, ".") - return label == host -} - func preferIPv4(addrs []netip.Addr) (netip.Addr, bool) { var fallback netip.Addr for _, addr := range addrs { diff --git a/internal/bridges/manager_test.go b/internal/bridges/manager_test.go index 0c6e391..9eab97f 100644 --- a/internal/bridges/manager_test.go +++ b/internal/bridges/manager_test.go @@ -107,13 +107,15 @@ func (n *fakeNode) dialedAddrs() []string { // 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")}, + 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}, }, @@ -950,7 +952,7 @@ func TestSinkLogsEveryEvent(t *testing.T) { logged := buf.String() for _, want := range []string{ connection.StartingMachine.String(), - link.String(), + "bridge needs login", "dialing", connection.FindingEndpoint.String(), } { @@ -958,6 +960,9 @@ func TestSinkLogsEveryEvent(t *testing.T) { 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 @@ -987,8 +992,11 @@ func TestNotifyLogsWhatTheScreenCollapses(t *testing.T) { } // 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, "http://evil.example.com/a/x") { - t.Errorf("run log = %q, want the link that was thrown away", logged) + 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") } } diff --git a/internal/config/global.go b/internal/config/global.go index 1df6e00..591c3ac 100644 --- a/internal/config/global.go +++ b/internal/config/global.go @@ -71,11 +71,12 @@ func (g *Global) ActiveEndpoint() Endpoint { // 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 { +// persists. replacing is the original endpoint of a verified URL edit, removed +// in the same write. Bridge activation later rewrites ApertureHost to localhost. +func (g *Global) SetActiveEndpoint(ep Endpoint, replacing *Endpoint) error { eps := []Endpoint{ep} for _, existing := range g.Settings.Endpoints { - if !sameEndpoint(existing, ep) { + if !sameEndpoint(existing, ep) && (replacing == nil || !sameEndpoint(existing, *replacing)) { eps = append(eps, existing) } } @@ -92,7 +93,7 @@ func (g *Global) SetActiveEndpoint(ep Endpoint) error { // SetApertureHost rotates the direct URL to the front of the endpoint list // (adding it if missing), updates ApertureHost, and persists. func (g *Global) SetApertureHost(url string) error { - return g.SetActiveEndpoint(Endpoint{URL: url}) + return g.SetActiveEndpoint(Endpoint{URL: url}, nil) } // UpsertEndpoint appends the endpoint to the endpoint list if not already present, diff --git a/internal/config/state_test.go b/internal/config/state_test.go index 0a1dbde..588b8df 100644 --- a/internal/config/state_test.go +++ b/internal/config/state_test.go @@ -216,7 +216,7 @@ func TestGlobal_SetActiveEndpoint_DistinguishesBridge(t *testing.T) { }, }, } - if err := g.SetActiveEndpoint(config.Endpoint{URL: "http://ai", BridgeID: "bridge-abcdef"}); err != nil { + if err := g.SetActiveEndpoint(config.Endpoint{URL: "http://ai", BridgeID: "bridge-abcdef"}, nil); err != nil { t.Fatal(err) } if g.Settings.Endpoints[0].BridgeID != "bridge-abcdef" { diff --git a/internal/connection/event.go b/internal/connection/event.go index 03beda7..dd7881b 100644 --- a/internal/connection/event.go +++ b/internal/connection/event.go @@ -71,17 +71,17 @@ func ParseLoginLink(raw string) (LoginLink, error) { return LoginLink{}, fmt.Errorf("login link is empty") } if strings.ContainsAny(raw, " \t\r\n") { - return LoginLink{}, fmt.Errorf("login link contains whitespace: %q", raw) + 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: %w", err) + return LoginLink{}, fmt.Errorf("login link is not a URL") } if parsed.Scheme != "https" { - return LoginLink{}, fmt.Errorf("login link is not https: %q", raw) + return LoginLink{}, fmt.Errorf("login link is not https") } if parsed.Host == "" { - return LoginLink{}, fmt.Errorf("login link has no host: %q", raw) + return LoginLink{}, fmt.Errorf("login link has no host") } return LoginLink{url: raw}, nil } diff --git a/internal/connection/event_test.go b/internal/connection/event_test.go index b13c6ac..37633bb 100644 --- a/internal/connection/event_test.go +++ b/internal/connection/event_test.go @@ -38,6 +38,23 @@ func TestParseLoginLink(t *testing.T) { } } +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. diff --git a/internal/tui/browser.go b/internal/tui/browser.go index 37b7acb..c28e829 100644 --- a/internal/tui/browser.go +++ b/internal/tui/browser.go @@ -2,34 +2,13 @@ package tui import ( "os" - "os/exec" - "runtime" "strings" "github.com/aymanbagabas/go-osc52/v2" ) -// openURL asks the desktop to open a link. Start, not Run: the opener can block -// for as long as the browser it launches lives, and a headless box fails here -// by having no opener, which Start already reports. Overridable in tests. -var openURL = func(url string) error { - var cmd *exec.Cmd - switch runtime.GOOS { - case "darwin": - cmd = exec.Command("open", url) - case "windows": - cmd = exec.Command("cmd", "/c", "start", "", url) - default: - cmd = exec.Command("xdg-open", url) - } - // Anything the opener prints would land in the middle of the TUI. - cmd.Stdout, cmd.Stderr = nil, nil - if err := cmd.Start(); err != nil { - return err - } - go cmd.Wait() // reap it; the opener outlives this call - return nil -} +// openURL asks the desktop to open a link. Overridable in tests. +var openURL = platformOpenURL // copyToClipboard puts s on the clipboard of whatever terminal is displaying // this TUI, over OSC 52. A local helper (xclip, pbcopy) writes to the clipboard diff --git a/internal/tui/menus.go b/internal/tui/menus.go index 5482e46..f8080f9 100644 --- a/internal/tui/menus.go +++ b/internal/tui/menus.go @@ -48,6 +48,10 @@ func (m *model) rootMenu() *menu.Menu { if it.Action == nil { continue } + if !m.connected { + it.Disabled = true + it.Action = nil + } items = append(items, it) } @@ -94,7 +98,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) { @@ -677,25 +681,25 @@ func (m *model) setupGuideMenu() *menu.Menu { } } -// promptEditEndpoint edits ep's URL in place and connects to what the user -// typed, 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. +// 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 { next, err := config.ParseEndpoint(v, ep.BridgeID) if err != nil { return simpleErrorCmd(err) } - if err := m.g.ReplaceEndpoint(ep, next); err != nil { - return simpleErrorCmd(err) + if m.act != nil && sameEndpoint(m.act.endpoint, ep) && m.act.replaces != nil { + return m.retargetActivation(next) } - // Follow the rename, so a failure screen already showing ep keeps - // naming the endpoint the user is now trying. - if m.failedEndpoint != nil && sameEndpoint(*m.failedEndpoint, ep) { - m.failedEndpoint = &next + seq := m.activationSeq + cmd := m.connectVia(next, false) + if m.activationSeq != seq { + m.act.replaces = &ep } - return m.activateEndpointCmd(next) + return cmd }) } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index f934f7a..63494de 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -125,8 +125,10 @@ type activation struct { // so abandoning or overriding the attempt takes it back out instead of // leaving an endpoint nobody chose. ephemeral bool - logCh chan bridgeLine - logCtx context.Context + // replaces is removed only when this edited endpoint verifies successfully. + replaces *config.Endpoint + logCh chan bridgeLine + logCtx context.Context // phase is the wait this attempt is in, and phaseSet distinguishes "not // started" from StartingMachine, which is the zero value. phase connection.Phase @@ -331,7 +333,14 @@ func fetchProvidersContext(ctx context.Context, host string, timeout time.Durati } func (m *model) activateEndpointCmd(ep config.Endpoint) tea.Cmd { - return m.activateEndpoint(ep, false, false) + var replaces *config.Endpoint + var ephemeral bool + if m.act != nil && sameEndpoint(m.act.endpoint, ep) { + replaces, ephemeral = m.act.replaces, m.act.ephemeral + } + cmd := m.activateEndpoint(ep, ephemeral, false) + m.act.replaces = replaces + return cmd } // activateEndpoint starts a cancellable attempt to connect to ep. ephemeral @@ -348,6 +357,11 @@ func (m *model) beginActivation(ep config.Endpoint, ephemeral, switchTailnet boo m.step = stepPreflight m.preflightErr = "" m.bridgeLogs = nil + if switchTailnet && ep.BridgeID != "" && ep.BridgeID == m.g.ActiveEndpoint().BridgeID { + // Cancellation cannot prove that Logout did not run. Any endpoint + // using this bridge must verify a new gateway before launching again. + m.connected = false + } ctx, cancel := context.WithCancel(context.Background()) m.activationSeq++ @@ -503,7 +517,7 @@ func (m *model) cancelActivation() (tea.Model, tea.Cmd) { } m.act = nil m.step = stepMenu - if len(m.stack) == 0 { + if len(m.stack) == 0 || !m.connected && m.g.ActiveEndpoint().BridgeID != "" { m.preflightErr = "connection cancelled" m.forcedToEndpoint = true m.failedEndpoint = &endpoint @@ -530,6 +544,16 @@ func (m *model) overrideActivationURL(value string) (tea.Model, tea.Cmd) { 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 sameEndpoint(next, act.endpoint) { + return m.activateEndpointCmd(next) + } m.stopActivation() ephemeral := !m.endpointConfigured(next) @@ -539,16 +563,18 @@ func (m *model) overrideActivationURL(value string) (tea.Model, tea.Cmd) { if err := m.g.ReplaceEndpoint(act.endpoint, next); err != nil { m.errMsg = err.Error() m.step = stepError - return m, nil + return nil } } else if ephemeral { if err := m.g.UpsertEndpoint(next); err != nil { m.errMsg = err.Error() m.step = stepError - return m, nil + return nil } } - return m, m.activateEndpoint(next, ephemeral, false) + cmd := m.activateEndpoint(next, ephemeral, false) + m.act.replaces = act.replaces + return cmd } // bridgeLogSink is where the attempt's events land on their way to the update @@ -670,8 +696,8 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.resetStack(m.setupGuideMenu()) return m, nil } - if !sameEndpoint(m.g.ActiveEndpoint(), msg.endpoint) { - if err := m.g.SetActiveEndpoint(msg.endpoint); err != nil { + if !sameEndpoint(m.g.ActiveEndpoint(), msg.endpoint) || m.act.replaces != nil { + if err := m.g.SetActiveEndpoint(msg.endpoint, m.act.replaces); err != nil { m.preflightErr = "could not save active endpoint: " + err.Error() m.forcedToEndpoint = true failed := msg.endpoint @@ -681,6 +707,8 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } } + m.act.replaces = nil + m.act.ephemeral = false m.recordBridgeTailnet(msg.endpoint) m.g.ApertureHost = msg.host m.g.Providers = msg.providers diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 58d1ca8..b0653d8 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -95,6 +95,7 @@ func TestRootMenu_QuickSelectPrepended(t *testing.T) { LastEndpointURL: "http://ai", }, }} + m.connected = true root := m.rootMenu() // First visible item should be the quick-select row with Digit=0. @@ -134,6 +135,7 @@ func TestRootMenu_NoQuickSelectWhenReplayNil(t *testing.T) { LastEndpointURL: "http://ai", }, }} + m.connected = true root := m.rootMenu() for _, it := range root.Items { if !it.Hidden && strings.Contains(it.Label, "Quick select") { @@ -155,6 +157,7 @@ func TestRootMenu_NoQuickSelectWithoutRecordedEndpoint(t *testing.T) { Settings: config.Settings{Endpoints: []config.Endpoint{{URL: "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) @@ -188,6 +191,7 @@ func TestQuickSelectRequiresRecordedEndpointToBeActive(t *testing.T) { }, }, } + 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) @@ -217,7 +221,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). @@ -870,12 +874,16 @@ func TestEndpointsMenu_EditRetargetsWorkingEndpoint(t *testing.T) { m.inputOnSave("http://aperture.example.ts.net") want := config.Endpoint{URL: "http://aperture.example.ts.net", BridgeID: bridge.ID} - if got := m.g.Settings.Endpoints; len(got) != 1 || !sameEndpoint(got[0], want) { - t.Fatalf("endpoints = %+v, want the row rewritten to %+v", got, want) + if got := m.g.ActiveEndpoint(); got != connected { + t.Fatalf("active endpoint = %+v, want %+v until verification", got, connected) } if m.act == nil || !sameEndpoint(m.act.endpoint, want) { t.Fatalf("activation = %+v, want a connection to %+v", m.act, want) } + m.Update(endpointActivationResult{id: m.act.id, endpoint: want, host: "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 From c56b2d9067f1262e9fe55a4d04f45e728e9b2b2b Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Fri, 18 Sep 2026 20:35:37 +0000 Subject: [PATCH 39/69] bridges,tui: land the files 608b85c left out of the tree 608b85c committed the callers without the new files, so HEAD does not build: manager.go acquires a turn on a Machine that is not there, and browser.go calls a platformOpenURL with no implementation on any platform. machine.go gives one bridge one cancellable turn at a time, so an inline override cannot dial through a node the cancelled attempt is still closing. browser_windows.go opens the login link with ShellExecuteW, because cmd.exe reads the & in a control-plane URL as a command separator. Reasoning in ADR 0003 and ADR 0004; the tests here are what holds both. --- .../adr/0003-preserve-verified-connections.md | 45 ++++ docs/adr/0004-contain-connection-authority.md | 50 ++++ internal/bridges/lifecycle_test.go | 228 ++++++++++++++++++ internal/bridges/machine.go | 82 +++++++ internal/bridges/security_test.go | 175 ++++++++++++++ internal/tui/browser_nonwindows.go | 23 ++ internal/tui/browser_test.go | 44 ++++ internal/tui/browser_windows.go | 19 ++ internal/tui/browser_windows_test.go | 30 +++ internal/tui/connection_test.go | 205 ++++++++++++++++ 10 files changed, 901 insertions(+) create mode 100644 docs/adr/0003-preserve-verified-connections.md create mode 100644 docs/adr/0004-contain-connection-authority.md create mode 100644 internal/bridges/lifecycle_test.go create mode 100644 internal/bridges/machine.go create mode 100644 internal/bridges/security_test.go create mode 100644 internal/tui/browser_nonwindows.go create mode 100644 internal/tui/browser_test.go create mode 100644 internal/tui/browser_windows.go create mode 100644 internal/tui/browser_windows_test.go create mode 100644 internal/tui/connection_test.go 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/internal/bridges/lifecycle_test.go b/internal/bridges/lifecycle_test.go new file mode 100644 index 0000000..437dae2 --- /dev/null +++ b/internal/bridges/lifecycle_test.go @@ -0,0 +1,228 @@ +package bridges + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "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) Up(ctx context.Context) (*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 := NewManager(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 := m.Activate(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 := m.Activate(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 := m.Activate(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) Up(ctx context.Context) (*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 := NewManager(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 := m.SwitchTailnet(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 := NewManager(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 := m.Activate(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 := m.Activate(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 := NewManager(false) + m.newNode = func(config.Bridge, string, func(string, ...any), func(string, ...any)) tailnetNode { return node } + if _, err := m.Activate(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 := m.Activate(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 []*Manager{nil, new(Manager), NewManager(false)} { + if err := m.Close(); err != nil { + t.Errorf("closing an unused manager: %v", err) + } + } +} diff --git a/internal/bridges/machine.go b/internal/bridges/machine.go new file mode 100644 index 0000000..d9cd777 --- /dev/null +++ b/internal/bridges/machine.go @@ -0,0 +1,82 @@ +package bridges + +import ( + "context" + "errors" + "net" +) + +// Machine owns the node and proxies for one bridge. Its turn covers an entire +// activation or logout, including cleanup; a cached Machine may have no node. +type Machine struct { + node tailnetNode + proxies map[string]*proxyRuntime + ev *liveEvents + turn chan struct{} + // cancel is guarded by Manager.mu, so shutdown can interrupt the owner + // without waiting for its turn (which may be waiting for authorization). + cancel context.CancelFunc +} + +// acquire grants a cancellable turn on one Machine. Manager.mu only protects +// the cache and cancellation handles, never network work or turn acquisition. +func (m *Manager) acquire(ctx context.Context, bridgeID string) (context.Context, *Machine, error) { + if err := ctx.Err(); err != nil { + return nil, nil, err + } + m.mu.Lock() + if m.nodes == nil { + m.mu.Unlock() + return nil, nil, net.ErrClosed + } + rt := m.nodes[bridgeID] + if rt == nil { + rt = &Machine{ + proxies: make(map[string]*proxyRuntime), + ev: &liveEvents{}, + turn: make(chan struct{}, 1), + } + m.nodes[bridgeID] = rt + } + m.mu.Unlock() + select { + case rt.turn <- struct{}{}: + case <-ctx.Done(): + return nil, nil, ctx.Err() + } + m.mu.Lock() + defer m.mu.Unlock() + if m.nodes == nil { + <-rt.turn + return nil, nil, net.ErrClosed + } + if err := ctx.Err(); err != nil { + <-rt.turn + return nil, nil, err + } + ctx, rt.cancel = context.WithCancel(ctx) + return ctx, rt, nil +} + +func (m *Manager) release(rt *Machine) { + m.mu.Lock() + rt.cancel() + rt.cancel = nil + m.mu.Unlock() + <-rt.turn +} + +// close is called with the Machine's turn held. It must finish before a new +// node can open the same bridge state directory. +func (rt *Machine) close() error { + var errs []error + for key, proxy := range rt.proxies { + errs = append(errs, closeProxy(proxy)) + delete(rt.proxies, key) + } + if rt.node != nil { + errs = append(errs, rt.node.Close()) + rt.node = nil + } + return errors.Join(errs...) +} diff --git a/internal/bridges/security_test.go b/internal/bridges/security_test.go new file mode 100644 index 0000000..38d6bc4 --- /dev/null +++ b/internal/bridges/security_test.go @@ -0,0 +1,175 @@ +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 := NewManager(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 := m.Activate(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.Login(link)) + if shown.String() != authURL { + t.Fatal("interactive consumer lost the authorization URL") + } + case "rejected link": + r := loginReporter{ev: sink(nil)} + r.notify(browse("http://login.tailscale.com/a/" + secret)) + case "health warning": + r := loginReporter{ev: sink(nil)} + r.notify(unhealthyLogin("request failed: " + authURL)) + case "backend and startup error": + m := NewManager(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)} + } + _, _ = m.Activate(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) + } + }) + } + } +} 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..802b5a3 --- /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/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, endpoint: config.Endpoint{URL: srv.URL}, host: 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].BridgeID = target.BridgeID + m.g.Settings.Endpoints[0].URL = "http://first" + } + 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, endpoint: target, 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, endpoint: first, 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, endpoint: candidate, 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") + } + } +} From 7ba574f462d2b998919ce2a015360397fb6b9b47 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Fri, 18 Sep 2026 20:37:15 +0000 Subject: [PATCH 40/69] config: settle the whole invocation before any of it is written `aperture -bridge work -endpoint ftp://host` exited with a usage error and left a bridge called work on disk, because Resolve looked the bridge up (creating it) before parsing the URL. Parsing first costs nothing: a bridge is only needed once the URL is known to be usable. A name matching two bridges now fails naming both IDs rather than taking the first. Nothing keeps names unique, so first-match made the second bridge unaddressable from the command line with no way to tell. Enforcing uniqueness in AddBridge was the alternative and it invalidates configs that are already on disk, for a label the program itself never keys off. --- internal/config/startup.go | 45 +++++++++++++++++++++++++-------- internal/config/startup_test.go | 41 ++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 10 deletions(-) diff --git a/internal/config/startup.go b/internal/config/startup.go index b6821d8..6a518ec 100644 --- a/internal/config/startup.go +++ b/internal/config/startup.go @@ -1,6 +1,9 @@ package config -import "strings" +import ( + "fmt" + "strings" +) // Startup is what the invocation asked the launcher to open. The zero value // means it asked for nothing. @@ -25,28 +28,50 @@ func (s Startup) Resolve(g *Global) (Endpoint, error) { if url == "" && name == "" { return g.ActiveEndpoint(), nil } - var bridgeID string - if name != "" { - bridge, err := s.bridge(g, name) + // The URL is checked before the bridge is looked up, 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. + ep := Endpoint{URL: DefaultLocation} + if url != "" { + parsed, err := ParseEndpoint(url, "") if err != nil { return Endpoint{}, err } - bridgeID = bridge.ID + ep = parsed } - if url == "" { - return Endpoint{URL: DefaultLocation, BridgeID: bridgeID}, nil + if name == "" { + return ep, nil } - return ParseEndpoint(url, bridgeID) + bridge, err := s.bridge(g, name) + if err != nil { + return Endpoint{}, err + } + ep.BridgeID = bridge.ID + return ep, nil } // bridge creates the named bridge if there is none, which 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 the flag then names neither: picking the +// first leaves the other unreachable from the command line, silently. func (s Startup) bridge(g *Global, name string) (Bridge, error) { + var matched []Bridge for _, b := range g.Settings.Bridges { if strings.EqualFold(b.Name, name) { - return b, nil + matched = append(matched, b) } } - return g.AddBridge(name) + 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 index 4e32d8e..b6711e5 100644 --- a/internal/config/startup_test.go +++ b/internal/config/startup_test.go @@ -2,6 +2,7 @@ package config_test import ( "path/filepath" + "strings" "testing" "github.com/tailscale/aperture-cli/internal/config" @@ -101,3 +102,43 @@ func TestResolveRejectsAUnusableURL(t *testing.T) { 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{}) + + s := config.Startup{URL: "ftp://aperture.example.com", BridgeName: "Work"} + if _, err := s.Resolve(g); 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.Startup{BridgeName: "WORK"}.Resolve(g) + 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) + } + } +} From c1d6b73bd32dce6784a0e58d690bee23fc38752b Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Fri, 18 Sep 2026 20:37:28 +0000 Subject: [PATCH 41/69] docs: name the gate CI actually runs `make test` skips the race detector and the build, which is where a bridge fails: several goroutines racing a control plane. A contributor following AGENTS.md could land red CI from a green local run. --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index aa90aa2..4321ff0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,7 +65,8 @@ Current: [Connection](docs/adr/0001-connection-bounded-context.md). ## Conventions - Commit prefixes match the package touched: `tui:`, `bridges:`, `config:`. -- `make test` is the gate. +- `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 From ea5e256eca95015fc33d5e446ace5e728ee3a0ad Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Fri, 18 Sep 2026 20:51:25 +0000 Subject: [PATCH 42/69] bridges: let a bridge leave the tailnet it registered on Two dead aperture-cli-bridge-* machines in the maintainer's tailnet came from deleting a bridge: the settings entry went and the registered device stayed. SwitchTailnet already knew a close without a logout orphans the device; removal never got that reasoning, and b2a6bc3 made it the common path by cascading endpoint deletion into it. Destroy holds the Machine's turn like every other operation on that node, because a second Machine for one bridge would open the state directory a running attempt is still writing. It initializes the node but never brings it up: bring-up is the interactive login being removed, so demanding one to leave would make an expired identity unremovable. The state directory goes last and only on success, since it holds the node key a later attempt needs to deregister the device. HasMachine reads that directory rather than Bridge.Tailnet, which is a display hint written after verification and cleared before a switch, so it is empty for machines that do exist. --- internal/bridges/lifecycle_test.go | 86 ++++++++++++++++++++++++++++ internal/bridges/machine.go | 90 ++++++++++++++++++++++++++++++ internal/bridges/manager.go | 6 +- 3 files changed, 178 insertions(+), 4 deletions(-) diff --git a/internal/bridges/lifecycle_test.go b/internal/bridges/lifecycle_test.go index 437dae2..d88185b 100644 --- a/internal/bridges/lifecycle_test.go +++ b/internal/bridges/lifecycle_test.go @@ -4,9 +4,13 @@ import ( "context" "errors" "fmt" + "io/fs" "net" "net/http" "net/http/httptest" + "os" + "path/filepath" + "strings" "sync" "testing" "time" @@ -226,3 +230,85 @@ func TestCloseEmptyManager(t *testing.T) { } } } + +// 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 := NewManager(false) + m.newNode = func(config.Bridge, string, func(string, ...any), func(string, ...any)) tailnetNode { return n } + defer m.Close() + + if err := m.Destroy(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 := NewManager(false) + m.newNode = func(config.Bridge, string, func(string, ...any), func(string, ...any)) tailnetNode { return n } + defer m.Close() + + if err := m.Destroy(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 := NewManager(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 := m.Destroy(context.Background(), bridge, nil); err != nil { + t.Fatalf("Destroy: %v", err) + } +} diff --git a/internal/bridges/machine.go b/internal/bridges/machine.go index d9cd777..b66b5d2 100644 --- a/internal/bridges/machine.go +++ b/internal/bridges/machine.go @@ -3,7 +3,13 @@ package bridges import ( "context" "errors" + "fmt" + "io/fs" "net" + "os" + + "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/connection" ) // Machine owns the node and proxies for one bridge. Its turn covers an entire @@ -66,6 +72,90 @@ func (m *Manager) release(rt *Machine) { <-rt.turn } +// MachineName is the hostname this bridge's node registers under, and so the +// device name the tailnet shows. Removal has to name the same thing the admin +// console does, or a user told to go delete it by hand cannot find it. +func MachineName(bridgeID string) string { return "aperture-cli-" + bridgeID } + +// HasMachine reports whether this bridge ever started a node. tsnet creates +// the state directory on first use, so its absence is the only durable +// evidence that nothing was ever registered: Bridge.Tailnet 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) +} + +// Destroy removes a bridge's machine from its tailnet and discards the state +// directory it kept the login in. It touches no settings: the Bridge record is +// the only thing naming the device, so the caller drops it after this returns +// nil and keeps it otherwise (ADR 0002). +// +// A bridge that never started has no device and must not start one to find +// out: bring-up is what would demand the interactive login being removed. +func (m *Manager) Destroy(ctx context.Context, bridge config.Bridge, emit func(connection.Event)) error { + if m == nil { + return fmt.Errorf("bridge manager is not configured") + } + if err := validateBridgeID(bridge.ID); err != nil { + return err + } + stateDir, err := config.BridgeStateDir(bridge.ID) + if err != nil { + return err + } + ev := sink(emit) + ctx, rt, err := m.acquire(ctx, bridge.ID) + if err != nil { + return err + } + defer m.release(rt) + if rt.node == nil && !HasMachine(bridge.ID) { + m.forget(bridge.ID) + return nil + } + if err := m.initNode(bridge, rt, ev); err != nil { + return err + } + ev.note("Removing bridge " + bridge.Name + " from its tailnet ...") + err = rt.destroy(ctx, stateDir) + m.forget(bridge.ID) + if err != nil { + return err + } + ev.note("Bridge " + bridge.Name + " is no longer a device on that tailnet.") + return nil +} + +// forget drops what this session learned about a bridge. The cache entry stays: +// it carries the turn, and a second Machine for one bridge could open the state +// directory a first one is still using. +func (m *Manager) forget(bridgeID string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.tailnets, bridgeID) +} + +// destroy deregisters this Machine and discards its persistence. Called with +// the Machine's turn held. +// +// Logout needs an initialized LocalAPI rather than an authorized node, so an +// identity the tailnet will no longer accept can still be removed. The state +// directory goes last and only on success: it holds the node key, which is +// what a later attempt would need to deregister the device. +func (rt *Machine) destroy(ctx context.Context, stateDir string) error { + logoutErr := rt.node.Logout(ctx) + closeErr := rt.close() + if err := errors.Join(logoutErr, closeErr); err != nil { + return err + } + return os.RemoveAll(stateDir) +} + // close is called with the Machine's turn held. It must finish before a new // node can open the same bridge state directory. func (rt *Machine) close() error { diff --git a/internal/bridges/manager.go b/internal/bridges/manager.go index 62ad3c0..97de79a 100644 --- a/internal/bridges/manager.go +++ b/internal/bridges/manager.go @@ -325,7 +325,7 @@ func NewManager(debug bool) *Manager { m.newNode = func(bridge config.Bridge, stateDir string, userLogf, debugLogf func(string, ...any)) tailnetNode { s := &tsnet.Server{ Dir: stateDir, - Hostname: "aperture-cli-" + bridge.ID, + Hostname: MachineName(bridge.ID), UserLogf: userLogf, } if debug { @@ -509,9 +509,7 @@ func (m *Manager) SwitchTailnet(ctx context.Context, bridge config.Bridge, emit logoutErr := rt.node.Logout(ctx) closeErr := rt.close() - m.mu.Lock() - delete(m.tailnets, bridge.ID) - m.mu.Unlock() + m.forget(bridge.ID) if err := errors.Join(logoutErr, closeErr); err != nil { return err From bad77c493cc20d02519870ca8b76d9da5f7c314d Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Fri, 18 Sep 2026 20:51:34 +0000 Subject: [PATCH 43/69] tui,docs: destroy the machine before the record that names it Deleting a connection dropped settings and left the device on the tailnet. Settings are the only record the device exists, so they have to outlast the logout: removal confirms, logs the node out, discards its state directory, and only then drops the endpoint and the orphaned bridge. Five of the six removal sites now describe the delete as a bridgeRemoval and hand it to one function, so the picker, its row page, the bridges page and the setup guide cannot disagree about what removing means. discardActivation is the sixth and stays as it is: it abandons an ephemeral endpoint for a bridge that never finished a login, which has nothing to deregister. The wait reuses the connect screen and keeps no cancel handle. Logout is a round trip to a control plane that was hanging past 90s on 2026-09-17, and Esc half way through it is how the record and the device start disagreeing. Bounded at 45s instead, after which the local records go and the message names the surviving device and its tailnet, which is what the user needs to finish the job in the admin console. A bridge with no state directory never registered, and is removed with no confirmation and no round trip. --- ...002-bridge-removal-destroys-the-machine.md | 18 +- docs/specs/bridge-resource-lifecycle.md | 65 +++-- internal/tui/menus.go | 76 +----- internal/tui/removal.go | 251 ++++++++++++++++++ internal/tui/removal_test.go | 228 ++++++++++++++++ internal/tui/tui.go | 3 + internal/tui/tui_test.go | 4 +- 7 files changed, 540 insertions(+), 105 deletions(-) create mode 100644 internal/tui/removal.go create mode 100644 internal/tui/removal_test.go diff --git a/docs/adr/0002-bridge-removal-destroys-the-machine.md b/docs/adr/0002-bridge-removal-destroys-the-machine.md index 7e8ecdf..b1e2198 100644 --- a/docs/adr/0002-bridge-removal-destroys-the-machine.md +++ b/docs/adr/0002-bridge-removal-destroys-the-machine.md @@ -1,6 +1,6 @@ # 0002. Removing a bridge destroys its Machine -Status: proposed +Status: accepted Date: 2026-09-18 ## Why? @@ -23,15 +23,19 @@ Detail, including all six removal sites and the constraints: Destroying the last Bridge reference destroys its Machine. -1. `Machine` grows `Destroy(ctx) error`: `LeaveTailnet`, `Close`, then discard - the state directory, which is the Machine's own persistence. No new - `Manager` method. `Manager` caches Machines; it does not own their - lifecycle (ADR 0001, decision 6). +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 `Tailnet` never registered. It has no Machine to destroy - and must not start one to find out. +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, and on timeout the local records go anyway and the user is told which device is still theirs to delete. diff --git a/docs/specs/bridge-resource-lifecycle.md b/docs/specs/bridge-resource-lifecycle.md index 1bb9a83..08f5bca 100644 --- a/docs/specs/bridge-resource-lifecycle.md +++ b/docs/specs/bridge-resource-lifecycle.md @@ -25,29 +25,35 @@ device orphaned rather than removed. ## Where a bridge can be removed -Six sites, all in `internal/tui`, none confirming, none touching anything but -settings. +Six sites, all in `internal/tui`. Five of them now describe the delete as a +`bridgeRemoval` 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` (`menus.go:213`) | the bridge, from a second delete UI parallel to the picker's | -| `removeConnectionRow` default arm (`menus.go:482`) | the bridge, for a row with no endpoint | -| `removeConnection` (`menus.go:497`) | the endpoint, then cascades | -| `dropOrphanBridge` (`menus.go:529`) | the bridge, once its last endpoint goes (`b2a6bc3`) | -| setup guide "Remove endpoint" (`menus.go:647`) | the endpoint, then cascades | -| `discardActivation` (`tui.go:471`) | the ephemeral endpoint, leaving the bridge | - -The last 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. +| `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(ctx) error`, on the aggregate that owns the node -([domain model](connection-domain-model.md#machine)), not a new method on -`Manager`: `LeaveTailnet`, `Close`, then discard the state directory, which is -the Machine's own persistence. +`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. +`Manager.Destroy` is the entry point, because the Machine's turn and its cache +entry are `Manager` state and destruction has to hold the turn 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 @@ -63,23 +69,26 @@ goes through `server.LocalClient()`, which calls `Start` without waiting for its credentials, but requiring `Up` before logout would unnecessarily demand authorization of the identity being left. -**Initialization can begin registration; `Up` waits for it.** A future destroy -operation must not require an interactive login to remove a bridge. -`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. +**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 has a context or an event sink.** All six return -`menu.Result` synchronously. The house pattern for slow work is `connectVia` -(`menus.go:793`); for progress without a connection attempt it is the -post-launch recheck (`tui.go:773`), which reuses `stepPreflight` with its own -result message. +**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 confirms today.** The house confirm shape is -`switchTailnetMenu` (`menus.go:544`). +**No removal path confirmed.** `removeBridgeMenu` follows `switchTailnetMenu`, +the house confirm shape. ## Out of scope diff --git a/internal/tui/menus.go b/internal/tui/menus.go index f8080f9..922db1f 100644 --- a/internal/tui/menus.go +++ b/internal/tui/menus.go @@ -14,6 +14,7 @@ import ( const ( rootTitle = "Which editor do you want to use?" endpointsTitle = "Aperture Endpoints" + bridgesTitle = "Bridges" setupGuideTitle = "Getting Started" ) @@ -223,14 +224,11 @@ 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(bridgeRemoval{bridge: m.g.Settings.Bridges[idx]}) }, }) return &menu.Menu{ - Title: "Bridges", + Title: bridgesTitle, Items: items, Hint: "Enter to connect · d to remove · a to add · Esc to go back", } @@ -298,7 +296,7 @@ func (m *model) endpointsMenu() *menu.Menu { if !ok { return menu.Result{} } - return m.removeConnectionRow(row) + return m.removeRow(row) }, }) @@ -460,13 +458,13 @@ func (m *model) connectionMenu(row connectionRow) *menu.Menu { case row.saved: items = append(items, menu.MenuItem{ Label: "Remove connection", - Action: func() menu.Result { return m.removeConnectionRow(row) }, + 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.removeConnectionRow(row) }, + Action: func() menu.Result { return m.removeRow(row) }, }) } @@ -477,49 +475,6 @@ func (m *model) connectionMenu(row connectionRow) *menu.Menu { } } -// removeConnectionRow deletes what a picker row stands for: the endpoint, or -// the bridge itself when no endpoint points at it yet. Shared by the row's page -// and the "d" key, which have to agree on what removing a row means. -func (m *model) removeConnectionRow(row connectionRow) menu.Result { - switch { - case row.active: - return errResult("connect to another endpoint before removing the active one") - case row.saved: - return m.removeConnection(row.ep) - default: - if err := m.g.RemoveBridge(row.bridge.ID); err != nil { - return errResult(err.Error()) - } - m.refreshEndpointsMenu() - return menu.Result{Cmd: tea.ClearScreen} - } -} - -func (m *model) removeConnection(ep config.Endpoint) menu.Result { - for i, existing := range m.g.Settings.Endpoints { - if !sameEndpoint(existing, ep) { - continue - } - if i == 0 { - return errResult("connect to another endpoint before removing the active one") - } - if err := m.g.RemoveEndpoint(i); err != nil { - return errResult(err.Error()) - } - if err := m.dropOrphanBridge(existing.BridgeID); err != nil { - return errResult(err.Error()) - } - break - } - if m.failedEndpoint != nil && sameEndpoint(*m.failedEndpoint, ep) { - m.clearEndpointFailure() - m.resetStack(m.rootMenu()) - return menu.Result{Cmd: tea.ClearScreen} - } - m.refreshEndpointsMenu() - return menu.Result{Cmd: tea.ClearScreen} -} - // dropOrphanBridge removes a bridge once its last endpoint is gone. Settings // hold two objects where the picker shows one row, so removing the endpoint // alone left the bridge re-listed as a bare "Connect via" row: to the user the @@ -644,23 +599,8 @@ func (m *model) setupGuideMenu() *menu.Menu { } if m.endpointConfigured(target) && !sameEndpoint(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()) - } - if err := m.dropOrphanBridge(ep.BridgeID); 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.removalFor(target)) }, }) } diff --git a/internal/tui/removal.go b/internal/tui/removal.go new file mode 100644 index 0000000..3c0255a --- /dev/null +++ b/internal/tui/removal.go @@ -0,0 +1,251 @@ +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" +) + +// bridgeRemoval is what one delete is about: the endpoint the user picked, and +// the bridge that endpoint was the last reason to keep. Either can be absent. +type bridgeRemoval struct { + bridge config.Bridge + endpoint *config.Endpoint +} + +// bridgeRemovedMsg carries the outcome of the tailnet round trip back to the +// update loop. timedOut separates "the tailnet refused" from "the tailnet did +// not answer in time", which are opposite answers about the local records. +type bridgeRemovedMsg struct { + id int + removal bridgeRemoval + err error + timedOut bool +} + +// Seams for the tests: pickerModel has no bridge manager, and a real Destroy +// would want a tailnet. +var ( + destroyBridge = func(ctx context.Context, mgr *bridges.Manager, bridge config.Bridge, emit func(connection.Event)) error { + return mgr.Destroy(ctx, bridge, emit) + } + bridgeHasMachine = bridges.HasMachine +) + +// bridgeDestroyTimeout bounds the logout. /machine/register was hanging past 90 +// seconds on 2026-09-17 and logout is a round trip to the same place, so the +// delete cannot wait on it indefinitely (ADR 0002, decision 6). +var bridgeDestroyTimeout = 45 * time.Second + +// removeRow deletes what a picker row stands for. Shared by the row's page and +// the "d" key, which have to agree on what removing a row means. +func (m *model) removeRow(row connectionRow) menu.Result { + if row.active { + return errResult("connect to another endpoint before removing the active one") + } + rem := bridgeRemoval{bridge: row.bridge} + if row.saved { + ep := row.ep + rem.endpoint = &ep + } + return m.remove(rem) +} + +// removalFor is the removal a saved endpoint implies, bridge included. The +// setup guide holds an endpoint rather than a picker row. +func (m *model) removalFor(ep config.Endpoint) bridgeRemoval { + rem := bridgeRemoval{endpoint: &ep} + rem.bridge, _ = m.g.Bridge(ep.BridgeID) + return rem +} + +// remove confirms and destroys the bridge's machine when this is the last +// reference to it, and otherwise just drops the records. Every delete in the +// TUI comes through here: the machine 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(rem bridgeRemoval) menu.Result { + if rem.endpoint == nil { + if ep, used := m.endpointUsing(rem.bridge.ID); used { + return errResult("bridge " + rem.bridge.Name + " is used by endpoint " + ep.URL + "; remove that connection instead") + } + } + if !m.destroys(rem) { + if err := m.removeRecords(rem); err != nil { + return errResult(err.Error()) + } + return menu.Result{Cmd: m.afterRemoval(rem)} + } + return menu.Result{Next: m.removeBridgeMenu(rem)} +} + +// destroys reports whether this removal takes the bridge's last endpoint and +// leaves a machine behind. A bridge that never started has no device, and must +// not start one to find out: bring-up is the interactive login being removed. +func (m *model) destroys(rem bridgeRemoval) bool { + if rem.bridge.ID == "" || !bridgeHasMachine(rem.bridge.ID) { + return false + } + for _, ep := range m.g.Settings.Endpoints { + if ep.BridgeID != rem.bridge.ID { + continue + } + if rem.endpoint == nil || !sameEndpoint(ep, *rem.endpoint) { + return false + } + } + return true +} + +func (m *model) endpointUsing(bridgeID string) (config.Endpoint, bool) { + for _, ep := range m.g.Settings.Endpoints { + if bridgeID != "" && ep.BridgeID == bridgeID { + return ep, true + } + } + return config.Endpoint{}, false +} + +// removeBridgeMenu is the confirmation. Removal is irreversible from here and +// takes a device off the user's tailnet, so the screen names the device by the +// name the admin console shows it under. +func (m *model) removeBridgeMenu(rem bridgeRemoval) *menu.Menu { + preamble := "Bridge " + rem.bridge.Name + " is the device " + bridges.MachineName(rem.bridge.ID) + if name := m.bridgeTailnet(rem.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 " + rem.bridge.Name + "?", + Preamble: preamble, + Items: []menu.MenuItem{ + { + Label: "Remove", + Shortcut: "y", + Action: func() menu.Result { return menu.Result{Cmd: m.destroyBridgeCmd(rem)} }, + }, + { + Label: "Cancel", + Shortcut: "n", + Action: func() menu.Result { return menu.Result{Pop: true} }, + }, + }, + Hint: "y to remove · n to cancel", + } +} + +// destroyBridgeCmd puts the logout on the connect screen, which is where this +// program already shows slow bridge work and its log tail. The attempt 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 disagree. +func (m *model) destroyBridgeCmd(rem bridgeRemoval) tea.Cmd { + m.stopActivation() + m.step = stepPreflight + m.preflightErr = "" + m.bridgeLogs = nil + + ctx, cancel := context.WithTimeout(context.Background(), bridgeDestroyTimeout) + ch := make(chan bridgeLine, 32) + m.activationSeq++ + act := &activation{ + id: m.activationSeq, + label: "Removing bridge " + rem.bridge.Name + " ...", + started: time.Now(), + logCh: ch, + logCtx: ctx, + } + m.act = act + emit := bridgeLogSink(ctx, ch, act.started) + destroy := func() tea.Msg { + defer cancel() + err := destroyBridge(ctx, m.bridgeManager, rem.bridge, emit) + return bridgeRemovedMsg{id: act.id, removal: rem, err: err, timedOut: err != nil && ctx.Err() != nil} + } + return tea.Batch(destroy, waitBridgeLog(ctx, ch), activationTick(act.id)) +} + +// bridgeRemoved applies the outcome. Settings go only once the device is gone +// or is known to have outlived the wait, because settings are the only record +// that the device exists. +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 + if msg.err != nil && !msg.timedOut { + m.step = stepError + m.errMsg = "Could not remove bridge " + msg.removal.bridge.Name + ": " + msg.err.Error() + + "\n\nThe connection is unchanged. Removing it again retries the logout." + return m, nil + } + if err := m.removeRecords(msg.removal); err != nil { + m.step = stepError + m.errMsg = err.Error() + return m, nil + } + cmd := m.afterRemoval(msg.removal) + if msg.timedOut { + m.step = stepError + m.errMsg = m.timedOutMessage(msg.removal.bridge) + } + return m, cmd +} + +// timedOutMessage is what the user needs to finish the job by hand: the device +// name, and where to look for it. A bare "timed out" leaves them hunting for a +// machine whose name this program chose. +func (m *model) timedOutMessage(bridge config.Bridge) string { + msg := "Bridge " + bridge.Name + " was removed here, but the tailnet did not confirm within " + + bridgeDestroyTimeout.String() + ".\n\nThe device " + bridges.MachineName(bridge.ID) + if name := m.bridgeTailnet(bridge); name != "" { + msg += " may still be on " + name + } else { + msg += " may still be registered" + } + return msg + ". Delete it from the Tailscale admin console if it is." +} + +// removeRecords drops the settings this removal covers, endpoint first: a +// bridge an endpoint still points at cannot be removed (global.go RemoveBridge). +func (m *model) removeRecords(rem bridgeRemoval) error { + if rem.endpoint != nil { + for i, ep := range m.g.Settings.Endpoints { + if i == 0 || !sameEndpoint(ep, *rem.endpoint) { + continue + } + if err := m.g.RemoveEndpoint(i); err != nil { + return err + } + break + } + } + return m.dropOrphanBridge(rem.bridge.ID) +} + +// afterRemoval puts the user back on a list that no longer shows what they +// removed. A removal of the endpoint the failure screen is about leaves that +// screen with nothing to retry, so the root menu takes its place. +func (m *model) afterRemoval(rem bridgeRemoval) tea.Cmd { + if rem.endpoint != nil && m.failedEndpoint != nil && sameEndpoint(*m.failedEndpoint, *rem.endpoint) { + 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..3c629bb --- /dev/null +++ b/internal/tui/removal_test.go @@ -0,0 +1,228 @@ +package tui + +import ( + "context" + "errors" + "os" + "strings" + "testing" + "time" + + 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.Manager, 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.ep.BridgeID == "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") + } +} + +// Point 6 of ADR 0002: the wait is bounded, and what survives it is named. +func TestDestroyTimeoutRemovesLocallyAndNamesTheDevice(t *testing.T) { + m := pickerModel(t) + withFakeClients(t, []clients.Client{}) + startedBridge(t, "bridge-aaaaaa") + orig := bridgeDestroyTimeout + bridgeDestroyTimeout = 50 * time.Millisecond + t.Cleanup(func() { bridgeDestroyTimeout = orig }) + withFakeDestroy(t, func(ctx context.Context, _ config.Bridge) error { + <-ctx.Done() + return ctx.Err() + }) + 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 kept local records: %+v", m.g.Settings) + } + for _, want := range []string{bridges.MachineName(row.bridge.ID), "corp.example.com"} { + if !strings.Contains(m.errMsg, want) { + t.Errorf("message %q does not name %q", m.errMsg, want) + } + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 63494de..16e8487 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -720,6 +720,9 @@ 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.act == nil || m.act.logCh != msg.ch { return m, nil diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index b0653d8..63985bb 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1742,7 +1742,7 @@ func TestRemoveConnectionRowTakesTheBridgeWithIt(t *testing.T) { if len(rows) != 2 { t.Fatalf("connectionRows() = %d rows, want 2", len(rows)) } - m.removeConnectionRow(rows[1]) + m.removeRow(rows[1]) after := m.connectionRows() if len(after) != 1 { @@ -1769,7 +1769,7 @@ func TestRemoveConnectionRowKeepsASharedBridge(t *testing.T) { Bridges: []config.Bridge{{ID: "b1", Name: "work"}}, }}} - m.removeConnectionRow(m.connectionRows()[1]) + 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) From f36234f38c312ce418ff5be67e0acf1c983b5fba Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Fri, 18 Sep 2026 20:59:10 +0000 Subject: [PATCH 44/69] bridges: wait for the node on the watch that reports it A login ran two IPN bus consumers: tsnet.Server.Up watches for Running while WatchLogin watched the same bus for the phase and the link. The backend evicts a consumer that falls behind with a terminal ErrMessage, which Up returns as "IPN bus consumer fell behind", so a burst of notifications during registration could fail a bring-up the user did nothing wrong in. ADR 0001 decision 4 rejected this shape up front and the code kept it. BringUp replaces Up and WatchLogin on the node, running one watch that names each wait and returns the status when Running arrives. Taking the wait means taking what Up did with it: ErrMessage is terminal, and a Running node with no TailscaleIPs is refused. resetServeStateOnce is dropped, nothing here sets a serve config. The alternative was keeping both watchers and hoping the login watch stays ahead of the bus, which is the same bet with no way to observe losing it: eviction surfaces as an unrelated bring-up error. Revisit if tsnet publishes phases itself, which would make an owned bring-up loop pure cost. --- internal/bridges/bringup_test.go | 103 ++++++++++++++++++++++++++ internal/bridges/lifecycle_test.go | 4 +- internal/bridges/manager.go | 113 ++++++++++++++--------------- internal/bridges/manager_test.go | 40 ++++------ 4 files changed, 175 insertions(+), 85 deletions(-) create mode 100644 internal/bridges/bringup_test.go diff --git a/internal/bridges/bringup_test.go b/internal/bridges/bringup_test.go new file mode 100644 index 0000000..d058392 --- /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(), + "Authorize this bridge at " + url, + 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/lifecycle_test.go b/internal/bridges/lifecycle_test.go index d88185b..a27ffb2 100644 --- a/internal/bridges/lifecycle_test.go +++ b/internal/bridges/lifecycle_test.go @@ -24,7 +24,7 @@ type pendingNode struct { started, cancelling, releaseUp, closing, releaseClose chan struct{} } -func (n *pendingNode) Up(ctx context.Context) (*ipnstate.Status, error) { +func (n *pendingNode) BringUp(ctx context.Context, _ events) (*ipnstate.Status, error) { close(n.started) <-ctx.Done() close(n.cancelling) @@ -111,7 +111,7 @@ func TestActivateWaitsForCancelledNodeCleanup(t *testing.T) { type needsLoginNode struct{ *fakeNode } -func (n *needsLoginNode) Up(ctx context.Context) (*ipnstate.Status, error) { +func (n *needsLoginNode) BringUp(ctx context.Context, _ events) (*ipnstate.Status, error) { n.up++ <-ctx.Done() return nil, ctx.Err() diff --git a/internal/bridges/manager.go b/internal/bridges/manager.go index 97de79a..2b4740a 100644 --- a/internal/bridges/manager.go +++ b/internal/bridges/manager.go @@ -18,7 +18,6 @@ import ( "github.com/tailscale/aperture-cli/internal/config" "github.com/tailscale/aperture-cli/internal/connection" - "tailscale.com/client/local" "tailscale.com/health" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" @@ -83,10 +82,9 @@ type proxyRuntime struct { } type tailnetNode interface { - Up(context.Context) (*ipnstate.Status, error) + BringUp(context.Context, events) (*ipnstate.Status, error) Status(context.Context) (*ipnstate.Status, error) DialContext(context.Context, string, string) (net.Conn, error) - WatchLogin(context.Context, events) Logout(context.Context) error Close() error } @@ -138,8 +136,30 @@ type tsnetNode struct { server *tsnet.Server } -func (n *tsnetNode) Up(ctx context.Context) (*ipnstate.Status, error) { - return n.server.Up(ctx) +// BringUp waits for the node to be usable and reports what it is waiting on, +// off 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, which arrives as a terminal "IPN bus consumer fell behind" on a login +// the user did nothing wrong in. +// +// Taking the wait means taking what Up did with it: a terminal ErrMessage, and +// the check that a Running node actually has an address. resetServeStateOnce +// is not ours to keep; 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 too: health changes reach every watcher regardless of + // mask, but a login already broken before this watch started shows up only + // in the initial one, which 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) { @@ -154,53 +174,38 @@ func (n *tsnetNode) DialContext(ctx context.Context, network, address string) (n return n.server.Dial(ctx, network, address) } -// WatchLogin reports what an interactive login is waiting on, until ctx is done. -// -// tsnet surfaces the link from a five second poll loop of its own, so a link -// landing just after a tick stays invisible for most of that window: one bridge -// was killed a few hundred milliseconds before its link would have printed. The -// IPN bus has it the moment the control plane answers. -func (n *tsnetNode) WatchLogin(ctx context.Context, ev events) { - // A cancelled watch is how this returns on every connection that works, - // so only a failure the caller did not ask for is worth a line. - report := func(err error) { - if err != nil && ctx.Err() == nil { - // Logged as well as noted: a dead watch leaves the attempt on its - // last phase forever, which on screen is indistinguishable from a - // control plane that is simply slow. - slog.Error("bridge login watch ended", "err", redactDiagnostic(err.Error())) - ev.note("Could not watch the bridge's login state: " + err.Error()) - } - } - - // LocalClient calls Start, so this blocks until the node is initialized, - // the same bring-up Up is waiting on in parallel. - lc, err := n.server.LocalClient() - if err != nil { - report(err) - return - } - // InitialHealthState too: health changes reach every watcher regardless of - // mask, but a login already broken before this watch started shows up only - // in the initial one, which is the reused node case. - watcher, err := lc.WatchIPNBus(ctx, ipn.NotifyInitialState|ipn.NotifyInitialHealthState) - if err != nil { - report(err) - return - } - defer watcher.Close() - report(reportLogin(watcher, ev)) +// notifier is the part of an IPN bus watch the bring-up reads, so the loop can +// be exercised against a recorded bus. +type notifier interface { + Next() (ipn.Notify, error) } -// reportLogin translates an IPN bus watch into phases until the watch ends. -func reportLogin(watcher *local.IPNBusWatcher, ev events) error { +// bringUp waits for Running on one watch, naming each wait as it is entered. +// The link comes off the bus rather than tsnet's five second poll loop, which +// 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 notifier, statusOf func(context.Context) (*ipnstate.Status, error), ev events) (*ipnstate.Status, error) { reporter := loginReporter{ev: ev} for { - notify, err := watcher.Next() + notify, err := w.Next() if err != nil { - return err + return nil, err + } + if notify.ErrMessage != nil { + return nil, fmt.Errorf("bridge backend: %s", *notify.ErrMessage) } reporter.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 } } @@ -435,23 +440,15 @@ func (m *Manager) runningNode(ctx context.Context, bridge config.Bridge, rt *Mac ev.enter(connection.StartingMachine) - // Up blocks until the node is Running, which for a bridge that has never - // logged in means blocking until the user visits a link nothing has shown - // them yet. The watch runs alongside it and ends with it. - watchCtx, stopWatch := context.WithCancel(ctx) - watchDone := make(chan struct{}) - go func() { - defer close(watchDone) - rt.node.WatchLogin(watchCtx, ev) - }() - + // BringUp blocks until the node is Running, which for a bridge that has + // never logged in means blocking until the user visits a link nothing has + // shown them yet. It reports the wait off the watch it is waiting on. + // // Timed because this is the wait every "it just sat there" report is // about, and the number is the difference between a slow control plane and // a login link the user never saw. start := time.Now() - status, err := rt.node.Up(ctx) - stopWatch() - <-watchDone + status, err := rt.node.BringUp(ctx, ev) if err != nil { slog.Error("bridge node did not come up", "bridge", bridge.ID, "after", time.Since(start), "err", redactDiagnostic(err.Error())) return nil, errors.Join(err, rt.close()) diff --git a/internal/bridges/manager_test.go b/internal/bridges/manager_test.go index 9eab97f..4f3bb7d 100644 --- a/internal/bridges/manager_test.go +++ b/internal/bridges/manager_test.go @@ -44,8 +44,14 @@ type fakeNode struct { dialed []string } -func (n *fakeNode) Up(context.Context) (*ipnstate.Status, error) { +// 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() } @@ -73,15 +79,6 @@ func (n *fakeNode) DialContext(ctx context.Context, network, address string) (ne return d.DialContext(ctx, network, n.backendAddr) } -// WatchLogin stands in for the IPN bus watch: watchFn is what a test wants the -// bus to report, and it runs until the manager cancels the watch. -func (n *fakeNode) WatchLogin(ctx context.Context, ev events) { - if n.watchFn != nil { - n.watchFn(ev) - } - <-ctx.Done() -} - // 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) { @@ -308,15 +305,14 @@ func TestActivateWaitsForPeerMapBeforeDialing(t *testing.T) { } } -// TestActivateLogsLoginLinkWhileUpBlocks covers the bridge that looked hung: a -// node that has never logged in blocks in Up until someone visits a link, so -// the link has to reach the log while Up is still blocked, not after it. -func TestActivateLogsLoginLinkWhileUpBlocks(t *testing.T) { +// 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" - watched := make(chan struct{}) node := &fakeNode{ backendAddr: backend.Listener.Addr().String(), status: tailnetStatus("ai.example.ts.net.", "100.64.0.2"), @@ -328,15 +324,6 @@ func TestActivateLogsLoginLinkWhileUpBlocks(t *testing.T) { return } ev.login(link) - close(watched) - } - // Up stands in for the wait on an interactive login, and gives up so a - // manager that never watches fails the assertion instead of hanging. - node.upFn = func() { - select { - case <-watched: - case <-time.After(2 * time.Second): - } } m := NewManager(false) @@ -359,8 +346,11 @@ func TestActivateLogsLoginLinkWhileUpBlocks(t *testing.T) { mu.Lock() defer mu.Unlock() for _, line := range logs { - if strings.Contains(line, url) { + 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")) From e34568ddeb71f365a20b91526770fd0cc8dd41b6 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Fri, 18 Sep 2026 20:59:43 +0000 Subject: [PATCH 45/69] docs: name the bring-up the contracts knot is about The knot is still real, a cached node re-logs in with nothing watching, but it was described by a symbol 9c61d05 removed, so a reader grepping for WatchLogin finds nothing and cannot tell whether the knot went with it. --- docs/specs/connection-contracts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/connection-contracts.md b/docs/specs/connection-contracts.md index df28ea5..6a33594 100644 --- a/docs/specs/connection-contracts.md +++ b/docs/specs/connection-contracts.md @@ -192,7 +192,7 @@ subscribing to it for its own lifetime. |---|---|---| | 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. | -| `WatchLogin` starts only when the node is created | `runningNode` (`manager.go:353`) 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. | +| 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 From f83ac1ce80fa098deeb81f11934788795bccd7c2 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Mon, 21 Sep 2026 15:50:43 +0000 Subject: [PATCH 46/69] test: point every tui and bridges test at a throwaway config dir One tui test built a model with no settings and ran an activation without t.Setenv, and once Begin started saving the candidate endpoint it overwrote ~/.config/aperture/settings.json on the developer's machine. Per-test Setenv relies on every author remembering; a TestMain in each package that writes settings makes forgetting harmless. --- .../{manager_test.go => machine_test.go} | 0 internal/bridges/main_test.go | 21 + internal/bridges/manager.go | 826 ------------------ internal/tui/main_test.go | 21 + 4 files changed, 42 insertions(+), 826 deletions(-) rename internal/bridges/{manager_test.go => machine_test.go} (100%) create mode 100644 internal/bridges/main_test.go delete mode 100644 internal/bridges/manager.go create mode 100644 internal/tui/main_test.go diff --git a/internal/bridges/manager_test.go b/internal/bridges/machine_test.go similarity index 100% rename from internal/bridges/manager_test.go rename to internal/bridges/machine_test.go diff --git a/internal/bridges/main_test.go b/internal/bridges/main_test.go new file mode 100644 index 0000000..13a3458 --- /dev/null +++ b/internal/bridges/main_test.go @@ -0,0 +1,21 @@ +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") + 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 2b4740a..0000000 --- a/internal/bridges/manager.go +++ /dev/null @@ -1,826 +0,0 @@ -// Package bridges runs embedded tsnet reverse proxies for Aperture endpoints. -package bridges - -import ( - "context" - "errors" - "fmt" - "log/slog" - "net" - "net/http" - "net/http/httputil" - "net/netip" - "net/url" - "regexp" - "strings" - "sync" - "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/tsnet" -) - -// Manager owns active tsnet nodes and localhost reverse proxies. -type Manager struct { - mu sync.Mutex - - 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 - nodes map[string]*Machine - // tailnets is the network each running node logged in to, keyed by bridge - // ID. Read back by the TUI to label a bridge with the tailnet it reaches. - tailnets map[string]string - shutdown func() error - - newNode func(bridge config.Bridge, stateDir string, userLogf, debugLogf func(string, ...any)) tailnetNode -} - -const ( - bridgePeerWaitWindow = 5 * time.Second - bridgePeerWaitInterval = 250 * time.Millisecond -) - -// liveEvents points a node's long-lived reporting at whichever connection is -// using it now. Nodes and proxies outlive the connection that built them, and -// closures that captured that connection's sink went on writing to a channel -// nobody read, losing every later dial failure and proxy error. -// -// Nothing clears it when a connection ends: a finished sink discards what it is -// given, and a clear needs a lifecycle hook only the Attempt can own. -type liveEvents struct { - mu sync.Mutex - ev events -} - -func (l *liveEvents) use(ev events) { - l.mu.Lock() - defer l.mu.Unlock() - l.ev = ev -} - -// emit has the events signature, so callers keep note and notef. -func (l *liveEvents) emit(e connection.Event) { - l.mu.Lock() - ev := l.ev - l.mu.Unlock() - if ev != nil { - ev(e) - } -} - -type proxyRuntime struct { - localURL string - server *http.Server - listener net.Listener -} - -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 -} - -// events is where a bridge reports what it is doing. This package translates -// the tailnet's vocabulary into it and publishes nothing else, so no caller has -// to recover meaning by matching prose from inside a vendored package. -type events func(connection.Event) - -// sink returns a usable events, so callers that want none can pass nil. -func sink(emit func(connection.Event)) events { - return func(e connection.Event) { - logEvent(e) - if emit != nil { - emit(e) - } - } -} - -// logEvent copies a connection event into 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 are 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 e.Kind { - case connection.PhaseEntered: - slog.Info("bridge phase", "phase", e.Phase) - case connection.LoginRequired: - slog.Info("bridge needs login") - default: - slog.Debug("bridge note", "text", redactDiagnostic(e.Text)) - } -} - -// Backend diagnostics can repeat authorization capabilities. Keep the link in -// the interactive event only; even debug logs are routinely 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) login(link connection.LoginLink) { e(connection.Login(link)) } - -type tsnetNode struct { - server *tsnet.Server -} - -// BringUp waits for the node to be usable and reports what it is waiting on, -// off 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, which arrives as a terminal "IPN bus consumer fell behind" on a login -// the user did nothing wrong in. -// -// Taking the wait means taking what Up did with it: a terminal ErrMessage, and -// the check that a Running node actually has an address. resetServeStateOnce -// is not ours to keep; 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 too: health changes reach every watcher regardless of - // mask, but a login already broken before this watch started shows up only - // in the initial one, which 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) -} - -// notifier is the part of an IPN bus watch the bring-up reads, so the loop can -// be exercised against a recorded bus. -type notifier interface { - Next() (ipn.Notify, error) -} - -// bringUp waits for Running on one watch, naming each wait as it is entered. -// The link comes off the bus rather than tsnet's five second poll loop, which -// 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 notifier, statusOf func(context.Context) (*ipnstate.Status, error), ev events) (*ipnstate.Status, error) { - reporter := loginReporter{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) - } - reporter.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 - } -} - -// loginReporter turns IPN bus notifications into the phases a connection -// attempt reports, holding the last one because the bus repeats states. -// -// ipn.NeedsLogin covers two waits that look identical and are not: before a -// BrowseToURL the control plane has not answered and there is nothing to do, -// after it everything is waiting on the user. Reporting the backend state made -// a 29 second registration indistinguishable from someone who wandered off. -type loginReporter struct { - ev events - phase connection.Phase - // loginBroken is 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 *loginReporter) 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 *loginReporter) notify(n *ipn.Notify) { - if n == nil { - return - } - if n.State != nil { - // The raw state, not just the phase: NoState and NeedsLogin are one - // phase on screen on purpose and 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, and NoState is the one that matters: a bridge that never - // logged in sits there for the whole of POST /machine/register, so - // it is the wait and not a not-started-yet. Tailscale's own comment - // reads "UIs should print Loading..." (ipnlocal/local.go). - r.enter(connection.AwaitingLoginLink) - case ipn.NeedsMachineAuth: - // 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 { - // Record the rejection reason, never the authorization capability. - slog.Error("unusable login link from the control plane", "err", err) - // Not fatal to the login: tsnet keeps printing its own copy, and - // the user can still finish by hand. Worth saying, because 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.login(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 sending 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. -// -// login-state only. The other warnables describe a node that is up and -// imperfect, and would bury the one line that is this attempt's business. -func (r *loginReporter) 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() -} - -// 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, - peerWait: bridgePeerWaitWindow, - peerWaitInterval: bridgePeerWaitInterval, - nodes: make(map[string]*Machine), - tailnets: make(map[string]string), - } - m.newNode = 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} - } - m.shutdown = sync.OnceValue(m.close) - 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, emit func(connection.Event)) (string, error) { - if m == nil { - return "", fmt.Errorf("bridge manager is not configured") - } - if err := validateBridgeID(bridge.ID); err != nil { - return "", err - } - ev := sink(emit) - target, err := parseTarget(remoteURL) - if err != nil { - return "", err - } - - ctx, rt, err := m.acquire(ctx, bridge.ID) - if err != nil { - return "", err - } - defer m.release(rt) - status, err := m.runningNode(ctx, bridge, rt, ev) - if err != nil { - return "", err - } - // Here rather than in runningNode, which returns immediately for a node - // already up: a reused bridge would otherwise report nothing while the - // first dial waits for the target to appear in its peer map. - ev.enter(connection.FindingEndpoint) - if m.debug { - // Up deliberately returns status without peers. Full status 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. - if fullStatus, err := rt.node.Status(ctx); err != nil { - ev.note("Could not read bridge network status: " + err.Error()) - } else { - status = fullStatus - } - logBridgeStatus(ev, status, target) - } - - if err := ctx.Err(); err != nil { - return "", err - } - key := target.String() - if proxy := rt.proxies[key]; proxy != nil { - return proxy.localURL, nil - } - - proxy, err := m.startProxy(rt, target) - if err != nil { - return "", err - } - rt.proxies[key] = proxy - ev.note("Listening on " + proxy.localURL) - return proxy.localURL, nil -} - -// initNode constructs a node without waiting for login. The Machine's turn is -// held by the caller; only Activate follows initialization with Up. -func (m *Manager) initNode(bridge config.Bridge, rt *Machine, ev events) error { - rt.ev.use(ev) - if rt.node != nil { - return nil - } - stateDir, err := config.BridgeStateDir(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. 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 m.debug { - events(rt.ev.emit).notef(format, args...) - } - } - userLogf, debugLogf := logNotes, logNotes - rt.node = m.newNode(bridge, stateDir, userLogf, debugLogf) - if rt.node == nil { - return fmt.Errorf("bridge node is not configured") - } - return nil -} - -// runningNode waits for an uncached node to become usable while holding its -// Machine's turn. A failed Up finishes cleanup before another attempt enters. -func (m *Manager) runningNode(ctx context.Context, bridge config.Bridge, rt *Machine, ev events) (*ipnstate.Status, error) { - if rt.node != nil { - rt.ev.use(ev) - return nil, nil - } - if err := m.initNode(bridge, rt, ev); err != nil { - return nil, err - } - - ev.enter(connection.StartingMachine) - - // BringUp blocks until the node is Running, which for a bridge that has - // never logged in means blocking until the user visits a link nothing has - // shown them yet. It reports the wait off the watch it is waiting on. - // - // Timed because this is the wait every "it just sat there" report is - // about, and the number is the difference between a slow control plane and - // a login link the user never saw. - start := time.Now() - status, err := rt.node.BringUp(ctx, ev) - if err != nil { - slog.Error("bridge node did not come up", "bridge", bridge.ID, "after", time.Since(start), "err", redactDiagnostic(err.Error())) - return nil, errors.Join(err, rt.close()) - } - slog.Info("bridge node up", "bridge", bridge.ID, "after", time.Since(start)) - - // Up returns the login status, so the tailnet this bridge reaches costs no - // extra call. The connection picker names it on rows the user has not - // connected to yet. - if status != nil && status.CurrentTailnet != nil && status.CurrentTailnet.Name != "" { - m.mu.Lock() - if m.tailnets == nil { - m.tailnets = make(map[string]string) - } - m.tailnets[bridge.ID] = status.CurrentTailnet.Name - m.mu.Unlock() - } - return status, nil -} - -// Tailnet returns the network the bridge's node logged in to during this -// session, or "" when it has not been started or reported one. -func (m *Manager) Tailnet(bridgeID string) string { - if m == nil { - return "" - } - m.mu.Lock() - defer m.mu.Unlock() - return m.tailnets[bridgeID] -} - -// SwitchTailnet logs the bridge out of the tailnet it is on and discards its -// node, so the next Activate asks for a new login. -// -// Logout only needs an initialized LocalAPI. Waiting for Running first would -// demand authorization of an expired or unapproved identity just to leave it. -func (m *Manager) SwitchTailnet(ctx context.Context, bridge config.Bridge, emit func(connection.Event)) error { - if m == nil { - return fmt.Errorf("bridge manager is not configured") - } - if err := validateBridgeID(bridge.ID); err != nil { - return err - } - ev := sink(emit) - ctx, rt, err := m.acquire(ctx, bridge.ID) - if err != nil { - return err - } - defer m.release(rt) - if err := m.initNode(bridge, rt, ev); err != nil { - return err - } - - ev.note("Logging bridge " + bridge.Name + " out of its tailnet ...") - logoutErr := rt.node.Logout(ctx) - - closeErr := rt.close() - m.forget(bridge.ID) - - 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 -} - -// Close shuts down all active reverse proxies and tsnet nodes. Concurrent and -// subsequent callers wait for the same cleanup and receive the same result. -func (m *Manager) Close() error { - if m == nil || m.shutdown == nil { - return nil - } - return m.shutdown() -} - -func (m *Manager) close() error { - m.mu.Lock() - nodes := m.nodes - m.nodes = nil - for _, rt := range nodes { - if rt.cancel != nil { - rt.cancel() - } - } - m.mu.Unlock() - - var errs []error - for _, rt := range nodes { - rt.turn <- struct{}{} - errs = append(errs, rt.close()) - <-rt.turn - } - m.mu.Lock() - clear(m.tailnets) - m.mu.Unlock() - return errors.Join(errs...) -} - -// closeProxy shuts down one localhost reverse proxy. An already-closed server -// or listener is not a failure: Close and SwitchTailnet can both reach the -// same proxy. -func closeProxy(proxy *proxyRuntime) error { - var errs []error - 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) - } - 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 -} - -// startProxy builds the reverse proxy for one target on rt's node. It reports -// through rt because the proxy is cached and will still be serving long after -// the connection that asked for it has gone. -func (m *Manager) startProxy(rt *Machine, target *url.URL) (*proxyRuntime, error) { - node, ev := rt.node, events(rt.ev.emit) - debug := m.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, - m.peerWait, - m.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", 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) - -// dialViaNode dials address over the bridge's node, resolving a name against -// the node's own peer map first and dialing the IP it finds. -// -// Handing the name to tsnet is what made a first connection hang for 30s: until -// the netmap lands its resolver falls through to the host resolver, which on a -// machine already on a tailnet answers with a same-named node on the wrong one. -// Short aliases use this node's current tailnet suffix; a shared peer requires -// 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 - } - // Not every target is a tailnet node: a subnet router or the tailnet's - // own DNS can serve it. Those resolve only the way tsnet resolves, so - // fall through and say so, since 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 resolves short names only within the current tailnet's MagicDNS -// suffix. A shared-in peer can have the same first label but belongs to another -// tailnet; reaching it requires its explicit 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/tui/main_test.go b/internal/tui/main_test.go new file mode 100644 index 0000000..ee3630a --- /dev/null +++ b/internal/tui/main_test.go @@ -0,0 +1,21 @@ +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") + code := m.Run() + os.RemoveAll(tmp) + os.Exit(code) +} From 4abd4d217ba010bd84da9e6cfbf630a001eadddc Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Mon, 21 Sep 2026 15:50:43 +0000 Subject: [PATCH 47/69] bridges,tui: give Machine its operations, retire Manager, decide nothing in the TUI Manager grew from four fields to eight the day after ADR 0001 decision 6 said it would not, every behaviour the model gives Machine was a Manager method with the Machine passed in, and the lock was named acquire and documented as "a cancellable turn", a phrase in nobody's vocabulary that had reached ADR 0003 and the contracts. The TUI meanwhile decided the domain rules at twenty-eight sites: when a removal destroys a device, when the Bridge record goes, when the joined tailnet is recorded, when an edit commits. Machine now owns Open, RouteTo, LeaveTailnet, Destroy, Close and Tailnet with the one-at-a-time rule private. Machines is the collection. Bridging is the stateless domain service for what belongs to no single aggregate, and Attempt is the ConnectionAttempt entity; the TUI's activation keeps only presentation state and calls the service. Every service operation that waits on the network is split from the one that writes settings, because bubbletea runs commands off the update loop and config.Global has no lock; the race detector is the gate. Renaming acquire alone would have fixed the word and kept the shape. Leaving it for APT-330, which deletes Manager anyway, would have handed it a bigger rebase and left the model wrong on main meanwhile. ADR 0005 records the decision; revisit when APT-330 lands or an object owning the current Gateway exists. --- cmd/aperture/main.go | 6 +- docs/adr/0001-connection-bounded-context.md | 1 + docs/adr/0005-machine-owns-its-operations.md | 83 ++++ docs/specs/bridge-resource-lifecycle.md | 8 +- docs/specs/connection-context-map.md | 4 +- docs/specs/connection-contracts.md | 20 +- docs/specs/connection-domain-model.md | 140 +++--- internal/bridges/bridging.go | 426 +++++++++++++++++++ internal/bridges/bridging_test.go | 91 ++++ internal/bridges/events.go | 80 ++++ internal/bridges/helpers_test.go | 50 +++ internal/bridges/lifecycle_test.go | 38 +- internal/bridges/machine.go | 390 ++++++++++++----- internal/bridges/machine_test.go | 46 +- internal/bridges/machines.go | 124 ++++++ internal/bridges/node.go | 225 ++++++++++ internal/bridges/route.go | 285 +++++++++++++ internal/bridges/security_test.go | 8 +- internal/config/endpoint.go | 2 +- internal/config/global.go | 21 +- internal/tui/connection_test.go | 13 +- internal/tui/menus.go | 82 +--- internal/tui/removal.go | 180 +++----- internal/tui/removal_test.go | 12 +- internal/tui/tui.go | 373 +++++----------- internal/tui/tui_test.go | 118 ++--- 26 files changed, 2054 insertions(+), 772 deletions(-) create mode 100644 docs/adr/0005-machine-owns-its-operations.md create mode 100644 internal/bridges/bridging.go create mode 100644 internal/bridges/bridging_test.go create mode 100644 internal/bridges/events.go create mode 100644 internal/bridges/helpers_test.go create mode 100644 internal/bridges/machines.go create mode 100644 internal/bridges/node.go create mode 100644 internal/bridges/route.go diff --git a/cmd/aperture/main.go b/cmd/aperture/main.go index 8a69f64..a28197b 100644 --- a/cmd/aperture/main.go +++ b/cmd/aperture/main.go @@ -208,8 +208,8 @@ func main() { os.Exit(1) } - bridgeManager := bridges.NewManager(g.Debug) - p := tea.NewProgram(tui.NewModel(g, buildVersion, bridgeManager, start)) + machines := bridges.NewMachines(g.Debug) + p := tea.NewProgram(tui.NewModel(g, buildVersion, machines, start)) var exitCode int if _, err := p.Run(); err != nil { @@ -217,7 +217,7 @@ func main() { 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 diff --git a/docs/adr/0001-connection-bounded-context.md b/docs/adr/0001-connection-bounded-context.md index 65ddfba..ace6cc2 100644 --- a/docs/adr/0001-connection-bounded-context.md +++ b/docs/adr/0001-connection-bounded-context.md @@ -50,6 +50,7 @@ Model, language and the anti-corruption layer: 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 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..573d29c --- /dev/null +++ b/docs/adr/0005-machine-owns-its-operations.md @@ -0,0 +1,83 @@ +# 0005. Machine owns its operations, Machines holds them, Bridging decides between Bridge and Machine + +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. `Bridging` is a stateless domain service for the transitions that belong + to no single aggregate: an Attempt reaching an Endpoint and committing it, + the tailnet joined recorded on the Bridge, a Bridge removed only after its + Machine is destroyed. +4. `Attempt` (the model's ConnectionAttempt) lives in `internal/bridges`. The + TUI's `activation` holds presentation state only. +5. Every service operation that waits on the network is split from the one + that writes settings. `Run` and `Destroy` may run anywhere and write + nothing; `Begin`, `Commit`, `Fail`, `Abandon`, `Destroys` and `Forget` 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`. `Bridging.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. `Begin` and `Fail` report +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 `Bridging` should survive that with its signatures. Or an object +owning the current Gateway exists, at which point `connected` leaves the TUI +and `InvalidatesActive` and `Fail` lose their reason to report a bool. diff --git a/docs/specs/bridge-resource-lifecycle.md b/docs/specs/bridge-resource-lifecycle.md index 08f5bca..8c6c570 100644 --- a/docs/specs/bridge-resource-lifecycle.md +++ b/docs/specs/bridge-resource-lifecycle.md @@ -12,14 +12,14 @@ their disk. Decision: [ADR 0002](../adr/0002-bridge-removal-destroys-the-machine | `$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 `newNode` (`manager.go:357`) sets no +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. -`SwitchTailnet` (`manager.go:528`) is the only caller of `Logout`, and its +`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. @@ -48,8 +48,8 @@ login nobody finished, and is the one case with no device to clean up. `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. -`Manager.Destroy` is the entry point, because the Machine's turn and its cache -entry are `Manager` state and destruction has to hold the turn like every other +`Machine.Destroy` is the entry point, reached through `Bridging.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 diff --git a/docs/specs/connection-context-map.md b/docs/specs/connection-context-map.md index 9cd4c9d..0c22eff 100644 --- a/docs/specs/connection-context-map.md +++ b/docs/specs/connection-context-map.md @@ -21,6 +21,8 @@ The context boundaries below also describe the proposed broader event refactor. | 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. | +| Bridging | The service between a Bridge, its Machine and the Endpoints reached through it: connecting, switching tailnet, removing. Stateless. | The Machine's own operations, which stay on the Machine. | | 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. | @@ -39,7 +41,7 @@ it for our node and for every peer in the netmap at once. | Context | Subdomain | Owns | Lives in | |---|---|---|---| -| Connection | Core | Connection Attempt, Phase, Progress, Login Link, Gateway, Route, Machine | `internal/bridges`, the activation half of `internal/tui` | +| Connection | Core | Connection Attempt, Phase, Progress, Login Link, Gateway, Route, Machine, Machines, Bridging | `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` | diff --git a/docs/specs/connection-contracts.md b/docs/specs/connection-contracts.md index 6a33594..3899d4a 100644 --- a/docs/specs/connection-contracts.md +++ b/docs/specs/connection-contracts.md @@ -26,7 +26,7 @@ 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 `Manager.Close` joins the same cleanup and +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 @@ -40,12 +40,14 @@ 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. -`Manager.Activate` and `Manager.SwitchTailnet` keep their public signatures. -Both acquire a cancellable turn on the Machine identified by Bridge ID. No -proxy may be created before `Up` succeeds, and no new node may use its state -directory before the prior node finishes closing. `SwitchTailnet` calls the -node's `Logout`, whose LocalAPI initialization does not wait for `Running`. -`Close` cancels operations, waits for cleanup and permanently closes the manager. +`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. +`Bridging` composes these for the TUI; see the domain model for its table. No new domain event or external API is introduced. A pending edit is committed by the existing successful `endpointActivationResult`; failed and stale results @@ -92,7 +94,7 @@ 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 -`Manager.Tailnet(bridgeID)` and then `g.SetBridgeTailnet`. The TUI is loading, +`Machine.Tailnet` and then `g.SetBridgeTailnet`, now inside `Bridging.Commit`. Before that the TUI was loading, calling and committing, which is orchestration, but it is also deciding the rule, which is not. @@ -116,7 +118,7 @@ not, and the gap is deliberate rather than unfinished: | `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 | Blocked on the Bridge/tailnet ownership decision above. `Manager.Tailnet` and `recordBridgeTailnet` still carry it. | +| `TailnetJoined` | Not built | `Bridging.Commit` carries the fact as a field of `Verified`; 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 diff --git a/docs/specs/connection-domain-model.md b/docs/specs/connection-domain-model.md index ac03b3a..cc44084 100644 --- a/docs/specs/connection-domain-model.md +++ b/docs/specs/connection-domain-model.md @@ -22,48 +22,53 @@ 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. -Manager's shutdown is one session-lifetime operation. Its transient -`shutdown func() error` uses the standard library's once-result primitive to -join concurrent callers and retain the same error. The first caller stops new -acquisitions and cancels operations; every caller waits until all Machines have -finished closing. No new domain event, JSON field, or migration is introduced. +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. -`activation` remains the ConnectionAttempt entity. Its fields are `id int`, -`endpoint config.Endpoint`, `label string`, `started time.Time`, -`cancel context.CancelFunc`, `ephemeral bool`, `replaces *config.Endpoint`, -`logCh chan bridgeLine`, `logCtx context.Context`, `phase connection.Phase`, -`phaseSet bool`, `authURL string`, `copied bool`, and `override textField`. +`bridges.Attempt` is the ConnectionAttempt entity as built. Its fields are +`Endpoint config.Endpoint`, `InvalidatesActive 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. - -`Machine` is an entity, identified by the Bridge ID key in `Manager.nodes`. -It owns `node tailnetNode`, `proxies map[string]*proxyRuntime`, `ev *liveEvents`, -`turn chan struct{}`, and `cancel context.CancelFunc`. The first three are the -existing runtime; `turn` grants one operation at a time and `cancel` allows -manager shutdown to interrupt that operation. These adapter fields remain in +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. Activation holds the -Machine's turn through startup and proxy creation. A failed startup closes the -node before releasing the turn. Logout initializes the LocalAPI without waiting -for authorization, then closes the node and all proxies. The Machine returns -to idle and may create a new node on the next activation. Manager shutdown -cancels current operations, waits for their turns, closes Machines, and rejects -new operations. A waiting operation can cancel without affecting the owner. - -Before dispatching a tailnet switch, the TUI marks the active destination -unverified if it shares that Bridge ID. 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. +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. + +`Bridging.Begin` 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. 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 @@ -220,23 +225,25 @@ two a URL is, which `ApertureHost` cannot. 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 cached by bridge id and reused across -Attempts (`Manager.nodes`), so it cannot be owned by any one of them. +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 | |---|---|---| -| `BridgeID` | `string` | Identity. At most one Machine per Bridge. | -| `Tailnet` | `string` | The network joined, empty until the netmap lands. | -| `Routes` | `map[string]*Route` | Keyed by target URL. | +| `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) (<-chan Event, error)`, `RouteTo(Endpoint) (Route, error)`, -`LeaveTailnet(ctx) error`, `Close() error`. +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. -- `LeaveTailnet` destroys the Machine: credentials live behind the node's own LocalAPI, so a close without a logout silently reuses them next time. +- 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. Today there are two of ours plus one of tsnet's; see the ADR. +- Exactly one IPN bus watch per Machine. ### States @@ -261,16 +268,52 @@ Entity, inside the Machine aggregate. The local door to one Endpoint. | Field | Type | |---|---| -| `LocalURL` | `string`, a `127.0.0.1:` listener | -| `Target` | `config.Endpoint` | +| `LocalURL` | `string`, a `127.0.0.1:` listener. The Gateway a client uses. | -Behaviors: `Gateway() Gateway`, `Close() error`. +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. + +## Bridging + +Domain service. Stateless over `Machines` and Settings. It exists because the +transitions it owns belong to no single aggregate: an Attempt reaches an +Endpoint through a Machine and then commits to Settings; joining a tailnet is +a Machine fact recorded on a Bridge; removing a Bridge destroys its Machine +first (ADR 0002). Before it, the TUI decided all three. + +| Operation | Runs on | Does | +|---|---|---| +| `Begin(ep, switchTailnet, replacing)` | update loop | Writes an unsaved Endpoint as the attempt's candidate, clears the Bridge's recorded tailnet before a switch, marks the attempt as invalidating the active destination. | +| `Retarget(a, next)`, `Edit(current, ep, next)` | update loop | Replace a candidate nobody chose; keep the original of a pending edit. | +| `Run(ctx, a, emit)` | any goroutine | Leaves the tailnet if asked, opens the Machine, routes, asks the Aperture for models. Writes nothing. | +| `Commit(a, verified)` | update loop | One settings write for the edit; records the tailnet on the Bridge; sets the Gateway and providers clients launch against. | +| `Fail(a)` | update loop | Keeps the candidate; reports whether the active destination is now unverified. | +| `Abandon(a)` | update loop | Removes the candidate this attempt added, never the active endpoint. | +| `Destroys(rem)` | update loop | Whether rem takes a device off a tailnet, and whether rem may go at all. | +| `Destroy(ctx, rem, emit)` | any goroutine | The bounded logout (ADR 0002 decision 6). Writes nothing. | +| `Forget(rem, destroyErr)` | update loop | Drops endpoint then bridge, or keeps both when the tailnet refused; an expired wait drops them and returns `*Unconfirmed`. | +| `Tailnet(bridge)` | update loop | What the running Machine reports, else what was saved. | + +The split into a waiting half and a writing half is not stylistic. 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. @@ -384,11 +427,12 @@ classDiagram ## Open, not assumed -- Two cross-aggregate reactions have no owning object, found by the - [contracts pass](connection-contracts.md): recording the tailnet a Machine - joined onto its Bridge, and deciding which Gateway is current for the next - client launch. Both live in the TUI today, which orchestrates but should not - decide. Needs resolving before the events are implemented. +- Which Gateway is current for the next client launch is now `Bridging.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 what `Begin` and `Fail` report. 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. diff --git a/internal/bridges/bridging.go b/internal/bridges/bridging.go new file mode 100644 index 0000000..767136c --- /dev/null +++ b/internal/bridges/bridging.go @@ -0,0 +1,426 @@ +package bridges + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/connection" +) + +const ( + providerFetchTimeout = 10 * time.Second + bridgeProviderFetchTimeout = 30 * time.Second +) + +// 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 + +// Bridging is the Connection context's domain service. It connects the user +// to an Aperture from an Endpoint, through a Bridge's Machine when the +// Endpoint names one, and keeps the Bridge record and its Machine in +// agreement: joining records the tailnet on the Bridge, switching clears it, +// removing the Bridge destroys the Machine first (ADR 0002). It holds no state +// of its own. +// +// Every operation that waits on the network is split in two. The waiting half +// (Run, Destroy) takes a context and may run on any goroutine. The half that +// reads or writes settings (Begin, Commit, Abandon, Forget) must run where +// settings are read, which for the TUI is its update loop: nothing else +// serializes access to config.Global. +type Bridging struct { + Machines *Machines + Settings *config.Global +} + +// Attempt is one try at reaching an Aperture from one Endpoint. It remembers +// what it wrote to settings on the user's behalf, so that abandoning it can +// take that back out, and which Endpoint it is an edit of, so that success can +// commit the edit and the removal of the original in one write (ADR 0003). +type Attempt struct { + Endpoint config.Endpoint + // InvalidatesActive reports that starting this attempt leaves the active + // destination unverified: the Machine it launches through is being logged + // out, and cancellation cannot prove the logout did not run (ADR 0003). + InvalidatesActive bool + + bridge config.Bridge + // ephemeral: Begin wrote Endpoint into settings so the failure screen has + // something to name, retry and edit. Abandon removes it; failure keeps it. + ephemeral bool + replaces *config.Endpoint + switchTailnet bool +} + +// Bridge is the Bridge this attempt connects through, zero 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 } + +// Retry is the same attempt again. A tailnet switch is not repeated: it ran, +// or failed, the first time, and the retry is about reaching the Endpoint. +func (a *Attempt) Retry() *Attempt { + next := *a + next.switchTailnet = false + next.InvalidatesActive = false + return &next +} + +// Verified is what a successful attempt produced: the Gateway a client sends +// requests to, the providers it answered with and, through a Bridge, the +// tailnet the Machine joined. +type Verified struct { + Gateway string + Tailnet string + Providers []config.ProviderInfo +} + +// Begin 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; +// 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: an abandoned login would +// otherwise leave the picker naming a tailnet the bridge has already left. +func (b Bridging) Begin(ep config.Endpoint, switchTailnet bool, replacing *config.Endpoint) (*Attempt, error) { + a := &Attempt{Endpoint: ep, replaces: replacing} + if ep.BridgeID != "" { + bridge, ok := b.Settings.Bridge(ep.BridgeID) + if !ok { + return nil, fmt.Errorf("bridge %s is not configured", ep.BridgeID) + } + a.bridge = bridge + if switchTailnet { + if err := b.Settings.SetBridgeTailnet(ep.BridgeID, ""); err != nil { + return nil, err + } + a.switchTailnet = true + a.InvalidatesActive = ep.BridgeID == b.Settings.ActiveEndpoint().BridgeID + } + } + if !b.configured(ep) { + if err := b.Settings.UpsertEndpoint(ep); err != nil { + return nil, err + } + a.ephemeral = true + } + return a, nil +} + +// Retarget swaps the Endpoint an attempt probes for one 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. The same Endpoint again is a retry. +func (b Bridging) Retarget(a *Attempt, next config.Endpoint) (*Attempt, error) { + if config.SameEndpoint(next, a.Endpoint) { + return a.Retry(), nil + } + ephemeral := !b.configured(next) + switch { + case a.ephemeral: + if err := b.Settings.ReplaceEndpoint(a.Endpoint, next); err != nil { + return nil, err + } + case ephemeral: + if err := b.Settings.UpsertEndpoint(next); err != nil { + return nil, err + } + } + n := &Attempt{Endpoint: next, replaces: a.replaces, ephemeral: ephemeral} + if next.BridgeID != "" { + bridge, ok := b.Settings.Bridge(next.BridgeID) + if !ok { + return nil, fmt.Errorf("bridge %s is not configured", next.BridgeID) + } + n.bridge = bridge + } + return n, nil +} + +// Edit verifies next before removing ep, keeping ep until it does (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 (b Bridging) Edit(current *Attempt, ep, next config.Endpoint) (*Attempt, error) { + if current != nil && config.SameEndpoint(current.Endpoint, ep) && current.replaces != nil { + return b.Retarget(current, next) + } + return b.Begin(next, false, &ep) +} + +// Run carries the attempt to a verified Gateway or an error, reporting each +// wait on emit. It writes nothing: Commit does, once the caller knows the +// result is still wanted. +func (b Bridging) Run(ctx context.Context, a *Attempt, emit func(connection.Event)) (Verified, error) { + if a.Endpoint.BridgeID == "" { + provs, err := fetchProviders(ctx, a.Endpoint.URL, providerFetchTimeout) + if err != nil { + return Verified{}, err + } + return Verified{Gateway: a.Endpoint.URL, Providers: provs}, nil + } + // Stamps 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", a.Endpoint.URL, "bridge", a.bridge.ID, "switchTailnet", a.switchTailnet) + mc, err := b.Machines.For(a.bridge) + if err != nil { + return Verified{}, 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 Verified{}, err + } + } + if err := mc.Open(ctx, emit); err != nil { + return Verified{}, err + } + route, err := mc.RouteTo(ctx, a.Endpoint.URL, emit) + if err != nil { + return Verified{}, 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 Verified{}, fmt.Errorf("bridge %s could not reach %s: %w", a.bridge.Name, a.Endpoint.URL, err) + } + return Verified{Gateway: route.LocalURL, Tailnet: mc.Tailnet(), Providers: provs}, nil +} + +// Commit makes a verified 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 and providers become +// what clients launch against. +func (b Bridging) Commit(a *Attempt, v Verified) error { + g := b.Settings + if !config.SameEndpoint(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.Endpoint.BridgeID != "" && v.Tailnet != "" { + // A failed write is not worth interrupting a connection that worked. + if err := g.SetBridgeTailnet(a.Endpoint.BridgeID, v.Tailnet); err != nil { + slog.Warn("could not record the bridge's tailnet", "bridge", a.Endpoint.BridgeID, "err", err) + } + } + g.ApertureHost = v.Gateway + g.Providers = v.Providers + return nil +} + +// Fail is the attempt not verifying. The candidate stays: the failure screen +// names it for retry and edit (ADR 0003). Reports whether the active +// destination is unverified as a result, which it is when the failing +// Endpoint is the active one. +func (b Bridging) Fail(a *Attempt) (invalidatesActive bool) { + return config.SameEndpoint(a.Endpoint, b.Settings.ActiveEndpoint()) +} + +// Abandon is the user giving up on the attempt. The candidate it added comes +// back out of settings, so nothing the user did not choose is left behind. +func (b Bridging) Abandon(a *Attempt) error { + if a == nil || !a.ephemeral { + return nil + } + a.ephemeral = false + return b.Settings.DropEndpoint(a.Endpoint) +} + +// Tailnet is the network a Bridge reaches, preferring what its running +// Machine reports to what was saved: a bridge that switched tailnets this +// session leaves a stale name on disk until the next verified connection +// rewrites it. +func (b Bridging) Tailnet(bridge config.Bridge) string { + if mc := b.Machines.lookup(bridge.ID); mc != nil { + if name := mc.Tailnet(); name != "" { + return name + } + } + return bridge.Tailnet +} + +// Removal is what one delete is about: the Bridge, and the Endpoint that was +// the last reason to keep it. Either can be absent. +type Removal struct { + Bridge config.Bridge + Endpoint *config.Endpoint +} + +// Unconfirmed is a removal the tailnet did not confirm within the wait. The +// local records are gone; the device may not be, and the user has to be told +// where to look for it. +type Unconfirmed struct { + Bridge config.Bridge + Wait time.Duration + Err error +} + +func (e *Unconfirmed) Error() string { + return fmt.Sprintf("the tailnet did not confirm within %s: %v", e.Wait, e.Err) +} + +func (e *Unconfirmed) Unwrap() error { return e.Err } + +// Destroys reports whether removing rem takes a Machine off a tailnet: rem is +// the Bridge's last Endpoint and the Bridge has started a Machine. A Bridge +// that never started has no device, and must not start one to find out. An +// error means rem may not go at all. +func (b Bridging) Destroys(rem Removal) (bool, error) { + if err := b.removable(rem); err != nil { + return false, err + } + if rem.Bridge.ID == "" || !HasMachine(rem.Bridge.ID) { + return false, nil + } + for _, ep := range b.Settings.Settings.Endpoints { + if ep.BridgeID != rem.Bridge.ID { + continue + } + if rem.Endpoint == nil || !config.SameEndpoint(ep, *rem.Endpoint) { + return false, nil + } + } + return true, nil +} + +// Destroy takes rem's Machine off its tailnet, waiting at most destroyTimeout +// for the tailnet to confirm. Settings are untouched: Forget drops them once +// the caller has the outcome, because they are the only record that the +// device exists. Only for a removal Destroys said yes to. +func (b Bridging) Destroy(ctx context.Context, rem Removal, emit func(connection.Event)) error { + mc, err := b.Machines.For(rem.Bridge) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(ctx, destroyTimeout) + defer cancel() + err = mc.Destroy(ctx, emit) + if err != nil && ctx.Err() != nil { + return &Unconfirmed{Bridge: rem.Bridge, Wait: destroyTimeout, Err: err} + } + return err +} + +// Forget drops the records rem covers, endpoint first: a Bridge an Endpoint +// still points at cannot be removed. destroyErr is Destroy's outcome, nil for +// a removal with nothing to destroy. A refusal keeps everything and is +// returned as is: the device is still on the tailnet and settings are the +// only thing naming it. A wait that expired drops the records and returns the +// *Unconfirmed, because the device may have outlived the wait. +func (b Bridging) Forget(rem Removal, destroyErr error) error { + var unconfirmed *Unconfirmed + if destroyErr != nil && !errors.As(destroyErr, &unconfirmed) { + return destroyErr + } + if err := b.removable(rem); err != nil { + return err + } + g := b.Settings + if rem.Endpoint != nil { + if err := g.DropEndpoint(*rem.Endpoint); err != nil { + return err + } + } + // Settings hold two objects where the picker shows one row, so removing + // the endpoint alone left the bridge re-listed as a bare "Connect via" + // row: to the user the row moved instead of going. A bridge two endpoints + // reach through stays. + if rem.Bridge.ID != "" && !b.bridgeUsed(rem.Bridge.ID) { + if err := g.RemoveBridge(rem.Bridge.ID); err != nil { + return err + } + } + return destroyErr +} + +// removable is why rem may not go: it is the active endpoint, which is the +// connection the user falls back to, or a bare Bridge some Endpoint still +// reaches through. +func (b Bridging) removable(rem Removal) error { + if rem.Endpoint != nil && config.SameEndpoint(*rem.Endpoint, b.Settings.ActiveEndpoint()) { + return errors.New("connect to another endpoint before removing the active one") + } + if rem.Endpoint == nil && rem.Bridge.ID != "" { + for _, ep := range b.Settings.Settings.Endpoints { + if ep.BridgeID == rem.Bridge.ID { + return fmt.Errorf("bridge %s is used by endpoint %s; remove that connection instead", rem.Bridge.Name, ep.URL) + } + } + } + return nil +} + +func (b Bridging) bridgeUsed(bridgeID string) bool { + for _, ep := range b.Settings.Settings.Endpoints { + if ep.BridgeID == bridgeID { + return true + } + } + return false +} + +func (b Bridging) configured(want config.Endpoint) bool { + for _, ep := range b.Settings.Settings.Endpoints { + if config.SameEndpoint(ep, want) { + return true + } + } + return false +} + +// fetchProviders asks an Aperture what it serves. This is the attempt's +// AskingForModels phase and the verification everything else waits on. +func fetchProviders(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 + } + // 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, url, detail) + } + return nil, fmt.Errorf("unexpected status %d from %s", resp.StatusCode, url) + } + 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/bridging_test.go b/internal/bridges/bridging_test.go new file mode 100644 index 0000000..39fb9fd --- /dev/null +++ b/internal/bridges/bridging_test.go @@ -0,0 +1,91 @@ +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 +} diff --git a/internal/bridges/events.go b/internal/bridges/events.go new file mode 100644 index 0000000..1583f03 --- /dev/null +++ b/internal/bridges/events.go @@ -0,0 +1,80 @@ +package bridges + +import ( + "log/slog" + "regexp" + "sync" + + "github.com/tailscale/aperture-cli/internal/connection" +) + +// liveEvents points a node's long-lived reporting at whichever connection is +// using it now. Nodes and proxies outlive the connection that built them, and +// closures that captured that connection's sink went on writing to a channel +// nobody read, losing every later dial failure and proxy error. +// +// Nothing clears it when a connection ends: a finished sink discards what it is +// given, and a clear needs a lifecycle hook only the Attempt can own. +type liveEvents struct { + mu sync.Mutex + ev events +} + +func (l *liveEvents) use(ev events) { + l.mu.Lock() + defer l.mu.Unlock() + l.ev = ev +} + +// emit has the events signature, so callers keep note and notef. +func (l *liveEvents) emit(e connection.Event) { + l.mu.Lock() + ev := l.ev + l.mu.Unlock() + if ev != nil { + ev(e) + } +} + +// events is where a bridge reports what it is doing. This package translates +// the tailnet's vocabulary into it and publishes nothing else, so no caller has +// to recover meaning by matching prose from inside a vendored package. +type events func(connection.Event) + +// sink returns a usable events, so callers that want none can pass nil. +func sink(emit func(connection.Event)) events { + return func(e connection.Event) { + logEvent(e) + if emit != nil { + emit(e) + } + } +} + +// logEvent copies a connection event into 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 are 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 e.Kind { + case connection.PhaseEntered: + slog.Info("bridge phase", "phase", e.Phase) + case connection.LoginRequired: + slog.Info("bridge needs login") + default: + slog.Debug("bridge note", "text", redactDiagnostic(e.Text)) + } +} + +// Backend diagnostics can repeat authorization capabilities. Keep the link in +// the interactive event only; even debug logs are routinely 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) login(link connection.LoginLink) { e(connection.Login(link)) } diff --git a/internal/bridges/helpers_test.go b/internal/bridges/helpers_test.go new file mode 100644 index 0000000..c286c5b --- /dev/null +++ b/internal/bridges/helpers_test.go @@ -0,0 +1,50 @@ +package bridges + +import ( + "context" + + "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() +} diff --git a/internal/bridges/lifecycle_test.go b/internal/bridges/lifecycle_test.go index a27ffb2..db98a2f 100644 --- a/internal/bridges/lifecycle_test.go +++ b/internal/bridges/lifecycle_test.go @@ -52,7 +52,7 @@ func TestActivateWaitsForCancelledNodeCleanup(t *testing.T) { 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 := NewManager(false) + m := NewMachines(false) calls := 0 m.newNode = func(config.Bridge, string, func(string, ...any), func(string, ...any)) tailnetNode { calls++ @@ -66,7 +66,7 @@ func TestActivateWaitsForCancelledNodeCleanup(t *testing.T) { defer cancel() first := make(chan error, 1) go func() { - _, err := m.Activate(ctx, bridge, "http://ai", nil) + _, err := activateMachine(m, ctx, bridge, "http://ai", nil) first <- err }() <-n.started @@ -78,7 +78,7 @@ func TestActivateWaitsForCancelledNodeCleanup(t *testing.T) { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) defer cancel() - url, err := m.Activate(ctx, bridge, "http://100.64.0.2", nil) + 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) } @@ -91,7 +91,7 @@ func TestActivateWaitsForCancelledNodeCleanup(t *testing.T) { if err := <-first; !errors.Is(err, context.Canceled) { t.Errorf("first activation: %v", err) } - url, err := m.Activate(context.Background(), bridge, "http://100.64.0.2", nil) + url, err := activateMachine(m, context.Background(), bridge, "http://100.64.0.2", nil) if err != nil { t.Fatal(err) } @@ -120,12 +120,12 @@ func (n *needsLoginNode) BringUp(ctx context.Context, _ events) (*ipnstate.Statu func TestSwitchTailnetDoesNotRequireAuthorization(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) n := &needsLoginNode{fakeNode: &fakeNode{}} - m := NewManager(false) + 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 := m.SwitchTailnet(ctx, config.Bridge{ID: "bridge-abcdef", Name: "Work"}, nil) + 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) } @@ -138,14 +138,14 @@ func TestCloseCancelsStartupBeforeClosingNode(t *testing.T) { } close(n.releaseUp) close(n.releaseClose) - m := NewManager(false) + 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 := m.Activate(ctx, bridge, "http://ai", nil) + _, err := activateMachine(m, ctx, bridge, "http://ai", nil) activationDone <- err }() <-n.started @@ -165,7 +165,7 @@ func TestCloseCancelsStartupBeforeClosingNode(t *testing.T) { if !n.closed { t.Error("Close returned with a live node") } - if _, err := m.Activate(context.Background(), bridge, "http://ai", nil); !errors.Is(err, net.ErrClosed) { + 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) } } @@ -188,9 +188,9 @@ func TestConcurrentCloseSharesCompletionAndError(t *testing.T) { 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 := NewManager(false) + m := NewMachines(false) m.newNode = func(config.Bridge, string, func(string, ...any), func(string, ...any)) tailnetNode { return node } - if _, err := m.Activate(context.Background(), config.Bridge{ID: "bridge-abcdef"}, "http://100.64.0.2", nil); err != nil { + 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) }) @@ -198,7 +198,7 @@ func TestConcurrentCloseSharesCompletionAndError(t *testing.T) { results := make(chan error, 2) go func() { results <- m.Close() }() <-node.closing - if _, err := m.Activate(context.Background(), config.Bridge{ID: "bridge-abcdef"}, "http://ai", nil); !errors.Is(err, net.ErrClosed) { + 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() }() @@ -224,7 +224,7 @@ func TestConcurrentCloseSharesCompletionAndError(t *testing.T) { } func TestCloseEmptyManager(t *testing.T) { - for _, m := range []*Manager{nil, new(Manager), NewManager(false)} { + for _, m := range []*Machines{nil, new(Machines), NewMachines(false)} { if err := m.Close(); err != nil { t.Errorf("closing an unused manager: %v", err) } @@ -256,11 +256,11 @@ func TestDestroyLeavesNoMachineBehind(t *testing.T) { t.Fatal("a started bridge reports no machine") } n := &fakeNode{} - m := NewManager(false) + m := NewMachines(false) m.newNode = func(config.Bridge, string, func(string, ...any), func(string, ...any)) tailnetNode { return n } defer m.Close() - if err := m.Destroy(context.Background(), bridge, nil); err != nil { + if err := destroyMachine(m, context.Background(), bridge, nil); err != nil { t.Fatalf("Destroy: %v", err) } if n.loggedOut != 1 || !n.closed || n.up != 0 { @@ -281,11 +281,11 @@ func TestDestroyKeepsStateWhenTheTailnetRefuses(t *testing.T) { bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} dir := stateDir(t, bridge.ID) n := &fakeNode{logoutErr: errors.New("control plane said no")} - m := NewManager(false) + m := NewMachines(false) m.newNode = func(config.Bridge, string, func(string, ...any), func(string, ...any)) tailnetNode { return n } defer m.Close() - if err := m.Destroy(context.Background(), bridge, nil); err == nil || !strings.Contains(err.Error(), "control plane said no") { + 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 { @@ -301,14 +301,14 @@ func TestDestroySkipsABridgeThatNeverStarted(t *testing.T) { if HasMachine(bridge.ID) { t.Fatal("an unstarted bridge reports a machine") } - m := NewManager(false) + 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 := m.Destroy(context.Background(), bridge, nil); err != nil { + if err := destroyMachine(m, context.Background(), bridge, nil); err != nil { t.Fatalf("Destroy: %v", err) } } diff --git a/internal/bridges/machine.go b/internal/bridges/machine.go index b66b5d2..77a73ea 100644 --- a/internal/bridges/machine.go +++ b/internal/bridges/machine.go @@ -5,71 +5,57 @@ import ( "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 owns the node and proxies for one bridge. Its turn covers an entire -// activation or logout, including cleanup; a cached Machine may have no node. +// Machine is what 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. It outlives any one connection attempt +// and is the aggregate root for its Routes. +// +// 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 a first one is still closing. An +// operation waiting its turn can be cancelled through its context without +// disturbing the one running; Close cancels the one running. type Machine struct { - node tailnetNode - proxies map[string]*proxyRuntime - ev *liveEvents - turn chan struct{} - // cancel is guarded by Manager.mu, so shutdown can interrupt the owner - // without waiting for its turn (which may be waiting for authorization). - cancel context.CancelFunc -} + bridge config.Bridge + // of is the collection this Machine belongs to, which holds the node + // factory and dial tuning shared by every member. + of *Machines -// acquire grants a cancellable turn on one Machine. Manager.mu only protects -// the cache and cancellation handles, never network work or turn acquisition. -func (m *Manager) acquire(ctx context.Context, bridgeID string) (context.Context, *Machine, error) { - if err := ctx.Err(); err != nil { - return nil, nil, err - } - m.mu.Lock() - if m.nodes == nil { - m.mu.Unlock() - return nil, nil, net.ErrClosed - } - rt := m.nodes[bridgeID] - if rt == nil { - rt = &Machine{ - proxies: make(map[string]*proxyRuntime), - ev: &liveEvents{}, - turn: make(chan struct{}, 1), - } - m.nodes[bridgeID] = rt - } - m.mu.Unlock() - select { - case rt.turn <- struct{}{}: - case <-ctx.Done(): - return nil, nil, ctx.Err() - } - m.mu.Lock() - defer m.mu.Unlock() - if m.nodes == nil { - <-rt.turn - return nil, nil, net.ErrClosed - } - if err := ctx.Err(); err != nil { - <-rt.turn - return nil, nil, err - } - ctx, rt.cancel = context.WithCancel(ctx) - return ctx, rt, nil + // turn is held by the operation running on this Machine. A one-slot channel + // rather than a mutex so that waiting for it can be cancelled. + turn chan struct{} + // mu guards what another goroutine reads or sets while an operation holds + // the turn: the running operation's cancel, closed and tailnet. + mu sync.Mutex + cancel context.CancelFunc + closed bool + tailnet string + + // Owned by whoever holds the turn. A Machine in the collection may have no + // node: idle after a logout, or never started. + node tailnetNode + routes map[string]*Route + ev *liveEvents } -func (m *Manager) release(rt *Machine) { - m.mu.Lock() - rt.cancel() - rt.cancel = nil - m.mu.Unlock() - <-rt.turn +func newMachine(bridge config.Bridge, ms *Machines) *Machine { + return &Machine{ + bridge: bridge, + of: ms, + turn: make(chan struct{}, 1), + routes: make(map[string]*Route), + ev: &liveEvents{}, + } } // MachineName is the hostname this bridge's node registers under, and so the @@ -90,83 +76,281 @@ func HasMachine(bridgeID string) bool { return !errors.Is(err, fs.ErrNotExist) } -// Destroy removes a bridge's machine from its tailnet and discards the state -// directory it kept the login in. It touches no settings: the Bridge record is +// begin takes the Machine for one operation and returns the context it runs +// under, which Close can cancel. +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 is the network the Machine joined, empty until it has or after it +// left. +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. An open Machine returns at once with its reporting pointed 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.use(ev) + return nil + } + if err := mc.initNode(ev); err != nil { + return err + } + + ev.enter(connection.StartingMachine) + + // BringUp blocks until the node is Running, which for a bridge that has + // never logged in means blocking until the user visits a link nothing has + // shown them yet. It reports the wait off the watch it is waiting on. + // + // Timed because this is the wait every "it just sat there" report is + // about, and the number is the difference between a slow control plane and + // 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 login status names the tailnet this bridge reaches at no extra + // call. The connection picker shows it on rows not connected to yet. + if status != nil && status.CurrentTailnet != nil && status.CurrentTailnet.Name != "" { + mc.setTailnet(status.CurrentTailnet.Name) + } + return nil +} + +// RouteTo opens, or returns, the Route to remoteURL through this Machine. The +// Machine must be open: a Route can only be created through an open Machine, +// and this never starts a node to satisfy one. +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.use(ev) + + // Reported here for a reused Machine too, which would otherwise say + // nothing while the first dial waits for the target to appear in its + // peer map. + ev.enter(connection.FindingEndpoint) + if mc.of.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 the tailnet it is on 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 removes the Machine from its tailnet and discards the state +// directory holding its login. It touches no settings: the Bridge record is // the only thing naming the device, so the caller drops it after this returns // nil and keeps it otherwise (ADR 0002). // -// A bridge that never started has no device and must not start one to find +// A Machine that never started has no device and must not start one to find // out: bring-up is what would demand the interactive login being removed. -func (m *Manager) Destroy(ctx context.Context, bridge config.Bridge, emit func(connection.Event)) error { - if m == nil { - return fmt.Errorf("bridge manager is not configured") - } - if err := validateBridgeID(bridge.ID); err != nil { - return err - } - stateDir, err := config.BridgeStateDir(bridge.ID) +// +// The state directory goes last and only on success: it holds the node key, +// which is what a later attempt would need to deregister the device. +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, rt, err := m.acquire(ctx, bridge.ID) + ctx, err = mc.begin(ctx) if err != nil { return err } - defer m.release(rt) - if rt.node == nil && !HasMachine(bridge.ID) { - m.forget(bridge.ID) + defer mc.end() + if mc.node == nil && !HasMachine(mc.bridge.ID) { + mc.setTailnet("") return nil } - if err := m.initNode(bridge, rt, ev); err != nil { + if err := mc.initNode(ev); err != nil { return err } - ev.note("Removing bridge " + bridge.Name + " from its tailnet ...") - err = rt.destroy(ctx, stateDir) - m.forget(bridge.ID) - if err != nil { + + 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 " + bridge.Name + " is no longer a device on that tailnet.") + ev.note("Bridge " + mc.bridge.Name + " is no longer a device on that tailnet.") return nil } -// forget drops what this session learned about a bridge. The cache entry stays: -// it carries the turn, and a second Machine for one bridge could open the state -// directory a first one is still using. -func (m *Manager) forget(bridgeID string) { - m.mu.Lock() - defer m.mu.Unlock() - delete(m.tailnets, bridgeID) +// Close ends the Machine for the process: it interrupts the operation running, +// waits for it to finish cleaning up, closes the node and every Route, and +// refuses further operations. 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() } -// destroy deregisters this Machine and discards its persistence. Called with -// the Machine's turn held. -// -// Logout needs an initialized LocalAPI rather than an authorized node, so an -// identity the tailnet will no longer accept can still be removed. The state -// directory goes last and only on success: it holds the node key, which is -// what a later attempt would need to deregister the device. -func (rt *Machine) destroy(ctx context.Context, stateDir string) error { - logoutErr := rt.node.Logout(ctx) - closeErr := rt.close() - if err := errors.Join(logoutErr, closeErr); err != nil { +// initNode constructs a node without waiting for login. Called with the turn +// held; only Open follows it with BringUp. +func (mc *Machine) initNode(ev events) error { + mc.ev.use(ev) + if mc.node != nil { + return nil + } + if mc.of.newNode == nil { + return fmt.Errorf("bridge node is not configured") + } + stateDir, err := config.BridgeStateDir(mc.bridge.ID) + if err != nil { return err } - return os.RemoveAll(stateDir) + // 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. 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.of.debug { + events(mc.ev.emit).notef(format, args...) + } + } + mc.node = mc.of.newNode(mc.bridge, stateDir, logNotes, logNotes) + if mc.node == nil { + return fmt.Errorf("bridge node is not configured") + } + return nil } -// close is called with the Machine's turn held. It must finish before a new -// node can open the same bridge state directory. -func (rt *Machine) close() error { +// shutdownNode closes every Route and the node, leaving the Machine idle. +// Called with the turn held: it must finish before a new node can open the +// same state directory. +func (mc *Machine) shutdownNode() error { var errs []error - for key, proxy := range rt.proxies { - errs = append(errs, closeProxy(proxy)) - delete(rt.proxies, key) + for key, route := range mc.routes { + errs = append(errs, route.close()) + delete(mc.routes, key) } - if rt.node != nil { - errs = append(errs, rt.node.Close()) - rt.node = nil + 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 index 4f3bb7d..2c323db 100644 --- a/internal/bridges/machine_test.go +++ b/internal/bridges/machine_test.go @@ -131,7 +131,7 @@ func TestActivateDebugDiagnostics(t *testing.T) { }, } node := &fakeNode{status: status, dialErr: errors.New("lookup aperture on 127.0.0.53:53: no such host")} - m := NewManager(true) + 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 @@ -139,7 +139,7 @@ func TestActivateDebugDiagnostics(t *testing.T) { defer m.Close() var logs []string - localURL, err := m.Activate( + localURL, err := activateMachine(m, context.Background(), config.Bridge{ID: "bridge-abcdef", Name: "Work"}, "http://aperture", @@ -181,12 +181,12 @@ func TestActivateDebugDiagnostics(t *testing.T) { func TestActivateClosesNodeWhenUpFails(t *testing.T) { node := &fakeNode{upErr: errors.New("login failed")} - m := NewManager(false) + m := NewMachines(false) m.newNode = func(_ config.Bridge, _ string, _ func(string, ...any), _ func(string, ...any)) tailnetNode { return node } - _, err := m.Activate( + _, err := activateMachine(m, context.Background(), config.Bridge{ID: "bridge-abcdef", Name: "Work"}, "http://ai", @@ -223,14 +223,14 @@ func TestActivateNormalLoggingOmitsDebugDiagnostics(t *testing.T) { defer backend.Close() backendAddr := strings.TrimPrefix(backend.URL, "http://") node := &fakeNode{status: status, backendAddr: backendAddr} - m := NewManager(false) + 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 := m.Activate( + localURL, err := activateMachine(m, context.Background(), config.Bridge{ID: "bridge-abcdef", Name: "Work"}, "http://aperture", @@ -271,7 +271,7 @@ func TestActivateWaitsForPeerMapBeforeDialing(t *testing.T) { return tailnetStatus("ai.example.ts.net.", "100.64.0.2"), nil } - m := NewManager(true) + m := NewMachines(true) m.peerWaitInterval = time.Millisecond m.newNode = func(_ config.Bridge, _ string, _ func(string, ...any), _ func(string, ...any)) tailnetNode { return node @@ -279,7 +279,7 @@ func TestActivateWaitsForPeerMapBeforeDialing(t *testing.T) { defer m.Close() var logs []string - localURL, err := m.Activate( + localURL, err := activateMachine(m, context.Background(), config.Bridge{ID: "bridge-abcdef", Name: "Work"}, "http://ai", @@ -326,7 +326,7 @@ func TestActivateLogsTheLoginLinkBeforeItIsUsable(t *testing.T) { ev.login(link) } - m := NewManager(false) + m := NewMachines(false) m.newNode = func(_ config.Bridge, _ string, _ func(string, ...any), _ func(string, ...any)) tailnetNode { return node } @@ -334,7 +334,7 @@ func TestActivateLogsTheLoginLinkBeforeItIsUsable(t *testing.T) { var mu sync.Mutex var logs []string - if _, err := m.Activate( + if _, err := activateMachine(m, context.Background(), config.Bridge{ID: "bridge-abcdef", Name: "Work"}, "http://ai", @@ -530,7 +530,7 @@ func TestActivateRecordsTailnet(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) defer backend.Close() - m := NewManager(false) + m := NewMachines(false) m.newNode = func(_ config.Bridge, _ string, _ func(string, ...any), _ func(string, ...any)) tailnetNode { return &fakeNode{ backendAddr: backend.Listener.Addr().String(), @@ -540,10 +540,10 @@ func TestActivateRecordsTailnet(t *testing.T) { defer m.Close() bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} - if _, err := m.Activate(context.Background(), bridge, "http://aperture.tailnet", nil); err != nil { + if _, err := activateMachine(m, context.Background(), bridge, "http://aperture.tailnet", nil); err != nil { t.Fatal(err) } - if got := m.Tailnet(bridge.ID); got != "corp.example.com" { + if got := tailnetOf(m, bridge.ID); got != "corp.example.com" { t.Errorf("Tailnet = %q, want corp.example.com", got) } } @@ -557,10 +557,10 @@ func TestSwitchTailnet(t *testing.T) { f := activate(t, backend) defer f.manager.Close() bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} - f.manager.tailnets[bridge.ID] = "corp.example.com" + f.manager.lookup(bridge.ID).setTailnet("corp.example.com") first := f.node - if err := f.manager.SwitchTailnet(context.Background(), bridge, nil); err != nil { + if err := switchTailnet(f.manager, context.Background(), bridge, nil); err != nil { t.Fatal(err) } if first.loggedOut != 1 { @@ -569,7 +569,7 @@ func TestSwitchTailnet(t *testing.T) { if !first.closed { t.Error("node was not closed") } - if got := f.manager.Tailnet(bridge.ID); got != "" { + 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 { @@ -581,7 +581,7 @@ func TestSwitchTailnet(t *testing.T) { replacement = &fakeNode{backendAddr: backend.Listener.Addr().String()} return replacement } - if _, err := f.manager.Activate(context.Background(), bridge, "http://aperture.tailnet", nil); err != nil { + if _, err := activateMachine(f.manager, context.Background(), bridge, "http://aperture.tailnet", nil); err != nil { t.Fatal(err) } if replacement == nil { @@ -600,7 +600,7 @@ func TestSwitchTailnetReportsLogoutFailure(t *testing.T) { defer f.manager.Close() f.node.logoutErr = errors.New("not logged in") - err := f.manager.SwitchTailnet(context.Background(), config.Bridge{ID: "bridge-abcdef", Name: "Work"}, nil) + 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) } @@ -619,7 +619,7 @@ func (n *fakeNode) Close() error { // 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 + manager *Machines node *fakeNode localURL string logs []string @@ -628,7 +628,7 @@ type activatedFixture struct { func activate(t *testing.T, backend *httptest.Server) activatedFixture { t.Helper() var f activatedFixture - f.manager = NewManager(false) + 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(), @@ -638,7 +638,7 @@ func activate(t *testing.T, backend *httptest.Server) activatedFixture { } var err error - f.localURL, err = f.manager.Activate( + f.localURL, err = activateMachine(f.manager, context.Background(), config.Bridge{ID: "bridge-abcdef", Name: "Work"}, "http://aperture.tailnet", @@ -749,7 +749,7 @@ func TestActivate(t *testing.T) { f := activate(t, backend) defer f.manager.Close() - localURL2, err := f.manager.Activate( + localURL2, err := activateMachine(f.manager, context.Background(), config.Bridge{ID: "bridge-abcdef", Name: "Work"}, "http://aperture.tailnet", @@ -881,7 +881,7 @@ func TestAProxyReportsToTheAttemptUsingItNow(t *testing.T) { // node and the proxy the first one built. var mu sync.Mutex var second []string - if _, err := f.manager.Activate( + if _, err := activateMachine(f.manager, context.Background(), config.Bridge{ID: "bridge-abcdef", Name: "Work"}, "http://aperture.tailnet", diff --git a/internal/bridges/machines.go b/internal/bridges/machines.go new file mode 100644 index 0000000..032bf3a --- /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 is the process's Machines, one per Bridge, and 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 whichever attempt is 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 is For without the creation, for callers that only want to read a +// Machine that already exists. +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 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 +} diff --git a/internal/bridges/node.go b/internal/bridges/node.go new file mode 100644 index 0000000..68af021 --- /dev/null +++ b/internal/bridges/node.go @@ -0,0 +1,225 @@ +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 what it is waiting on, +// off 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, which arrives as a terminal "IPN bus consumer fell behind" on a login +// the user did nothing wrong in. +// +// Taking the wait means taking what Up did with it: a terminal ErrMessage, and +// the check that a Running node actually has an address. resetServeStateOnce +// is not ours to keep; 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 too: health changes reach every watcher regardless of + // mask, but a login already broken before this watch started shows up only + // in the initial one, which 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) +} + +// notifier is the part of an IPN bus watch the bring-up reads, so the loop can +// be exercised against a recorded bus. +type notifier interface { + Next() (ipn.Notify, error) +} + +// bringUp waits for Running on one watch, naming each wait as it is entered. +// The link comes off the bus rather than tsnet's five second poll loop, which +// 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 notifier, statusOf func(context.Context) (*ipnstate.Status, error), ev events) (*ipnstate.Status, error) { + reporter := loginReporter{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) + } + reporter.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 + } +} + +// loginReporter turns IPN bus notifications into the phases a connection +// attempt reports, holding the last one because the bus repeats states. +// +// ipn.NeedsLogin covers two waits that look identical and are not: before a +// BrowseToURL the control plane has not answered and there is nothing to do, +// after it everything is waiting on the user. Reporting the backend state made +// a 29 second registration indistinguishable from someone who wandered off. +type loginReporter struct { + ev events + phase connection.Phase + // loginBroken is 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 *loginReporter) 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 *loginReporter) notify(n *ipn.Notify) { + if n == nil { + return + } + if n.State != nil { + // The raw state, not just the phase: NoState and NeedsLogin are one + // phase on screen on purpose and 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, and NoState is the one that matters: a bridge that never + // logged in sits there for the whole of POST /machine/register, so + // it is the wait and not a not-started-yet. Tailscale's own comment + // reads "UIs should print Loading..." (ipnlocal/local.go). + r.enter(connection.AwaitingLoginLink) + case ipn.NeedsMachineAuth: + // 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 { + // Record the rejection reason, never the authorization capability. + slog.Error("unusable login link from the control plane", "err", err) + // Not fatal to the login: tsnet keeps printing its own copy, and + // the user can still finish by hand. Worth saying, because 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.login(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 sending 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. +// +// login-state only. The other warnables describe a node that is up and +// imperfect, and would bury the one line that is this attempt's business. +func (r *loginReporter) 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 is how a Machine gets its node in production: 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/route.go b/internal/bridges/route.go new file mode 100644 index 0000000..2cb15bb --- /dev/null +++ b/internal/bridges/route.go @@ -0,0 +1,285 @@ +package bridges + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/http/httputil" + "net/netip" + "net/url" + "strings" + "time" + + "tailscale.com/ipn/ipnstate" +) + +// Route is the local door to one Endpoint through one Machine: a loopback +// listener reverse-proxying over the Machine's node. LocalURL is the Gateway 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. Already closed is not a failure: 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. It +// reports through the Machine because the Route is cached and will still be +// serving long after the connection that asked for it has gone. Called with +// the Machine's turn held. +func (mc *Machine) openRoute(target *url.URL) (*Route, error) { + node, ev := mc.node, events(mc.ev.emit) + debug := mc.of.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.of.peerWait, + mc.of.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", 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 &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, resolving a name against +// the node's own peer map first and dialing the IP it finds. +// +// Handing the name to tsnet is what made a first connection hang for 30s: until +// the netmap lands its resolver falls through to the host resolver, which on a +// machine already on a tailnet answers with a same-named node on the wrong one. +// Short aliases use this node's current tailnet suffix; a shared peer requires +// 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 + } + // Not every target is a tailnet node: a subnet router or the tailnet's + // own DNS can serve it. Those resolve only the way tsnet resolves, so + // fall through and say so, since 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 resolves short names only within the current tailnet's MagicDNS +// suffix. A shared-in peer can have the same first label but belongs to another +// tailnet; reaching it requires its explicit 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 index 38d6bc4..a15d443 100644 --- a/internal/bridges/security_test.go +++ b/internal/bridges/security_test.go @@ -80,13 +80,13 @@ func TestProxyRequiresExplicitSharedPeerName(t *testing.T) { 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 := NewManager(false) + 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 := m.Activate(context.Background(), config.Bridge{ID: "bridge-abcdef", Name: "Work"}, target, nil) + localURL, err := activateMachine(m, context.Background(), config.Bridge{ID: "bridge-abcdef", Name: "Work"}, target, nil) if err != nil { t.Fatal(err) } @@ -150,13 +150,13 @@ func TestRunLogOmitsLoginCapabilities(t *testing.T) { r := loginReporter{ev: sink(nil)} r.notify(unhealthyLogin("request failed: " + authURL)) case "backend and startup error": - m := NewManager(true) + 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)} } - _, _ = m.Activate(context.Background(), config.Bridge{ID: "bridge-abcdef"}, "http://ai", nil) + _, _ = activateMachine(m, context.Background(), config.Bridge{ID: "bridge-abcdef"}, "http://ai", nil) _ = m.Close() } data, err := os.ReadFile(f.Name()) diff --git a/internal/config/endpoint.go b/internal/config/endpoint.go index f17e0b7..3ef0572 100644 --- a/internal/config/endpoint.go +++ b/internal/config/endpoint.go @@ -43,6 +43,6 @@ func ParseEndpoint(value, bridgeID string) (Endpoint, error) { return Endpoint{URL: strings.TrimRight(value, "/"), BridgeID: bridgeID}, nil } -func sameEndpoint(a, b Endpoint) bool { +func SameEndpoint(a, b Endpoint) bool { return a.URL == b.URL && a.BridgeID == b.BridgeID } diff --git a/internal/config/global.go b/internal/config/global.go index 591c3ac..4f24f0b 100644 --- a/internal/config/global.go +++ b/internal/config/global.go @@ -76,7 +76,7 @@ func (g *Global) ActiveEndpoint() Endpoint { func (g *Global) SetActiveEndpoint(ep Endpoint, replacing *Endpoint) error { eps := []Endpoint{ep} for _, existing := range g.Settings.Endpoints { - if !sameEndpoint(existing, ep) && (replacing == nil || !sameEndpoint(existing, *replacing)) { + if !SameEndpoint(existing, ep) && (replacing == nil || !SameEndpoint(existing, *replacing)) { eps = append(eps, existing) } } @@ -100,7 +100,7 @@ func (g *Global) SetApertureHost(url string) error { // without changing which endpoint is active, and persists. func (g *Global) UpsertEndpoint(ep Endpoint) error { for _, existing := range g.Settings.Endpoints { - if sameEndpoint(existing, ep) { + if SameEndpoint(existing, ep) { return nil } } @@ -119,7 +119,7 @@ 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 !SameEndpoint(existing, old) { continue } oldIdx = i @@ -133,7 +133,7 @@ func (g *Global) ReplaceEndpoint(old, next Endpoint) error { for _, ep := range eps { duplicate := false for _, existing := range deduped { - if sameEndpoint(existing, ep) { + if SameEndpoint(existing, ep) { duplicate = true break } @@ -176,6 +176,19 @@ func (g *Global) RemoveEndpoint(idx int) error { return nil } +// DropEndpoint removes ep from the list unless it is the active endpoint, +// which is the connection the user falls back to. An endpoint not in the list +// is not an error. +func (g *Global) DropEndpoint(ep Endpoint) error { + for i, existing := range g.Settings.Endpoints { + if i == 0 || !SameEndpoint(existing, ep) { + continue + } + return g.RemoveEndpoint(i) + } + return nil +} + // AddBridge creates, saves, and returns a bridge with a generated stable ID. func (g *Global) AddBridge(name string) (Bridge, error) { name = strings.TrimSpace(name) diff --git a/internal/tui/connection_test.go b/internal/tui/connection_test.go index 802b5a3..79d5d16 100644 --- a/internal/tui/connection_test.go +++ b/internal/tui/connection_test.go @@ -11,6 +11,7 @@ import ( "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" ) @@ -35,7 +36,7 @@ func TestEndpointEditPreservesVerifiedConnection(t *testing.T) { } else { m.Update(tea.KeyMsg{Type: tea.KeyEsc}) // The cancelled request can still deliver a success already queued. - m.Update(endpointActivationResult{id: m.activationSeq, endpoint: config.Endpoint{URL: srv.URL}, host: srv.URL}) + m.Update(endpointActivationResult{id: m.activationSeq, verified: bridges.Verified{Gateway: srv.URL}}) } if got := m.g.ActiveEndpoint(); got != old { t.Errorf("%s replaced verified endpoint: got %+v, want %+v", outcome, got, old) @@ -88,7 +89,7 @@ func TestTailnetSwitchInvalidatesSharedConnection(t *testing.T) { if outcome == "cancel" { m.Update(tea.KeyMsg{Type: tea.KeyEsc}) } else { - m.Update(endpointActivationResult{id: m.act.id, endpoint: target, err: fmt.Errorf("model check failed after logout")}) + 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()) @@ -151,8 +152,8 @@ func TestEndpointEditRetryAndOverrideKeepOriginal(t *testing.T) { srv := modelsServer(t) m.promptEditEndpoint(old) m.inputOnSave(srv.URL) - first := m.act.endpoint - m.Update(endpointActivationResult{id: m.act.id, endpoint: first, err: fmt.Errorf("temporary failure")}) + first := m.act.endpoint() + m.Update(endpointActivationResult{id: m.act.id, err: fmt.Errorf("temporary failure")}) var cmd tea.Cmd switch action { case "retry": @@ -182,8 +183,8 @@ func TestEndpointEditSameCandidateCancellation(t *testing.T) { original := m.g.ActiveEndpoint() m.promptEditEndpoint(original) m.inputOnSave("http://candidate") - candidate := m.act.endpoint - m.Update(endpointActivationResult{id: m.act.id, endpoint: candidate, err: fmt.Errorf("temporary failure")}) + 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}) diff --git a/internal/tui/menus.go b/internal/tui/menus.go index 922db1f..45d8c73 100644 --- a/internal/tui/menus.go +++ b/internal/tui/menus.go @@ -6,6 +6,7 @@ import ( "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" @@ -112,7 +113,7 @@ func (m *model) quickSelect() (tea.Cmd, string) { if !hasSavedEndpoint || !m.endpointConfigured(saved) { return nil, "" } - if !sameEndpoint(saved, m.g.ActiveEndpoint()) { + if !config.SameEndpoint(saved, m.g.ActiveEndpoint()) { return nil, "" } if cmd := c.Replay(m.g); cmd != nil { @@ -137,17 +138,13 @@ func (m *model) lastLaunchEndpoint() (config.Endpoint, bool) { func (m *model) endpointConfigured(want config.Endpoint) bool { for _, ep := range m.g.Settings.Endpoints { - if sameEndpoint(ep, want) { + if config.SameEndpoint(ep, want) { return true } } return false } -func sameEndpoint(a, b config.Endpoint) bool { - return a.URL == b.URL && a.BridgeID == b.BridgeID -} - func simpleErrorCmd(err error) tea.Cmd { return func() tea.Msg { return menu.SimpleDoneMsg{Err: err} } } @@ -224,7 +221,7 @@ func (m *model) bridgesMenu() *menu.Menu { if idx < 0 || idx >= len(m.g.Settings.Bridges) { return menu.Result{} } - return m.remove(bridgeRemoval{bridge: m.g.Settings.Bridges[idx]}) + return m.remove(bridges.Removal{Bridge: m.g.Settings.Bridges[idx]}) }, }) return &menu.Menu{ @@ -237,7 +234,7 @@ func (m *model) bridgesMenu() *menu.Menu { // 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.bridgeTailnet(bridge); name != "" { + if name := m.bridging().Tailnet(bridge); name != "" { return "tailnet " + name } return bridge.ID @@ -382,22 +379,12 @@ func (m *model) connectionDescription(row connectionRow) string { if row.ep.BridgeID == "" { return "" } - if name := m.bridgeTailnet(row.bridge); name != "" { + if name := m.bridging().Tailnet(row.bridge); name != "" { return "tailnet " + name } return "tailnet not known yet" } -// bridgeTailnet prefers what the running node reports to what was saved: a -// bridge that switched tailnets this session leaves a stale name on disk until -// the next successful connection rewrites it. -func (m *model) bridgeTailnet(bridge config.Bridge) string { - if name := m.bridgeManager.Tailnet(bridge.ID); name != "" { - return name - } - return bridge.Tailnet -} - // connectionMenu is 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. @@ -438,7 +425,7 @@ func (m *model) connectionMenu(row connectionRow) *menu.Menu { if row.ep.BridgeID != "" { description := "log the bridge out and sign in to a different tailnet" - if name := m.bridgeTailnet(row.bridge); name != "" { + if name := m.bridging().Tailnet(row.bridge); name != "" { description = "leave " + name + " and sign in to a different tailnet" } items = append(items, menu.MenuItem{ @@ -475,28 +462,12 @@ func (m *model) connectionMenu(row connectionRow) *menu.Menu { } } -// dropOrphanBridge removes a bridge once its last endpoint is gone. Settings -// hold two objects where the picker shows one row, so removing the endpoint -// alone left the bridge re-listed as a bare "Connect via" row: to the user the -// row moved instead of going. A bridge two endpoints reach through stays. -func (m *model) dropOrphanBridge(id string) error { - if id == "" { - return nil - } - for _, ep := range m.g.Settings.Endpoints { - if ep.BridgeID == id { - return nil - } - } - return m.g.RemoveBridge(id) -} - // 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.bridgeTailnet(row.bridge); name != "" { + if name := m.bridging().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; " + @@ -509,12 +480,6 @@ func (m *model) switchTailnetMenu(row connectionRow) *menu.Menu { Label: "Switch tailnet", Shortcut: "y", Action: func() menu.Result { - // Drop the recorded name now: an abandoned login would - // otherwise leave the picker naming a tailnet the bridge - // has already left. - if err := m.g.SetBridgeTailnet(row.bridge.ID, ""); err != nil { - return errResult(err.Error()) - } return menu.Result{Cmd: m.connectVia(row.ep, true)} }, }, @@ -562,7 +527,7 @@ func (m *model) setupGuideMenu() *menu.Menu { } } - hasPrevious := m.connected && !sameEndpoint(target, m.g.ActiveEndpoint()) + hasPrevious := m.connected && !config.SameEndpoint(target, m.g.ActiveEndpoint()) if hasPrevious { preamble += "\n\nThe previous endpoint remains active: " + m.endpointLabel(m.g.ActiveEndpoint()) + "." } @@ -597,7 +562,7 @@ func (m *model) setupGuideMenu() *menu.Menu { }, }) } - if m.endpointConfigured(target) && !sameEndpoint(target, m.g.ActiveEndpoint()) { + if m.endpointConfigured(target) && !config.SameEndpoint(target, m.g.ActiveEndpoint()) { items = append(items, menu.MenuItem{ Label: "Remove endpoint", Action: func() menu.Result { return m.remove(m.removalFor(target)) }, @@ -631,15 +596,15 @@ func (m *model) promptEditEndpoint(ep config.Endpoint) { if err != nil { return simpleErrorCmd(err) } - if m.act != nil && sameEndpoint(m.act.endpoint, ep) && m.act.replaces != nil { - return m.retargetActivation(next) + var current *bridges.Attempt + if m.act != nil { + current = m.act.attempt } - seq := m.activationSeq - cmd := m.connectVia(next, false) - if m.activationSeq != seq { - m.act.replaces = &ep + a, err := m.bridging().Edit(current, ep, next) + if err != nil { + return simpleErrorCmd(err) } - return cmd + return m.startAttempt(a) }) } @@ -723,17 +688,10 @@ func (m *model) connectBridgeCmd(bridge config.Bridge) tea.Cmd { return m.connectVia(config.Endpoint{URL: config.DefaultLocation, BridgeID: bridge.ID}, false) } -// connectVia connects to ep, saving it first when it is not in settings yet so -// the failure screen has something to name, retry and edit. switchTailnet logs -// the bridge out on the way, so the connection asks for a login. +// 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 { - ephemeral := !m.endpointConfigured(ep) - if ephemeral { - if err := m.g.UpsertEndpoint(ep); err != nil { - return simpleErrorCmd(err) - } - } - return m.activateEndpoint(ep, ephemeral, switchTailnet) + return m.connect(ep, switchTailnet, nil) } func (m *model) endpointLabel(ep config.Endpoint) string { diff --git a/internal/tui/removal.go b/internal/tui/removal.go index 3c0255a..40012f4 100644 --- a/internal/tui/removal.go +++ b/internal/tui/removal.go @@ -2,6 +2,7 @@ package tui import ( "context" + "errors" "time" tea "github.com/charmbracelet/bubbletea" @@ -11,71 +12,50 @@ import ( "github.com/tailscale/aperture-cli/internal/menu" ) -// bridgeRemoval is what one delete is about: the endpoint the user picked, and -// the bridge that endpoint was the last reason to keep. Either can be absent. -type bridgeRemoval struct { - bridge config.Bridge - endpoint *config.Endpoint -} - // bridgeRemovedMsg carries the outcome of the tailnet round trip back to the -// update loop. timedOut separates "the tailnet refused" from "the tailnet did -// not answer in time", which are opposite answers about the local records. +// update loop, where the records can be dropped. type bridgeRemovedMsg struct { - id int - removal bridgeRemoval - err error - timedOut bool + id int + removal bridges.Removal + err error } -// Seams for the tests: pickerModel has no bridge manager, and a real Destroy -// would want a tailnet. -var ( - destroyBridge = func(ctx context.Context, mgr *bridges.Manager, bridge config.Bridge, emit func(connection.Event)) error { - return mgr.Destroy(ctx, bridge, emit) - } - bridgeHasMachine = bridges.HasMachine -) - -// bridgeDestroyTimeout bounds the logout. /machine/register was hanging past 90 -// seconds on 2026-09-17 and logout is a round trip to the same place, so the -// delete cannot wait on it indefinitely (ADR 0002, decision 6). -var bridgeDestroyTimeout = 45 * time.Second +// destroyBridge is the tailnet round trip a removal makes. A seam for the +// tests: pickerModel has no Machines, and a real Destroy would want a tailnet. +var destroyBridge = func(ctx context.Context, b bridges.Bridging, rem bridges.Removal, emit func(connection.Event)) error { + return b.Destroy(ctx, rem, emit) +} // removeRow deletes what a picker row stands for. Shared by the row's page and // the "d" key, which have to agree on what removing a row means. func (m *model) removeRow(row connectionRow) menu.Result { - if row.active { - return errResult("connect to another endpoint before removing the active one") - } - rem := bridgeRemoval{bridge: row.bridge} + rem := bridges.Removal{Bridge: row.bridge} if row.saved { ep := row.ep - rem.endpoint = &ep + rem.Endpoint = &ep } return m.remove(rem) } // removalFor is the removal a saved endpoint implies, bridge included. The // setup guide holds an endpoint rather than a picker row. -func (m *model) removalFor(ep config.Endpoint) bridgeRemoval { - rem := bridgeRemoval{endpoint: &ep} - rem.bridge, _ = m.g.Bridge(ep.BridgeID) +func (m *model) removalFor(ep config.Endpoint) bridges.Removal { + rem := bridges.Removal{Endpoint: &ep} + rem.Bridge, _ = m.g.Bridge(ep.BridgeID) return rem } -// remove confirms and destroys the bridge's machine when this is the last -// reference to it, and otherwise just drops the records. Every delete in the -// TUI comes through here: the machine 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(rem bridgeRemoval) menu.Result { - if rem.endpoint == nil { - if ep, used := m.endpointUsing(rem.bridge.ID); used { - return errResult("bridge " + rem.bridge.Name + " is used by endpoint " + ep.URL + "; remove that connection instead") - } - } - if !m.destroys(rem) { - if err := m.removeRecords(rem); err != nil { +// remove confirms before a removal that takes a device off a tailnet, and +// otherwise drops the records at once. Every delete in the TUI comes through +// here: the machine 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(rem bridges.Removal) menu.Result { + destroys, err := m.bridging().Destroys(rem) + if err != nil { + return errResult(err.Error()) + } + if !destroys { + if err := m.bridging().Forget(rem, nil); err != nil { return errResult(err.Error()) } return menu.Result{Cmd: m.afterRemoval(rem)} @@ -83,45 +63,18 @@ func (m *model) remove(rem bridgeRemoval) menu.Result { return menu.Result{Next: m.removeBridgeMenu(rem)} } -// destroys reports whether this removal takes the bridge's last endpoint and -// leaves a machine behind. A bridge that never started has no device, and must -// not start one to find out: bring-up is the interactive login being removed. -func (m *model) destroys(rem bridgeRemoval) bool { - if rem.bridge.ID == "" || !bridgeHasMachine(rem.bridge.ID) { - return false - } - for _, ep := range m.g.Settings.Endpoints { - if ep.BridgeID != rem.bridge.ID { - continue - } - if rem.endpoint == nil || !sameEndpoint(ep, *rem.endpoint) { - return false - } - } - return true -} - -func (m *model) endpointUsing(bridgeID string) (config.Endpoint, bool) { - for _, ep := range m.g.Settings.Endpoints { - if bridgeID != "" && ep.BridgeID == bridgeID { - return ep, true - } - } - return config.Endpoint{}, false -} - // removeBridgeMenu is the confirmation. Removal is irreversible from here and // takes a device off the user's tailnet, so the screen names the device by the // name the admin console shows it under. -func (m *model) removeBridgeMenu(rem bridgeRemoval) *menu.Menu { - preamble := "Bridge " + rem.bridge.Name + " is the device " + bridges.MachineName(rem.bridge.ID) - if name := m.bridgeTailnet(rem.bridge); name != "" { +func (m *model) removeBridgeMenu(rem bridges.Removal) *menu.Menu { + preamble := "Bridge " + rem.Bridge.Name + " is the device " + bridges.MachineName(rem.Bridge.ID) + if name := m.bridging().Tailnet(rem.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 " + rem.bridge.Name + "?", + Title: "Remove bridge " + rem.Bridge.Name + "?", Preamble: preamble, Items: []menu.MenuItem{ { @@ -143,67 +96,69 @@ func (m *model) removeBridgeMenu(rem bridgeRemoval) *menu.Menu { // program already shows slow bridge work and its log tail. The attempt 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 disagree. -func (m *model) destroyBridgeCmd(rem bridgeRemoval) tea.Cmd { +func (m *model) destroyBridgeCmd(rem bridges.Removal) tea.Cmd { m.stopActivation() m.step = stepPreflight m.preflightErr = "" m.bridgeLogs = nil - ctx, cancel := context.WithTimeout(context.Background(), bridgeDestroyTimeout) + ctx, cancel := context.WithCancel(context.Background()) ch := make(chan bridgeLine, 32) m.activationSeq++ act := &activation{ id: m.activationSeq, - label: "Removing bridge " + rem.bridge.Name + " ...", + label: "Removing bridge " + rem.Bridge.Name + " ...", started: time.Now(), logCh: ch, logCtx: ctx, } m.act = act emit := bridgeLogSink(ctx, ch, act.started) + bridging := m.bridging() destroy := func() tea.Msg { defer cancel() - err := destroyBridge(ctx, m.bridgeManager, rem.bridge, emit) - return bridgeRemovedMsg{id: act.id, removal: rem, err: err, timedOut: err != nil && ctx.Err() != nil} + err := destroyBridge(ctx, bridging, rem, emit) + return bridgeRemovedMsg{id: act.id, removal: rem, err: err} } return tea.Batch(destroy, waitBridgeLog(ctx, ch), activationTick(act.id)) } -// bridgeRemoved applies the outcome. Settings go only once the device is gone -// or is known to have outlived the wait, because settings are the only record -// that the device exists. +// bridgeRemoved shows the outcome. Whether the records go is the service's +// call; this only decides which screen says what happened. 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 - if msg.err != nil && !msg.timedOut { + err := m.bridging().Forget(msg.removal, msg.err) + var unconfirmed *bridges.Unconfirmed + switch { + case errors.As(err, &unconfirmed): + cmd := m.afterRemoval(msg.removal) + m.step = stepError + m.errMsg = m.unconfirmedMessage(unconfirmed) + return m, cmd + case err != nil && errors.Is(err, msg.err): m.step = stepError - m.errMsg = "Could not remove bridge " + msg.removal.bridge.Name + ": " + msg.err.Error() + + m.errMsg = "Could not remove bridge " + msg.removal.Bridge.Name + ": " + err.Error() + "\n\nThe connection is unchanged. Removing it again retries the logout." return m, nil - } - if err := m.removeRecords(msg.removal); err != nil { + case err != nil: m.step = stepError m.errMsg = err.Error() return m, nil } - cmd := m.afterRemoval(msg.removal) - if msg.timedOut { - m.step = stepError - m.errMsg = m.timedOutMessage(msg.removal.bridge) - } - return m, cmd + return m, m.afterRemoval(msg.removal) } -// timedOutMessage is what the user needs to finish the job by hand: the device -// name, and where to look for it. A bare "timed out" leaves them hunting for a -// machine whose name this program chose. -func (m *model) timedOutMessage(bridge config.Bridge) string { - msg := "Bridge " + bridge.Name + " was removed here, but the tailnet did not confirm within " + - bridgeDestroyTimeout.String() + ".\n\nThe device " + bridges.MachineName(bridge.ID) - if name := m.bridgeTailnet(bridge); name != "" { +// unconfirmedMessage is what the user needs to finish the job by hand: the +// device name, and where to look for it. A bare "timed out" leaves them +// hunting for a machine whose name this program chose. +func (m *model) unconfirmedMessage(u *bridges.Unconfirmed) string { + msg := "Bridge " + u.Bridge.Name + " was removed here, but the tailnet did not confirm within " + + u.Wait.String() + ".\n\nThe device " + bridges.MachineName(u.Bridge.ID) + if name := m.bridging().Tailnet(u.Bridge); name != "" { msg += " may still be on " + name } else { msg += " may still be registered" @@ -211,28 +166,11 @@ func (m *model) timedOutMessage(bridge config.Bridge) string { return msg + ". Delete it from the Tailscale admin console if it is." } -// removeRecords drops the settings this removal covers, endpoint first: a -// bridge an endpoint still points at cannot be removed (global.go RemoveBridge). -func (m *model) removeRecords(rem bridgeRemoval) error { - if rem.endpoint != nil { - for i, ep := range m.g.Settings.Endpoints { - if i == 0 || !sameEndpoint(ep, *rem.endpoint) { - continue - } - if err := m.g.RemoveEndpoint(i); err != nil { - return err - } - break - } - } - return m.dropOrphanBridge(rem.bridge.ID) -} - // afterRemoval puts the user back on a list that no longer shows what they // removed. A removal of the endpoint the failure screen is about leaves that // screen with nothing to retry, so the root menu takes its place. -func (m *model) afterRemoval(rem bridgeRemoval) tea.Cmd { - if rem.endpoint != nil && m.failedEndpoint != nil && sameEndpoint(*m.failedEndpoint, *rem.endpoint) { +func (m *model) afterRemoval(rem bridges.Removal) tea.Cmd { + if rem.Endpoint != nil && m.failedEndpoint != nil && config.SameEndpoint(*m.failedEndpoint, *rem.Endpoint) { m.clearEndpointFailure() m.resetStack(m.rootMenu()) return tea.ClearScreen diff --git a/internal/tui/removal_test.go b/internal/tui/removal_test.go index 3c629bb..a79b27f 100644 --- a/internal/tui/removal_test.go +++ b/internal/tui/removal_test.go @@ -19,8 +19,8 @@ import ( func withFakeDestroy(t *testing.T, fn func(context.Context, config.Bridge) error) { t.Helper() orig := destroyBridge - destroyBridge = func(ctx context.Context, _ *bridges.Manager, b config.Bridge, _ func(connection.Event)) error { - return fn(ctx, b) + destroyBridge = func(ctx context.Context, _ bridges.Bridging, rem bridges.Removal, _ func(connection.Event)) error { + return fn(ctx, rem.Bridge) } t.Cleanup(func() { destroyBridge = orig }) } @@ -206,12 +206,8 @@ func TestDestroyTimeoutRemovesLocallyAndNamesTheDevice(t *testing.T) { m := pickerModel(t) withFakeClients(t, []clients.Client{}) startedBridge(t, "bridge-aaaaaa") - orig := bridgeDestroyTimeout - bridgeDestroyTimeout = 50 * time.Millisecond - t.Cleanup(func() { bridgeDestroyTimeout = orig }) - withFakeDestroy(t, func(ctx context.Context, _ config.Bridge) error { - <-ctx.Done() - return ctx.Err() + withFakeDestroy(t, func(_ context.Context, b config.Bridge) error { + return &bridges.Unconfirmed{Bridge: b, Wait: 50 * time.Millisecond, Err: context.DeadlineExceeded} }) row := bridgedRow(t, m) m.resetStack(m.endpointsMenu()) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 16e8487..92fe99b 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -9,9 +9,6 @@ package tui import ( "context" "fmt" - "io" - "log/slog" - "net/http" "strings" "time" "unicode" @@ -52,27 +49,22 @@ var ( dotRed = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render("●") ) -const ( - providerFetchTimeout = 10 * time.Second - bridgeProviderFetchTimeout = 30 * time.Second -) - // 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, bridgeManager *bridges.Manager, start config.Endpoint) tea.Model { +func NewModel(g *config.Global, buildVersion string, machines *bridges.Machines, start config.Endpoint) tea.Model { return &model{ - g: g, - buildVersion: buildVersion, - bridgeManager: bridgeManager, - start: start, - 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. @@ -116,19 +108,15 @@ type model struct { // cancel is nil for attempts that cannot be interrupted (the post-launch // re-check), which is what makes Esc and the inline override inert there. type activation struct { - id int - endpoint config.Endpoint - label string - started time.Time - cancel context.CancelFunc - // ephemeral records that this flow is what put endpoint into settings, - // so abandoning or overriding the attempt takes it back out instead of - // leaving an endpoint nobody chose. - ephemeral bool - // replaces is removed only when this edited endpoint verifies successfully. - replaces *config.Endpoint - logCh chan bridgeLine - logCtx context.Context + 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 wait this attempt is in, and phaseSet distinguishes "not // started" from StartingMachine, which is the zero value. phase connection.Phase @@ -164,12 +152,27 @@ func (a *activation) entered(p connection.Phase) bool { return true } +// endpoint is the Endpoint the attempt on screen is trying, zero when the +// screen is showing something else. +func (a *activation) endpoint() config.Endpoint { + if a == nil || a.attempt == nil { + return config.Endpoint{} + } + return a.attempt.Endpoint +} + // cancelable reports whether Esc can interrupt this attempt. func (a *activation) cancelable() bool { return a != nil && a.cancel != nil } // overridable 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) overridable() bool { return a.cancelable() && a.endpoint.BridgeID != "" } +func (a *activation) overridable() bool { return a.cancelable() && a.endpoint().BridgeID != "" } + +// bridging is the Connection context's service over this program's settings +// and Machines. Stateless, so built where it is used. +func (m *model) bridging() bridges.Bridging { + return bridges.Bridging{Machines: m.machines, Settings: m.g} +} // textField is the shared single-line editor behind the add-endpoint input // step and the inline URL override on the connect screen. @@ -216,22 +219,15 @@ func (m *model) Init() tea.Cmd { return m.connectVia(m.start, false) } -// preflightResult is emitted when the /v1/models check completes. -type preflightResult struct { - host string - providers []config.ProviderInfo - err error -} - +// endpointActivationResult is how an attempt's outcome reaches 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 - endpoint config.Endpoint - host string - providers []config.ProviderInfo - err error + id int + verified bridges.Verified + err error } // bridgeLine is one thing the attempt reported and how far into the attempt it @@ -287,175 +283,74 @@ func activationTick(id int) tea.Cmd { 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} +// 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 && config.SameEndpoint(m.act.endpoint(), ep) { + return m.startAttempt(m.act.attempt.Retry()) } + return m.connect(ep, false, nil) } -func fetchProviders(host string) ([]config.ProviderInfo, error) { - return fetchProvidersContext(context.Background(), host, providerFetchTimeout) -} - -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 - } - // 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, url, detail) - } - return nil, fmt.Errorf("unexpected status %d from %s", resp.StatusCode, url) - } - body, err := io.ReadAll(resp.Body) +// 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 := m.bridging().Begin(ep, switchTailnet, replacing) if err != nil { - return nil, err + return simpleErrorCmd(err) } - provs, err := config.ParseProviders(body) - if err != nil { - return nil, fmt.Errorf("could not parse models response: %w", err) - } - return provs, nil -} - -func (m *model) activateEndpointCmd(ep config.Endpoint) tea.Cmd { - var replaces *config.Endpoint - var ephemeral bool - if m.act != nil && sameEndpoint(m.act.endpoint, ep) { - replaces, ephemeral = m.act.replaces, m.act.ephemeral - } - cmd := m.activateEndpoint(ep, ephemeral, false) - m.act.replaces = replaces - return cmd + return m.startAttempt(a) } -// activateEndpoint starts a cancellable attempt to connect to ep. ephemeral -// marks an endpoint this flow wrote to settings on the user's behalf, so -// cancelling can take it back out. switchTailnet logs the bridge out first, so -// the attempt starts from a login prompt rather than the tailnet it is on. -func (m *model) activateEndpoint(ep config.Endpoint, ephemeral, switchTailnet bool) tea.Cmd { - cmd := m.beginActivation(ep, ephemeral, switchTailnet) - return tea.Batch(cmd, activationTick(m.act.id)) -} - -func (m *model) beginActivation(ep config.Endpoint, ephemeral, switchTailnet bool) tea.Cmd { +// 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 - if switchTailnet && ep.BridgeID != "" && ep.BridgeID == m.g.ActiveEndpoint().BridgeID { - // Cancellation cannot prove that Logout did not run. Any endpoint - // using this bridge must verify a new gateway before launching again. + if a.InvalidatesActive { m.connected = false } ctx, cancel := context.WithCancel(context.Background()) m.activationSeq++ act := &activation{ - id: m.activationSeq, - endpoint: ep, - label: "Checking " + ep.URL + " ...", - started: time.Now(), - cancel: cancel, - ephemeral: ephemeral, + id: m.activationSeq, + attempt: a, + label: "Checking " + a.Endpoint.URL + " ...", + started: time.Now(), + cancel: cancel, } m.act = act + bridging := m.bridging() - bridge, ok := m.g.Bridge(ep.BridgeID) - switch { - case ep.BridgeID == "": - return func() tea.Msg { - defer cancel() - provs, err := fetchProvidersContext(ctx, ep.URL, providerFetchTimeout) - return endpointActivationResult{id: act.id, endpoint: ep, host: ep.URL, providers: provs, err: err} - } - case !ok: - return func() tea.Msg { - defer cancel() - return endpointActivationResult{ - id: act.id, - endpoint: ep, - host: ep.URL, - err: fmt.Errorf("bridge %s is not configured", ep.BridgeID), - } - } - case m.bridgeManager == nil: - return func() tea.Msg { + if a.Endpoint.BridgeID == "" { + run := func() tea.Msg { defer cancel() - return endpointActivationResult{ - id: act.id, - endpoint: ep, - host: ep.URL, - err: fmt.Errorf("bridge manager is not configured"), - } + v, err := bridging.Run(ctx, a, nil) + return endpointActivationResult{id: act.id, verified: v, err: err} } + return tea.Batch(run, activationTick(act.id)) } ch := make(chan bridgeLine, 32) act.logCh = ch act.logCtx = ctx - act.label = "Connecting bridge " + bridge.Name + " to " + ep.URL + " ..." - if switchTailnet { - act.label = "Switching bridge " + bridge.Name + " to a different tailnet ..." + 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) - activate := func() tea.Msg { + run := func() tea.Msg { defer cancel() - // Stamps 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", ep.URL, "bridge", bridge.ID, "switchTailnet", switchTailnet) - // Inside the attempt, so it 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 switchTailnet { - if err := m.bridgeManager.SwitchTailnet(ctx, bridge, emit); err != nil { - return endpointActivationResult{id: act.id, endpoint: ep, host: ep.URL, err: err} - } - } - localURL, err := m.bridgeManager.Activate(ctx, bridge, ep.URL, emit) - if err != nil { - return endpointActivationResult{id: act.id, endpoint: ep, host: ep.URL, err: 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. - // The phase is the attempt's own work, not the bridge's. - emit(connection.Entered(connection.AskingForModels)) - 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{id: act.id, endpoint: ep, host: localURL, providers: provs, err: err} + v, err := bridging.Run(ctx, a, emit) + return endpointActivationResult{id: act.id, verified: v, err: err} } - return tea.Batch(activate, waitBridgeLog(ctx, ch)) -} - -// recordBridgeTailnet saves the tailnet a bridge connected through so the -// picker can name it before the bridge is started again. A failed write is not -// worth interrupting a connection that worked. -func (m *model) recordBridgeTailnet(ep config.Endpoint) { - if ep.BridgeID == "" { - return - } - name := m.bridgeManager.Tailnet(ep.BridgeID) - if name == "" { - return - } - _ = m.g.SetBridgeTailnet(ep.BridgeID, name) + return tea.Batch(run, waitBridgeLog(ctx, ch), activationTick(act.id)) } // stopActivation ends the in-flight attempt without touching settings. The @@ -482,23 +377,7 @@ func (m *model) discardActivation() error { return nil } m.stopActivation() - if !act.ephemeral { - return nil - } - act.ephemeral = false - return m.removeEndpoint(act.endpoint) -} - -// removeEndpoint deletes ep from settings. The active endpoint at index 0 is -// left alone: it is the connection the user falls back to. -func (m *model) removeEndpoint(ep config.Endpoint) error { - for i, existing := range m.g.Settings.Endpoints { - if i == 0 || !sameEndpoint(existing, ep) { - continue - } - return m.g.RemoveEndpoint(i) - } - return nil + return m.bridging().Abandon(act.attempt) } // cancelActivation abandons the attempt on screen and returns to the menu the @@ -509,7 +388,7 @@ func (m *model) cancelActivation() (tea.Model, tea.Cmd) { if act == nil { return m, nil } - endpoint := act.endpoint + endpoint := act.endpoint() if err := m.discardActivation(); err != nil { m.errMsg = "could not remove endpoint: " + err.Error() m.step = stepError @@ -533,14 +412,14 @@ func (m *model) overrideActivationURL(value string) (tea.Model, tea.Cmd) { if act == nil { return m, nil } - next, err := config.ParseEndpoint(value, act.endpoint.BridgeID) + next, err := config.ParseEndpoint(value, act.endpoint().BridgeID) 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 } - if sameEndpoint(next, act.endpoint) { + if config.SameEndpoint(next, act.endpoint()) { act.override.reset() return m, nil } @@ -551,30 +430,17 @@ func (m *model) overrideActivationURL(value string) (tea.Model, tea.Cmd) { // 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 sameEndpoint(next, act.endpoint) { - return m.activateEndpointCmd(next) + if act == nil || act.attempt == nil { + return m.connect(next, false, nil) } m.stopActivation() - - ephemeral := !m.endpointConfigured(next) - if act.ephemeral { - // Replace rather than add: the guessed endpoint was never reachable - // and nobody asked for it. - if err := m.g.ReplaceEndpoint(act.endpoint, next); err != nil { - m.errMsg = err.Error() - m.step = stepError - return nil - } - } else if ephemeral { - if err := m.g.UpsertEndpoint(next); err != nil { - m.errMsg = err.Error() - m.step = stepError - return nil - } + a, err := m.bridging().Retarget(act.attempt, next) + if err != nil { + m.errMsg = err.Error() + m.step = stepError + return nil } - cmd := m.activateEndpoint(next, ephemeral, false) - m.act.replaces = act.replaces - return cmd + return m.startAttempt(a) } // bridgeLogSink is where the attempt's events land on their way to the update @@ -639,15 +505,12 @@ func (m *model) quitCmd() tea.Cmd { if m.act != nil { cancel = m.act.cancel } - bridgeManager := m.bridgeManager + 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()} } } @@ -658,60 +521,33 @@ 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()) - 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: if m.act == nil || msg.id != m.act.id { // Cancelled or overridden: a newer attempt owns the screen. return m, nil } m.act.cancel = nil + bridging := m.bridging() + failed := m.act.endpoint() if msg.err != nil { - if sameEndpoint(msg.endpoint, m.g.ActiveEndpoint()) { + if bridging.Fail(m.act.attempt) { m.connected = false } m.preflightErr = msg.err.Error() m.forcedToEndpoint = true - failed := msg.endpoint m.failedEndpoint = &failed m.step = stepMenu m.resetStack(m.setupGuideMenu()) return m, nil } - if !sameEndpoint(m.g.ActiveEndpoint(), msg.endpoint) || m.act.replaces != nil { - if err := m.g.SetActiveEndpoint(msg.endpoint, m.act.replaces); 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 := bridging.Commit(m.act.attempt, msg.verified); err != nil { + m.preflightErr = err.Error() + m.forcedToEndpoint = true + m.failedEndpoint = &failed + m.step = stepMenu + m.resetStack(m.setupGuideMenu()) + return m, nil } - m.act.replaces = nil - m.act.ephemeral = false - m.recordBridgeTailnet(msg.endpoint) - m.g.ApertureHost = msg.host - m.g.Providers = msg.providers m.connected = true m.preflightErr = "" m.forcedToEndpoint = false @@ -794,11 +630,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 + cmd := m.connect(m.g.ActiveEndpoint(), false, nil) // No cancel handle: this re-check owns the screen until it answers. - m.activationSeq++ - m.act = &activation{id: m.activationSeq, label: "Checking " + m.g.ApertureHost + " ...", started: time.Now()} - return m, tea.Batch(runPreflight(m.g.ApertureHost), activationTick(m.act.id)) + if m.act != nil { + m.act.cancel = nil + } + return m, cmd case menu.InstallDoneMsg: if msg.Err != nil { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 63985bb..59e0ad4 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -13,6 +13,7 @@ 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" @@ -417,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.Endpoint{URL: "http://ai"}}} + m.Update(endpointActivationResult{id: 1, err: fmt.Errorf("connection refused")}) if !m.forcedToEndpoint { t.Error("forcedToEndpoint should be true") } @@ -438,11 +441,7 @@ func TestEndpointActivationFailure_ShowsSetupGuide(t *testing.T) { g: &config.Global{ApertureHost: "http://ai"}, } m.activateEndpointCmd(ep) - m.Update(endpointActivationResult{ - id: m.act.id, - endpoint: ep, - err: fmt.Errorf("timeout"), - }) + m.Update(endpointActivationResult{id: m.act.id, err: fmt.Errorf("timeout")}) if !m.forcedToEndpoint { t.Error("forcedToEndpoint should be true") } @@ -572,10 +571,10 @@ func TestEndpointBridgeMenu_AddsFirstBridgeInline(t *testing.T) { t.Fatalf("step = %v, want stepPreflight", m.step) } want := config.Endpoint{URL: config.DefaultLocation, BridgeID: bridgeID} - if m.act == nil || !sameEndpoint(m.act.endpoint, want) { + if m.act == nil || !config.SameEndpoint(m.act.endpoint(), want) { t.Fatalf("activation = %+v, want %+v", m.act, want) } - if !m.act.ephemeral { + if !m.act.attempt.Ephemeral() { t.Error("guessed endpoint is not marked ephemeral, so abandoning it would leave it behind") } if !m.endpointConfigured(want) { @@ -610,7 +609,7 @@ func TestEndpointBridgeMenu_ConnectsExistingBridgeWithoutPrompting(t *testing.T) if m.step != stepPreflight { t.Fatalf("step = %v, want stepPreflight", m.step) } - if m.act == nil || m.act.endpoint.URL != config.DefaultLocation || m.act.endpoint.BridgeID != bridge.ID { + if m.act == nil || m.act.endpoint().URL != config.DefaultLocation || m.act.endpoint().BridgeID != bridge.ID { t.Fatalf("activation = %+v, want %s via %s", m.act, config.DefaultLocation, bridge.ID) } if !m.act.overridable() { @@ -636,13 +635,13 @@ func TestInitOpensOnTheStartEndpoint(t *testing.T) { if cmd := m.Init(); cmd == nil { t.Fatal("Init did not start a connection") } - if m.act == nil || !sameEndpoint(m.act.endpoint, named) { + if m.act == nil || !config.SameEndpoint(m.act.endpoint(), named) { t.Fatalf("activation = %+v, want %+v", m.act, named) } - if !m.act.ephemeral { + 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(); !sameEndpoint(got, saved) { + if got := m.g.ActiveEndpoint(); !config.SameEndpoint(got, saved) { t.Errorf("active endpoint = %+v, want %+v until the attempt succeeds", got, saved) } } @@ -660,10 +659,10 @@ func TestInitOpensOnTheSavedEndpointWhenNothingIsNamed(t *testing.T) { if cmd := m.Init(); cmd == nil { t.Fatal("Init did not start a connection") } - if m.act == nil || !sameEndpoint(m.act.endpoint, saved) { + if m.act == nil || !config.SameEndpoint(m.act.endpoint(), saved) { t.Fatalf("activation = %+v, want %+v", m.act, saved) } - if m.act.ephemeral { + if m.act.attempt.Ephemeral() { t.Error("the saved endpoint is not ephemeral; cancelling must not delete it") } } @@ -697,14 +696,14 @@ func TestPreflightOverrideReplacesGuessedEndpoint(t *testing.T) { t.Error("guessed attempt was not cancelled") } want := config.Endpoint{URL: "http://aperture.example.ts.net", BridgeID: bridge.ID} - if m.act == nil || !sameEndpoint(m.act.endpoint, want) { + if m.act == nil || !config.SameEndpoint(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 || !sameEndpoint(got[0], previous) || !sameEndpoint(got[1], want) { + if got := m.g.Settings.Endpoints; len(got) != 2 || !config.SameEndpoint(got[0], previous) || !config.SameEndpoint(got[1], want) { t.Fatalf("endpoints = %+v, want the previous one plus the typed one", got) } } @@ -787,11 +786,11 @@ func TestPreflightEscapeAbandonsDiscovery(t *testing.T) { 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 || !sameEndpoint(got[0], previous) { + if got := m.g.Settings.Endpoints; len(got) != 1 || !config.SameEndpoint(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, endpoint: guessed.endpoint, err: fmt.Errorf("too late")}) + 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()) } @@ -877,10 +876,10 @@ func TestEndpointsMenu_EditRetargetsWorkingEndpoint(t *testing.T) { if got := m.g.ActiveEndpoint(); got != connected { t.Fatalf("active endpoint = %+v, want %+v until verification", got, connected) } - if m.act == nil || !sameEndpoint(m.act.endpoint, want) { + if m.act == nil || !config.SameEndpoint(m.act.endpoint(), want) { t.Fatalf("activation = %+v, want a connection to %+v", m.act, want) } - m.Update(endpointActivationResult{id: m.act.id, endpoint: want, host: "http://127.0.0.1:12345"}) + m.Update(endpointActivationResult{id: m.act.id, verified: bridges.Verified{Gateway: "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) } @@ -976,7 +975,7 @@ func TestConnectionPicker_ConnectsViaUnusedBridge(t *testing.T) { m.activate(connect) want := config.Endpoint{URL: config.DefaultLocation, BridgeID: "bridge-bbbbbb"} - if m.act == nil || !sameEndpoint(m.act.endpoint, want) { + if m.act == nil || !config.SameEndpoint(m.act.endpoint(), want) { t.Fatalf("activation = %+v, want a connection to %+v", m.act, want) } if !m.endpointConfigured(want) { @@ -999,7 +998,7 @@ func TestConnectionPicker_SwitchTailnetConfirmsThenReconnects(t *testing.T) { yes, _ := findItem(t, m.top().Items, "Switch tailnet") m.activate(yes) - if m.act == nil || m.act.endpoint.BridgeID != "bridge-aaaaaa" { + if m.act == nil || m.act.endpoint().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. @@ -1102,7 +1101,7 @@ func TestBridgesMenu_ConnectsThroughBridge(t *testing.T) { m.activate(idx) want := config.Endpoint{URL: config.DefaultLocation, BridgeID: "bridge-bbbbbb"} - if m.act == nil || !sameEndpoint(m.act.endpoint, want) { + if m.act == nil || !config.SameEndpoint(m.act.endpoint(), want) { t.Fatalf("activation = %+v, want a connection to %+v", m.act, want) } } @@ -1165,7 +1164,7 @@ func TestBridgeEndpointFailureKeepsPreviousEndpointActive(t *testing.T) { t.Fatal("selecting the bridge did not begin activation") } want := config.Endpoint{URL: config.DefaultLocation, BridgeID: bridge.ID} - if got := m.g.ActiveEndpoint(); !sameEndpoint(got, old) { + if got := m.g.ActiveEndpoint(); !config.SameEndpoint(got, old) { t.Fatalf("active endpoint changed before activation: %+v", got) } if !m.endpointConfigured(want) { @@ -1177,11 +1176,11 @@ func TestBridgeEndpointFailureKeepsPreviousEndpointActive(t *testing.T) { if !ok { t.Fatalf("activation message = %T", msg) } - if !sameEndpoint(result.endpoint, want) || result.err == nil { + if !config.SameEndpoint(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(); !config.SameEndpoint(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" { @@ -1218,7 +1217,7 @@ 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(); !config.SameEndpoint(got, old) { t.Fatalf("active endpoint changed before /v1/models: %+v", got) } m.Update(activationResult(t, cmd)) @@ -1438,10 +1437,10 @@ func TestAuthFooterCopyKey(t *testing.T) { width: 100, step: stepPreflight, act: &activation{ - id: 3, - authURL: testAuthURL, - endpoint: config.Endpoint{BridgeID: "b1"}, - cancel: func() {}, + id: 3, + authURL: testAuthURL, + attempt: &bridges.Attempt{Endpoint: config.Endpoint{BridgeID: "b1"}}, + cancel: func() {}, }, } if !m.act.overridable() { @@ -1521,63 +1520,6 @@ func TestAuthFooterLinksEveryWrappedLine(t *testing.T) { } } -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) - } -} - -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() - - got, err := fetchProviders(srv.URL + "/") - 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() - - ctx, cancel := context.WithCancel(context.Background()) - result := make(chan error, 1) - go func() { - _, err := fetchProvidersContext(ctx, srv.URL, time.Minute) - result <- err - }() - <-requestStarted - cancel() - - if err := <-result; !errors.Is(err, context.Canceled) { - t.Fatalf("fetchProvidersContext error = %v, want context canceled", err) - } -} - func modelsServer(t *testing.T) *httptest.Server { t.Helper() return modelsServerWithHandler(t, nil) From 182cd8b86477b17bd177fbf3fe8630a5e8a9384a Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Mon, 21 Sep 2026 16:20:51 +0000 Subject: [PATCH 48/69] config,bridges,tui: make Endpoint two types, and give Attempt its own transitions An Endpoint carried a BridgeID that was empty for a direct connection, so every caller decided the kind by testing a string for emptiness and the two concepts shared one struct. Endpoint is now an interface with two closed implementations, DirectEndpoint and BridgeEndpoint; the kind is the type, the values compare with ==, and settings.json keeps the shape it had, with bridgeId present or absent, because the file predates the split and is not changing under existing users. The Bridging service from 8ce575d is gone. Its attempt half was the ConnectionAttempt's own transitions with the entity passed as an argument, so those are methods on Attempt now: BeginAttempt, Retarget, Run, Commit, Abandon. Commit rather than Succeed because it persists a result Run already produced and decides nothing; Fail is gone because whether the failing endpoint was the active one is knowable when the attempt begins, so it is a field. The removal half hangs off the real nouns it is about: Machines.Destroy and Machines.Tailnet on the collection, DestroysMachine and ForgetBridge as the two halves of removing a Bridge. No process object, no invented noun. The run log names the activated URL by scheme and host only: ParseEndpointURL accepts userinfo and a query, and the run log is the file people share. --- internal/bridges/attempt.go | 224 ++++++++++++++++ internal/bridges/bridging.go | 426 ------------------------------- internal/bridges/events.go | 12 + internal/bridges/fetch.go | 53 ++++ internal/bridges/remove.go | 146 +++++++++++ internal/config/endpoint.go | 94 ++++++- internal/config/endpoint_test.go | 59 +++-- internal/config/global.go | 36 +-- internal/config/settings.go | 6 +- internal/config/startup.go | 15 +- internal/config/startup_test.go | 12 +- internal/config/state_test.go | 38 +-- internal/tui/connection_test.go | 7 +- internal/tui/menus.go | 101 ++++---- internal/tui/removal.go | 79 +++--- internal/tui/removal_test.go | 6 +- internal/tui/tui.go | 64 ++--- internal/tui/tui_test.go | 140 +++++----- 18 files changed, 810 insertions(+), 708 deletions(-) create mode 100644 internal/bridges/attempt.go delete mode 100644 internal/bridges/bridging.go create mode 100644 internal/bridges/fetch.go create mode 100644 internal/bridges/remove.go diff --git a/internal/bridges/attempt.go b/internal/bridges/attempt.go new file mode 100644 index 0000000..2e98e14 --- /dev/null +++ b/internal/bridges/attempt.go @@ -0,0 +1,224 @@ +package bridges + +import ( + "context" + "fmt" + "log/slog" + "slices" + + "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 that abandoning it can take that back +// out, and which Endpoint it is an edit of, so that committing 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. Everything else reads or writes settings and runs where settings +// are read, which for the TUI is its update loop: nothing else serializes +// access to config.Global. +type Attempt struct { + Endpoint config.Endpoint + // InvalidatesActive reports that starting this attempt leaves the active + // destination unverified: the Machine it launches through is being logged + // out, and cancellation cannot prove the logout did not run (ADR 0003). + InvalidatesActive bool + // TargetsActive reports that this attempt is at the active Endpoint, so + // its failure leaves the active destination unverified too. + TargetsActive bool + + bridge config.Bridge + // ephemeral: BeginAttempt wrote Endpoint into settings so the failure + // screen has something to name, retry and edit. Abandon removes it; + // failure keeps it. + ephemeral bool + replaces config.Endpoint + switchTailnet 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; 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: 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 verifies next before removing ep, keeping ep until it does +// (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 swaps the Endpoint this attempt probes for one 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. 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 + } + return n, nil +} + +// Retry is the same attempt again. A tailnet switch is not repeated: it ran, +// or failed, the first time, and the retry is about reaching the Endpoint. +func (a *Attempt) Retry() *Attempt { + next := *a + next.switchTailnet = false + next.InvalidatesActive = false + return &next +} + +// Bridge is the Bridge this attempt connects through, zero 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 } + +// Verified is what a successful attempt produced: the Gateway a client sends +// requests to, the providers it answered with and, through a Bridge, the +// tailnet the Machine joined. +type Verified struct { + Gateway string + Tailnet string + Providers []config.ProviderInfo +} + +// Run carries the attempt to a verified Gateway or an error, reporting each +// wait on emit. It writes nothing: Commit does, once the caller knows the +// result is still wanted. +func (a *Attempt) Run(ctx context.Context, machines *Machines, emit func(connection.Event)) (Verified, error) { + bridged, ok := a.Endpoint.(config.BridgeEndpoint) + if !ok { + provs, err := fetchProviders(ctx, a.Endpoint.URL(), providerFetchTimeout) + if err != nil { + return Verified{}, err + } + return Verified{Gateway: a.Endpoint.URL(), Providers: provs}, nil + } + // Stamps 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 Verified{}, 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 Verified{}, err + } + } + if err := mc.Open(ctx, emit); err != nil { + return Verified{}, err + } + route, err := mc.RouteTo(ctx, bridged.URL(), emit) + if err != nil { + return Verified{}, 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 Verified{}, fmt.Errorf("bridge %s could not reach %s: %w", a.bridge.Name, bridged.URL(), err) + } + return Verified{Gateway: route.LocalURL, Tailnet: mc.Tailnet(), Providers: provs}, nil +} + +// Commit makes a verified 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 and providers +// become what clients launch against. +func (a *Attempt) Commit(g *config.Global, v Verified) 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 != "" && v.Tailnet != "" { + // A failed write is not worth interrupting a connection that worked. + if err := g.SetBridgeTailnet(a.bridge.ID, v.Tailnet); err != nil { + slog.Warn("could not record the bridge's tailnet", "bridge", a.bridge.ID, "err", err) + } + } + g.ApertureHost = v.Gateway + g.Providers = v.Providers + return nil +} + +// Abandon is the user giving up on the attempt. The candidate it added comes +// back out of settings, so nothing the user did not choose is left behind. 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 + } + a.ephemeral = false + return g.DropEndpoint(a.Endpoint) +} diff --git a/internal/bridges/bridging.go b/internal/bridges/bridging.go deleted file mode 100644 index 767136c..0000000 --- a/internal/bridges/bridging.go +++ /dev/null @@ -1,426 +0,0 @@ -package bridges - -import ( - "context" - "errors" - "fmt" - "io" - "log/slog" - "net/http" - "strings" - "time" - - "github.com/tailscale/aperture-cli/internal/config" - "github.com/tailscale/aperture-cli/internal/connection" -) - -const ( - providerFetchTimeout = 10 * time.Second - bridgeProviderFetchTimeout = 30 * time.Second -) - -// 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 - -// Bridging is the Connection context's domain service. It connects the user -// to an Aperture from an Endpoint, through a Bridge's Machine when the -// Endpoint names one, and keeps the Bridge record and its Machine in -// agreement: joining records the tailnet on the Bridge, switching clears it, -// removing the Bridge destroys the Machine first (ADR 0002). It holds no state -// of its own. -// -// Every operation that waits on the network is split in two. The waiting half -// (Run, Destroy) takes a context and may run on any goroutine. The half that -// reads or writes settings (Begin, Commit, Abandon, Forget) must run where -// settings are read, which for the TUI is its update loop: nothing else -// serializes access to config.Global. -type Bridging struct { - Machines *Machines - Settings *config.Global -} - -// Attempt is one try at reaching an Aperture from one Endpoint. It remembers -// what it wrote to settings on the user's behalf, so that abandoning it can -// take that back out, and which Endpoint it is an edit of, so that success can -// commit the edit and the removal of the original in one write (ADR 0003). -type Attempt struct { - Endpoint config.Endpoint - // InvalidatesActive reports that starting this attempt leaves the active - // destination unverified: the Machine it launches through is being logged - // out, and cancellation cannot prove the logout did not run (ADR 0003). - InvalidatesActive bool - - bridge config.Bridge - // ephemeral: Begin wrote Endpoint into settings so the failure screen has - // something to name, retry and edit. Abandon removes it; failure keeps it. - ephemeral bool - replaces *config.Endpoint - switchTailnet bool -} - -// Bridge is the Bridge this attempt connects through, zero 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 } - -// Retry is the same attempt again. A tailnet switch is not repeated: it ran, -// or failed, the first time, and the retry is about reaching the Endpoint. -func (a *Attempt) Retry() *Attempt { - next := *a - next.switchTailnet = false - next.InvalidatesActive = false - return &next -} - -// Verified is what a successful attempt produced: the Gateway a client sends -// requests to, the providers it answered with and, through a Bridge, the -// tailnet the Machine joined. -type Verified struct { - Gateway string - Tailnet string - Providers []config.ProviderInfo -} - -// Begin 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; -// 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: an abandoned login would -// otherwise leave the picker naming a tailnet the bridge has already left. -func (b Bridging) Begin(ep config.Endpoint, switchTailnet bool, replacing *config.Endpoint) (*Attempt, error) { - a := &Attempt{Endpoint: ep, replaces: replacing} - if ep.BridgeID != "" { - bridge, ok := b.Settings.Bridge(ep.BridgeID) - if !ok { - return nil, fmt.Errorf("bridge %s is not configured", ep.BridgeID) - } - a.bridge = bridge - if switchTailnet { - if err := b.Settings.SetBridgeTailnet(ep.BridgeID, ""); err != nil { - return nil, err - } - a.switchTailnet = true - a.InvalidatesActive = ep.BridgeID == b.Settings.ActiveEndpoint().BridgeID - } - } - if !b.configured(ep) { - if err := b.Settings.UpsertEndpoint(ep); err != nil { - return nil, err - } - a.ephemeral = true - } - return a, nil -} - -// Retarget swaps the Endpoint an attempt probes for one 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. The same Endpoint again is a retry. -func (b Bridging) Retarget(a *Attempt, next config.Endpoint) (*Attempt, error) { - if config.SameEndpoint(next, a.Endpoint) { - return a.Retry(), nil - } - ephemeral := !b.configured(next) - switch { - case a.ephemeral: - if err := b.Settings.ReplaceEndpoint(a.Endpoint, next); err != nil { - return nil, err - } - case ephemeral: - if err := b.Settings.UpsertEndpoint(next); err != nil { - return nil, err - } - } - n := &Attempt{Endpoint: next, replaces: a.replaces, ephemeral: ephemeral} - if next.BridgeID != "" { - bridge, ok := b.Settings.Bridge(next.BridgeID) - if !ok { - return nil, fmt.Errorf("bridge %s is not configured", next.BridgeID) - } - n.bridge = bridge - } - return n, nil -} - -// Edit verifies next before removing ep, keeping ep until it does (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 (b Bridging) Edit(current *Attempt, ep, next config.Endpoint) (*Attempt, error) { - if current != nil && config.SameEndpoint(current.Endpoint, ep) && current.replaces != nil { - return b.Retarget(current, next) - } - return b.Begin(next, false, &ep) -} - -// Run carries the attempt to a verified Gateway or an error, reporting each -// wait on emit. It writes nothing: Commit does, once the caller knows the -// result is still wanted. -func (b Bridging) Run(ctx context.Context, a *Attempt, emit func(connection.Event)) (Verified, error) { - if a.Endpoint.BridgeID == "" { - provs, err := fetchProviders(ctx, a.Endpoint.URL, providerFetchTimeout) - if err != nil { - return Verified{}, err - } - return Verified{Gateway: a.Endpoint.URL, Providers: provs}, nil - } - // Stamps 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", a.Endpoint.URL, "bridge", a.bridge.ID, "switchTailnet", a.switchTailnet) - mc, err := b.Machines.For(a.bridge) - if err != nil { - return Verified{}, 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 Verified{}, err - } - } - if err := mc.Open(ctx, emit); err != nil { - return Verified{}, err - } - route, err := mc.RouteTo(ctx, a.Endpoint.URL, emit) - if err != nil { - return Verified{}, 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 Verified{}, fmt.Errorf("bridge %s could not reach %s: %w", a.bridge.Name, a.Endpoint.URL, err) - } - return Verified{Gateway: route.LocalURL, Tailnet: mc.Tailnet(), Providers: provs}, nil -} - -// Commit makes a verified 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 and providers become -// what clients launch against. -func (b Bridging) Commit(a *Attempt, v Verified) error { - g := b.Settings - if !config.SameEndpoint(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.Endpoint.BridgeID != "" && v.Tailnet != "" { - // A failed write is not worth interrupting a connection that worked. - if err := g.SetBridgeTailnet(a.Endpoint.BridgeID, v.Tailnet); err != nil { - slog.Warn("could not record the bridge's tailnet", "bridge", a.Endpoint.BridgeID, "err", err) - } - } - g.ApertureHost = v.Gateway - g.Providers = v.Providers - return nil -} - -// Fail is the attempt not verifying. The candidate stays: the failure screen -// names it for retry and edit (ADR 0003). Reports whether the active -// destination is unverified as a result, which it is when the failing -// Endpoint is the active one. -func (b Bridging) Fail(a *Attempt) (invalidatesActive bool) { - return config.SameEndpoint(a.Endpoint, b.Settings.ActiveEndpoint()) -} - -// Abandon is the user giving up on the attempt. The candidate it added comes -// back out of settings, so nothing the user did not choose is left behind. -func (b Bridging) Abandon(a *Attempt) error { - if a == nil || !a.ephemeral { - return nil - } - a.ephemeral = false - return b.Settings.DropEndpoint(a.Endpoint) -} - -// Tailnet is the network a Bridge reaches, preferring what its running -// Machine reports to what was saved: a bridge that switched tailnets this -// session leaves a stale name on disk until the next verified connection -// rewrites it. -func (b Bridging) Tailnet(bridge config.Bridge) string { - if mc := b.Machines.lookup(bridge.ID); mc != nil { - if name := mc.Tailnet(); name != "" { - return name - } - } - return bridge.Tailnet -} - -// Removal is what one delete is about: the Bridge, and the Endpoint that was -// the last reason to keep it. Either can be absent. -type Removal struct { - Bridge config.Bridge - Endpoint *config.Endpoint -} - -// Unconfirmed is a removal the tailnet did not confirm within the wait. The -// local records are gone; the device may not be, and the user has to be told -// where to look for it. -type Unconfirmed struct { - Bridge config.Bridge - Wait time.Duration - Err error -} - -func (e *Unconfirmed) Error() string { - return fmt.Sprintf("the tailnet did not confirm within %s: %v", e.Wait, e.Err) -} - -func (e *Unconfirmed) Unwrap() error { return e.Err } - -// Destroys reports whether removing rem takes a Machine off a tailnet: rem is -// the Bridge's last Endpoint and the Bridge has started a Machine. A Bridge -// that never started has no device, and must not start one to find out. An -// error means rem may not go at all. -func (b Bridging) Destroys(rem Removal) (bool, error) { - if err := b.removable(rem); err != nil { - return false, err - } - if rem.Bridge.ID == "" || !HasMachine(rem.Bridge.ID) { - return false, nil - } - for _, ep := range b.Settings.Settings.Endpoints { - if ep.BridgeID != rem.Bridge.ID { - continue - } - if rem.Endpoint == nil || !config.SameEndpoint(ep, *rem.Endpoint) { - return false, nil - } - } - return true, nil -} - -// Destroy takes rem's Machine off its tailnet, waiting at most destroyTimeout -// for the tailnet to confirm. Settings are untouched: Forget drops them once -// the caller has the outcome, because they are the only record that the -// device exists. Only for a removal Destroys said yes to. -func (b Bridging) Destroy(ctx context.Context, rem Removal, emit func(connection.Event)) error { - mc, err := b.Machines.For(rem.Bridge) - if err != nil { - return err - } - ctx, cancel := context.WithTimeout(ctx, destroyTimeout) - defer cancel() - err = mc.Destroy(ctx, emit) - if err != nil && ctx.Err() != nil { - return &Unconfirmed{Bridge: rem.Bridge, Wait: destroyTimeout, Err: err} - } - return err -} - -// Forget drops the records rem covers, endpoint first: a Bridge an Endpoint -// still points at cannot be removed. destroyErr is Destroy's outcome, nil for -// a removal with nothing to destroy. A refusal keeps everything and is -// returned as is: the device is still on the tailnet and settings are the -// only thing naming it. A wait that expired drops the records and returns the -// *Unconfirmed, because the device may have outlived the wait. -func (b Bridging) Forget(rem Removal, destroyErr error) error { - var unconfirmed *Unconfirmed - if destroyErr != nil && !errors.As(destroyErr, &unconfirmed) { - return destroyErr - } - if err := b.removable(rem); err != nil { - return err - } - g := b.Settings - if rem.Endpoint != nil { - if err := g.DropEndpoint(*rem.Endpoint); err != nil { - return err - } - } - // Settings hold two objects where the picker shows one row, so removing - // the endpoint alone left the bridge re-listed as a bare "Connect via" - // row: to the user the row moved instead of going. A bridge two endpoints - // reach through stays. - if rem.Bridge.ID != "" && !b.bridgeUsed(rem.Bridge.ID) { - if err := g.RemoveBridge(rem.Bridge.ID); err != nil { - return err - } - } - return destroyErr -} - -// removable is why rem may not go: it is the active endpoint, which is the -// connection the user falls back to, or a bare Bridge some Endpoint still -// reaches through. -func (b Bridging) removable(rem Removal) error { - if rem.Endpoint != nil && config.SameEndpoint(*rem.Endpoint, b.Settings.ActiveEndpoint()) { - return errors.New("connect to another endpoint before removing the active one") - } - if rem.Endpoint == nil && rem.Bridge.ID != "" { - for _, ep := range b.Settings.Settings.Endpoints { - if ep.BridgeID == rem.Bridge.ID { - return fmt.Errorf("bridge %s is used by endpoint %s; remove that connection instead", rem.Bridge.Name, ep.URL) - } - } - } - return nil -} - -func (b Bridging) bridgeUsed(bridgeID string) bool { - for _, ep := range b.Settings.Settings.Endpoints { - if ep.BridgeID == bridgeID { - return true - } - } - return false -} - -func (b Bridging) configured(want config.Endpoint) bool { - for _, ep := range b.Settings.Settings.Endpoints { - if config.SameEndpoint(ep, want) { - return true - } - } - return false -} - -// fetchProviders asks an Aperture what it serves. This is the attempt's -// AskingForModels phase and the verification everything else waits on. -func fetchProviders(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 - } - // 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, url, detail) - } - return nil, fmt.Errorf("unexpected status %d from %s", resp.StatusCode, url) - } - 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/events.go b/internal/bridges/events.go index 1583f03..7c63efb 100644 --- a/internal/bridges/events.go +++ b/internal/bridges/events.go @@ -2,6 +2,7 @@ package bridges import ( "log/slog" + "net/url" "regexp" "sync" @@ -78,3 +79,14 @@ 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) login(link connection.LoginLink) { e(connection.Login(link)) } + +// redactURL is the part of an endpoint URL safe for the run log: scheme and +// host. ParseEndpointURL accepts userinfo and a query, and a 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..db43991 --- /dev/null +++ b/internal/bridges/fetch.go @@ -0,0 +1,53 @@ +package bridges + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/tailscale/aperture-cli/internal/config" +) + +const ( + providerFetchTimeout = 10 * time.Second + bridgeProviderFetchTimeout = 30 * time.Second +) + +// fetchProviders asks an Aperture what it serves. This is the attempt's +// AskingForModels phase and the verification everything else waits on. +func fetchProviders(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 + } + // 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, url, detail) + } + return nil, fmt.Errorf("unexpected status %d from %s", resp.StatusCode, url) + } + 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/remove.go b/internal/bridges/remove.go new file mode 100644 index 0000000..bf4858c --- /dev/null +++ b/internal/bridges/remove.go @@ -0,0 +1,146 @@ +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 + +// Unconfirmed is a removal the tailnet did not confirm within the wait. The +// local records are gone; the device may not be, and the user has to be told +// where to look for it. +type Unconfirmed struct { + Bridge config.Bridge + Wait time.Duration + Err error +} + +func (e *Unconfirmed) Error() string { + return fmt.Sprintf("the tailnet did not confirm within %s: %v", e.Wait, e.Err) +} + +func (e *Unconfirmed) Unwrap() error { return e.Err } + +// DestroysMachine reports whether removing ep, or the bare bridge when ep is +// nil, takes a Machine off a tailnet: ep is the Bridge's last Endpoint and the +// Bridge has started a Machine. A Bridge that never started has no device, and +// must not start one to find out. An error means it may not be removed at all. +func DestroysMachine(g *config.Global, bridge config.Bridge, ep config.Endpoint) (bool, error) { + if err := removable(g, bridge, ep); err != nil { + return false, err + } + if bridge.ID == "" || !HasMachine(bridge.ID) { + return false, nil + } + for _, other := range g.Settings.Endpoints { + if through(other, bridge.ID) && other != ep { + return false, nil + } + } + return true, nil +} + +// Destroy takes the bridge's Machine off its tailnet, waiting at most +// destroyTimeout for the tailnet to confirm. Settings are untouched: +// ForgetBridge drops them once the caller has the outcome, because they are +// the only record that the device exists. +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 err != nil && ctx.Err() != nil { + return &Unconfirmed{Bridge: bridge, Wait: destroyTimeout, Err: err} + } + return err +} + +// Tailnet is the network a Bridge reaches, preferring what its running +// Machine reports to what was saved: a bridge that switched tailnets this +// session leaves a stale name on disk 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 +} + +// ForgetBridge drops the records a removal covers, endpoint first: a Bridge +// an Endpoint still points at cannot be removed. destroyErr is Destroy's +// outcome, nil for a removal with nothing to destroy. A refusal keeps +// everything and is returned as is: the device is still on the tailnet and +// settings are the only thing naming it. A wait that expired drops the +// records and returns the *Unconfirmed, because the device may have outlived +// the wait. +// +// Settings hold two objects where the picker shows one row, so removing the +// endpoint alone left the bridge re-listed as a bare "Connect via" row: to the +// user the row moved instead of going. A bridge two endpoints reach through +// stays. +func ForgetBridge(g *config.Global, bridge config.Bridge, ep config.Endpoint, destroyErr error) error { + var unconfirmed *Unconfirmed + if destroyErr != nil && !errors.As(destroyErr, &unconfirmed) { + return destroyErr + } + if err := removable(g, bridge, ep); err != nil { + return err + } + if ep != nil { + if err := g.DropEndpoint(ep); err != nil { + return err + } + } + if bridge.ID != "" && !bridgeUsed(g, bridge.ID) { + if err := g.RemoveBridge(bridge.ID); err != nil { + return err + } + } + return destroyErr +} + +// removable is why ep, or the bare bridge, may not go: it is the active +// endpoint, which is the connection the user falls back to, or a Bridge some +// Endpoint still reaches through. +func removable(g *config.Global, bridge config.Bridge, ep config.Endpoint) error { + if ep != nil && ep == g.ActiveEndpoint() { + return errors.New("connect to another endpoint before removing the active one") + } + if ep == nil && bridge.ID != "" { + for _, other := range g.Settings.Endpoints { + if through(other, bridge.ID) { + return fmt.Errorf("bridge %s is used by endpoint %s; remove that connection instead", bridge.Name, other.URL()) + } + } + } + return nil +} + +func bridgeUsed(g *config.Global, bridgeID string) bool { + for _, ep := range g.Settings.Endpoints { + if through(ep, bridgeID) { + return true + } + } + return false +} + +// through reports whether ep is reached through bridgeID. +func through(ep config.Endpoint, bridgeID string) bool { + bridged, ok := ep.(config.BridgeEndpoint) + return ok && bridged.BridgeID() == bridgeID +} diff --git a/internal/config/endpoint.go b/internal/config/endpoint.go index 3ef0572..b25afe7 100644 --- a/internal/config/endpoint.go +++ b/internal/config/endpoint.go @@ -1,6 +1,7 @@ package config import ( + "encoding/json" "fmt" "net/url" "strings" @@ -11,12 +12,41 @@ import ( // fallback 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"` +// Endpoint is a remote Aperture and the way to it. There are two kinds and no +// third: a DirectEndpoint the host reaches itself, and a BridgeEndpoint +// reached through a Bridge's Machine. Values are comparable, so two Endpoints +// are the same when == says so. +type Endpoint interface { + URL() string + // WithURL is the same way to a different Aperture: an edit or an inline + // override keeps its Bridge. + WithURL(url string) Endpoint + endpoint() +} + +// DirectEndpoint is an Aperture the host reaches over its own network. +type DirectEndpoint struct{ url string } + +// BridgeEndpoint is an Aperture reached through the Machine of one Bridge. +type BridgeEndpoint struct{ url, bridgeID string } + +// Direct is the Endpoint for an Aperture at url reached without a Bridge. +func Direct(url string) DirectEndpoint { return DirectEndpoint{url: url} } + +// Bridged is the Endpoint for an Aperture at url reached 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 is an embedded tsnet node used to reach Aperture without requiring // Tailscale to run on the host. type Bridge struct { @@ -28,21 +58,61 @@ type Bridge struct { Tailnet string `json:"tailnet,omitempty"` } -// ParseEndpoint turns user input into an Endpoint reached over bridgeID, which -// is empty for a direct connection. A bare host is assumed to be http, since -// Aperture is reached over the tailnet. -func ParseEndpoint(value, bridgeID string) (Endpoint, error) { +// ParseEndpointURL turns user input into the URL an Endpoint is made from. A +// bare host is assumed to be 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 Endpoint{}, fmt.Errorf("endpoint URL must be an absolute http or https URL") + return "", fmt.Errorf("endpoint URL must be an absolute http or https URL") } - return Endpoint{URL: strings.TrimRight(value, "/"), BridgeID: bridgeID}, nil + return strings.TrimRight(value, "/"), nil } -func SameEndpoint(a, b Endpoint) bool { - return a.URL == b.URL && a.BridgeID == b.BridgeID +// endpointRecord is how an Endpoint is written to settings.json: one shape for +// both kinds, the kind told by whether bridgeId is present. 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 is the settings field: a list whose elements are an interface, +// which encoding/json cannot decode 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 index 83d6dc7..584b9cc 100644 --- a/internal/config/endpoint_test.go +++ b/internal/config/endpoint_test.go @@ -2,43 +2,62 @@ package config import "testing" -func TestParseEndpoint(t *testing.T) { +func TestParseEndpointURL(t *testing.T) { tests := []struct { - name string - in string - bridgeID string - want Endpoint - wantErr bool + name string + in string + want string + wantErr bool }{ - {name: "bare host assumes http", in: "ai", want: Endpoint{URL: "http://ai"}}, - {name: "trailing slash trimmed", in: "http://ai/", want: Endpoint{URL: "http://ai"}}, - {name: "surrounding space trimmed", in: " http://ai ", want: Endpoint{URL: "http://ai"}}, - {name: "https preserved", in: "https://aperture.example.ts.net", want: Endpoint{URL: "https://aperture.example.ts.net"}}, - { - name: "bridge recorded", - in: "aperture", - bridgeID: "bridge-abcdef", - want: Endpoint{URL: "http://aperture", BridgeID: "bridge-abcdef"}, - }, + {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 := ParseEndpoint(tt.in, tt.bridgeID) + got, err := ParseEndpointURL(tt.in) if tt.wantErr { if err == nil { - t.Fatalf("ParseEndpoint(%q) = %+v, want error", tt.in, got) + t.Fatalf("ParseEndpointURL(%q) = %q, want error", tt.in, got) } return } if err != nil { - t.Fatalf("ParseEndpoint(%q) error = %v", tt.in, err) + t.Fatalf("ParseEndpointURL(%q) error = %v", tt.in, err) } if got != tt.want { - t.Errorf("ParseEndpoint(%q) = %+v, want %+v", tt.in, 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 4f24f0b..1f9a2a6 100644 --- a/internal/config/global.go +++ b/internal/config/global.go @@ -44,7 +44,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, @@ -64,7 +64,7 @@ func (g *Global) SetYolo(on bool) error { // 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] } @@ -73,10 +73,10 @@ func (g *Global) ActiveEndpoint() Endpoint { // (adding it if missing), updates ApertureHost to the endpoint URL, and // persists. replacing is the original endpoint of a verified URL edit, removed // in the same write. Bridge activation later rewrites ApertureHost to localhost. -func (g *Global) SetActiveEndpoint(ep Endpoint, replacing *Endpoint) error { +func (g *Global) SetActiveEndpoint(ep Endpoint, replacing Endpoint) error { eps := []Endpoint{ep} for _, existing := range g.Settings.Endpoints { - if !SameEndpoint(existing, ep) && (replacing == nil || !SameEndpoint(existing, *replacing)) { + if existing != ep && existing != replacing { eps = append(eps, existing) } } @@ -86,21 +86,21 @@ func (g *Global) SetActiveEndpoint(ep Endpoint, replacing *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. func (g *Global) SetApertureHost(url string) error { - return g.SetActiveEndpoint(Endpoint{URL: url}, nil) + 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. func (g *Global) UpsertEndpoint(ep Endpoint) error { for _, existing := range g.Settings.Endpoints { - if SameEndpoint(existing, ep) { + if existing == ep { return nil } } @@ -119,21 +119,21 @@ 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 } @@ -149,7 +149,7 @@ func (g *Global) ReplaceEndpoint(old, next Endpoint) error { } g.Settings = updated if oldIdx == 0 { - g.ApertureHost = next.URL + g.ApertureHost = next.URL() } return nil } @@ -171,7 +171,7 @@ 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 } @@ -181,7 +181,7 @@ func (g *Global) RemoveEndpoint(idx int) error { // is not an error. func (g *Global) DropEndpoint(ep Endpoint) error { for i, existing := range g.Settings.Endpoints { - if i == 0 || !SameEndpoint(existing, ep) { + if i == 0 || existing != ep { continue } return g.RemoveEndpoint(i) @@ -232,8 +232,8 @@ func (g *Global) SetBridgeTailnet(id, tailnet string) error { // RemoveBridge deletes a bridge if no endpoint still references it. 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 { @@ -265,8 +265,10 @@ func (g *Global) Bridge(id string) (Bridge, bool) { // RecordLaunch stores the launch record to disk and updates the in-memory copy. 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) } diff --git a/internal/config/settings.go b/internal/config/settings.go index 93acb33..e4ffa64 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -23,7 +23,7 @@ type Settings struct { // 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 endpointList `json:"endpoints,omitempty"` // YoloMode appends each client's skip-permissions args (e.g. // --dangerously-skip-permissions for Claude Code, --yolo for Gemini) @@ -60,7 +60,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 } @@ -83,7 +83,7 @@ func SaveSettings(s Settings) error { func defaultSettings() Settings { return Settings{ - Endpoints: []Endpoint{{URL: DefaultLocation}}, + Endpoints: []Endpoint{Direct(DefaultLocation)}, } } diff --git a/internal/config/startup.go b/internal/config/startup.go index 6a518ec..9713137 100644 --- a/internal/config/startup.go +++ b/internal/config/startup.go @@ -31,23 +31,22 @@ func (s Startup) Resolve(g *Global) (Endpoint, error) { // The URL is checked before the bridge is looked up, 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. - ep := Endpoint{URL: DefaultLocation} + location := DefaultLocation if url != "" { - parsed, err := ParseEndpoint(url, "") + parsed, err := ParseEndpointURL(url) if err != nil { - return Endpoint{}, err + return nil, err } - ep = parsed + location = parsed } if name == "" { - return ep, nil + return Direct(location), nil } bridge, err := s.bridge(g, name) if err != nil { - return Endpoint{}, err + return nil, err } - ep.BridgeID = bridge.ID - return ep, nil + return Bridged(location, bridge.ID), nil } // bridge creates the named bridge if there is none, which is what makes a first diff --git a/internal/config/startup_test.go b/internal/config/startup_test.go index b6711e5..b4d7e8d 100644 --- a/internal/config/startup_test.go +++ b/internal/config/startup_test.go @@ -26,25 +26,25 @@ func loadInto(t *testing.T, s config.Settings) *config.Global { } func TestResolveFallsBackToTheSavedOne(t *testing.T) { - g := loadInto(t, config.Settings{Endpoints: []config.Endpoint{{URL: "http://saved"}}}) + g := loadInto(t, config.Settings{Endpoints: []config.Endpoint{config.Direct("http://saved")}}) ep, err := config.Startup{}.Resolve(g) if err != nil { t.Fatalf("Resolve: %v", err) } - if ep != (config.Endpoint{URL: "http://saved"}) { + 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{{URL: "http://saved"}}}) + g := loadInto(t, config.Settings{Endpoints: []config.Endpoint{config.Direct("http://saved")}}) ep, err := config.Startup{URL: "aperture.example.com"}.Resolve(g) if err != nil { t.Fatalf("Resolve: %v", err) } - if ep != (config.Endpoint{URL: "http://aperture.example.com"}) { + if ep != (config.Direct("http://aperture.example.com")) { t.Errorf("endpoint = %+v, want the named one, schemed", ep) } } @@ -59,7 +59,7 @@ func TestResolveGuessesTheLocationForANamedBridge(t *testing.T) { if err != nil { t.Fatalf("Resolve: %v", err) } - if ep != (config.Endpoint{URL: config.DefaultLocation, BridgeID: "bridge-abc123"}) { + 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 { @@ -79,7 +79,7 @@ func TestResolveCreatesAnUnknownBridge(t *testing.T) { 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.BridgeID != g.Settings.Bridges[0].ID || ep.URL != "http://aperture.example.com" { + 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) } diff --git a/internal/config/state_test.go b/internal/config/state_test.go index 588b8df..e38331d 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"}, nil); 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,8 +235,8 @@ 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 { @@ -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) diff --git a/internal/tui/connection_test.go b/internal/tui/connection_test.go index 79d5d16..6d103cf 100644 --- a/internal/tui/connection_test.go +++ b/internal/tui/connection_test.go @@ -66,7 +66,7 @@ func TestEndpointEditCommitsOnlyAfterSuccess(t *testing.T) { 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) { + 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) } } @@ -80,8 +80,7 @@ func TestTailnetSwitchInvalidatesSharedConnection(t *testing.T) { withFakeTailscale(t, tsConnected) target := m.g.Settings.Endpoints[1] if shared { - m.g.Settings.Endpoints[0].BridgeID = target.BridgeID - m.g.Settings.Endpoints[0].URL = "http://first" + 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()) @@ -186,7 +185,7 @@ func TestEndpointEditSameCandidateCancellation(t *testing.T) { candidate := m.act.endpoint() m.Update(endpointActivationResult{id: m.act.id, err: fmt.Errorf("temporary failure")}) m.promptEditEndpoint(candidate) - m.inputOnSave(candidate.URL) + 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") diff --git a/internal/tui/menus.go b/internal/tui/menus.go index 45d8c73..bdba330 100644 --- a/internal/tui/menus.go +++ b/internal/tui/menus.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "os" + "slices" "strings" tea "github.com/charmbracelet/bubbletea" @@ -113,7 +114,7 @@ func (m *model) quickSelect() (tea.Cmd, string) { if !hasSavedEndpoint || !m.endpointConfigured(saved) { return nil, "" } - if !config.SameEndpoint(saved, m.g.ActiveEndpoint()) { + if saved != m.g.ActiveEndpoint() { return nil, "" } if cmd := c.Replay(m.g); cmd != nil { @@ -127,22 +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 + last := m.g.LastLaunch + switch { + case last.LastEndpointURL == "": + return nil, false + case last.LastBridgeID != "": + return config.Bridged(last.LastEndpointURL, last.LastBridgeID), true } - return config.Endpoint{ - URL: m.g.LastLaunch.LastEndpointURL, - BridgeID: m.g.LastLaunch.LastBridgeID, - }, true + return config.Direct(last.LastEndpointURL), true } func (m *model) endpointConfigured(want config.Endpoint) bool { - for _, ep := range m.g.Settings.Endpoints { - if config.SameEndpoint(ep, want) { - return true - } - } - return false + return slices.Contains(m.g.Settings.Endpoints, want) } func simpleErrorCmd(err error) tea.Cmd { @@ -221,7 +218,7 @@ func (m *model) bridgesMenu() *menu.Menu { if idx < 0 || idx >= len(m.g.Settings.Bridges) { return menu.Result{} } - return m.remove(bridges.Removal{Bridge: m.g.Settings.Bridges[idx]}) + return m.remove(m.g.Settings.Bridges[idx], nil) }, }) return &menu.Menu{ @@ -234,7 +231,7 @@ func (m *model) bridgesMenu() *menu.Menu { // 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.bridging().Tailnet(bridge); name != "" { + if name := m.machines.Tailnet(bridge); name != "" { return "tailnet " + name } return bridge.ID @@ -329,9 +326,9 @@ func (m *model) connectionRows() []connectionRow { 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 ep.BridgeID != "" { - used[ep.BridgeID] = true - row.bridge, _ = m.g.Bridge(ep.BridgeID) + if bridged, ok := ep.(config.BridgeEndpoint); ok { + used[bridged.BridgeID()] = true + row.bridge, _ = m.g.Bridge(bridged.BridgeID()) } rows = append(rows, row) } @@ -342,7 +339,7 @@ func (m *model) connectionRows() []connectionRow { continue } rows = append(rows, connectionRow{ - ep: config.Endpoint{URL: config.DefaultLocation, BridgeID: b.ID}, + ep: config.Bridged(config.DefaultLocation, b.ID), bridge: b, }) } @@ -376,10 +373,10 @@ func (m *model) connectionLabel(row connectionRow) string { // 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.ep.BridgeID == "" { + if row.bridge.ID == "" { return "" } - if name := m.bridging().Tailnet(row.bridge); name != "" { + if name := m.machines.Tailnet(row.bridge); name != "" { return "tailnet " + name } return "tailnet not known yet" @@ -394,7 +391,7 @@ func (m *model) connectionMenu(row connectionRow) *menu.Menu { title = m.endpointLabel(row.ep) } - connect, target := "Connect", row.ep.URL + connect, target := "Connect", row.ep.URL() if row.active && m.connected { connect = "Reconnect" } @@ -402,7 +399,7 @@ func (m *model) connectionMenu(row connectionRow) *menu.Menu { // 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 + target = "looks for Aperture at " + row.ep.URL() } items := []menu.MenuItem{{ Label: connect, @@ -415,7 +412,7 @@ func (m *model) connectionMenu(row connectionRow) *menu.Menu { if row.saved { items = append(items, menu.MenuItem{ Label: "Change URL", - Description: "now " + row.ep.URL, + Description: "now " + row.ep.URL(), Action: func() menu.Result { m.promptEditEndpoint(row.ep) return menu.Result{} @@ -423,9 +420,9 @@ func (m *model) connectionMenu(row connectionRow) *menu.Menu { }) } - if row.ep.BridgeID != "" { + if row.bridge.ID != "" { description := "log the bridge out and sign in to a different tailnet" - if name := m.bridging().Tailnet(row.bridge); name != "" { + if name := m.machines.Tailnet(row.bridge); name != "" { description = "leave " + name + " and sign in to a different tailnet" } items = append(items, menu.MenuItem{ @@ -467,11 +464,11 @@ func (m *model) connectionMenu(row connectionRow) *menu.Menu { // 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.bridging().Tailnet(row.bridge); name != "" { + 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." + row.ep.URL() + " is looked for there." return &menu.Menu{ Title: "Switch tailnet for " + row.bridge.Name + "?", Preamble: preamble, @@ -499,35 +496,35 @@ func (m *model) switchTailnetMenu(row connectionRow) *menu.Menu { 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 { + 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 && !config.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()) + "." } @@ -562,10 +559,10 @@ func (m *model) setupGuideMenu() *menu.Menu { }, }) } - if m.endpointConfigured(target) && !config.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 { return m.remove(m.removalFor(target)) }, + Action: func() menu.Result { return m.remove(m.bridgeOf(target), target) }, }) } @@ -591,8 +588,8 @@ func (m *model) setupGuideMenu() *menu.Menu { // 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 { - next, err := config.ParseEndpoint(v, ep.BridgeID) + m.promptForInput("Edit Endpoint:", "URL", ep.URL(), func(v string) tea.Cmd { + url, err := config.ParseEndpointURL(v) if err != nil { return simpleErrorCmd(err) } @@ -600,7 +597,7 @@ func (m *model) promptEditEndpoint(ep config.Endpoint) { if m.act != nil { current = m.act.attempt } - a, err := m.bridging().Edit(current, ep, next) + a, err := bridges.EditAttempt(m.g, current, ep, ep.WithURL(url)) if err != nil { return simpleErrorCmd(err) } @@ -622,10 +619,11 @@ 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 := config.ParseEndpoint(v, "") + 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) } @@ -685,7 +683,7 @@ func (m *model) endpointBridgeMenu() *menu.Menu { // 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.Endpoint{URL: config.DefaultLocation, BridgeID: bridge.ID}, false) + return m.connectVia(config.Bridged(config.DefaultLocation, bridge.ID), false) } // connectVia connects to ep. switchTailnet logs the bridge out on the way, so @@ -695,13 +693,14 @@ func (m *model) connectVia(ep config.Endpoint, switchTailnet bool) tea.Cmd { } 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. diff --git a/internal/tui/removal.go b/internal/tui/removal.go index 40012f4..7cb5c1f 100644 --- a/internal/tui/removal.go +++ b/internal/tui/removal.go @@ -13,74 +13,77 @@ import ( ) // bridgeRemovedMsg carries the outcome of the tailnet round trip back to the -// update loop, where the records can be dropped. +// update loop, where the records can be dropped. endpoint is nil for a bare +// bridge. type bridgeRemovedMsg struct { - id int - removal bridges.Removal - err error + id int + bridge config.Bridge + endpoint config.Endpoint + err error } // destroyBridge is the tailnet round trip a removal makes. A seam for the // tests: pickerModel has no Machines, and a real Destroy would want a tailnet. -var destroyBridge = func(ctx context.Context, b bridges.Bridging, rem bridges.Removal, emit func(connection.Event)) error { - return b.Destroy(ctx, rem, emit) +var destroyBridge = func(ctx context.Context, machines *bridges.Machines, bridge config.Bridge, emit func(connection.Event)) error { + return machines.Destroy(ctx, bridge, emit) } // removeRow deletes what a picker row stands for. Shared by the row's page and // the "d" key, which have to agree on what removing a row means. func (m *model) removeRow(row connectionRow) menu.Result { - rem := bridges.Removal{Bridge: row.bridge} + var ep config.Endpoint if row.saved { - ep := row.ep - rem.Endpoint = &ep + ep = row.ep } - return m.remove(rem) + return m.remove(row.bridge, ep) } -// removalFor is the removal a saved endpoint implies, bridge included. The -// setup guide holds an endpoint rather than a picker row. -func (m *model) removalFor(ep config.Endpoint) bridges.Removal { - rem := bridges.Removal{Endpoint: &ep} - rem.Bridge, _ = m.g.Bridge(ep.BridgeID) - return rem +// bridgeOf is the Bridge a saved endpoint is reached through, zero for a +// direct one. 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 confirms before a removal that takes a device off a tailnet, and // otherwise drops the records at once. Every delete in the TUI comes through // here: the machine 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(rem bridges.Removal) menu.Result { - destroys, err := m.bridging().Destroys(rem) +func (m *model) remove(bridge config.Bridge, ep config.Endpoint) menu.Result { + destroys, err := bridges.DestroysMachine(m.g, bridge, ep) if err != nil { return errResult(err.Error()) } if !destroys { - if err := m.bridging().Forget(rem, nil); err != nil { + if err := bridges.ForgetBridge(m.g, bridge, ep, nil); err != nil { return errResult(err.Error()) } - return menu.Result{Cmd: m.afterRemoval(rem)} + return menu.Result{Cmd: m.afterRemoval(ep)} } - return menu.Result{Next: m.removeBridgeMenu(rem)} + return menu.Result{Next: m.removeBridgeMenu(bridge, ep)} } // removeBridgeMenu is the confirmation. Removal is irreversible from here and // takes a device off the user's tailnet, so the screen names the device by the // name the admin console shows it under. -func (m *model) removeBridgeMenu(rem bridges.Removal) *menu.Menu { - preamble := "Bridge " + rem.Bridge.Name + " is the device " + bridges.MachineName(rem.Bridge.ID) - if name := m.bridging().Tailnet(rem.Bridge); name != "" { +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 " + rem.Bridge.Name + "?", + Title: "Remove bridge " + bridge.Name + "?", Preamble: preamble, Items: []menu.MenuItem{ { Label: "Remove", Shortcut: "y", - Action: func() menu.Result { return menu.Result{Cmd: m.destroyBridgeCmd(rem)} }, + Action: func() menu.Result { return menu.Result{Cmd: m.destroyBridgeCmd(bridge, ep)} }, }, { Label: "Cancel", @@ -96,7 +99,7 @@ func (m *model) removeBridgeMenu(rem bridges.Removal) *menu.Menu { // program already shows slow bridge work and its log tail. The attempt 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 disagree. -func (m *model) destroyBridgeCmd(rem bridges.Removal) tea.Cmd { +func (m *model) destroyBridgeCmd(bridge config.Bridge, ep config.Endpoint) tea.Cmd { m.stopActivation() m.step = stepPreflight m.preflightErr = "" @@ -107,18 +110,18 @@ func (m *model) destroyBridgeCmd(rem bridges.Removal) tea.Cmd { m.activationSeq++ act := &activation{ id: m.activationSeq, - label: "Removing bridge " + rem.Bridge.Name + " ...", + label: "Removing bridge " + bridge.Name + " ...", started: time.Now(), logCh: ch, logCtx: ctx, } m.act = act emit := bridgeLogSink(ctx, ch, act.started) - bridging := m.bridging() + machines := m.machines destroy := func() tea.Msg { defer cancel() - err := destroyBridge(ctx, bridging, rem, emit) - return bridgeRemovedMsg{id: act.id, removal: rem, err: err} + 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)) } @@ -131,17 +134,17 @@ func (m *model) bridgeRemoved(msg bridgeRemovedMsg) (tea.Model, tea.Cmd) { } m.act = nil m.step = stepMenu - err := m.bridging().Forget(msg.removal, msg.err) + err := bridges.ForgetBridge(m.g, msg.bridge, msg.endpoint, msg.err) var unconfirmed *bridges.Unconfirmed switch { case errors.As(err, &unconfirmed): - cmd := m.afterRemoval(msg.removal) + cmd := m.afterRemoval(msg.endpoint) m.step = stepError m.errMsg = m.unconfirmedMessage(unconfirmed) return m, cmd case err != nil && errors.Is(err, msg.err): m.step = stepError - m.errMsg = "Could not remove bridge " + msg.removal.Bridge.Name + ": " + err.Error() + + m.errMsg = "Could not remove bridge " + msg.bridge.Name + ": " + err.Error() + "\n\nThe connection is unchanged. Removing it again retries the logout." return m, nil case err != nil: @@ -149,7 +152,7 @@ func (m *model) bridgeRemoved(msg bridgeRemovedMsg) (tea.Model, tea.Cmd) { m.errMsg = err.Error() return m, nil } - return m, m.afterRemoval(msg.removal) + return m, m.afterRemoval(msg.endpoint) } // unconfirmedMessage is what the user needs to finish the job by hand: the @@ -158,7 +161,7 @@ func (m *model) bridgeRemoved(msg bridgeRemovedMsg) (tea.Model, tea.Cmd) { func (m *model) unconfirmedMessage(u *bridges.Unconfirmed) string { msg := "Bridge " + u.Bridge.Name + " was removed here, but the tailnet did not confirm within " + u.Wait.String() + ".\n\nThe device " + bridges.MachineName(u.Bridge.ID) - if name := m.bridging().Tailnet(u.Bridge); name != "" { + if name := m.machines.Tailnet(u.Bridge); name != "" { msg += " may still be on " + name } else { msg += " may still be registered" @@ -169,8 +172,8 @@ func (m *model) unconfirmedMessage(u *bridges.Unconfirmed) string { // afterRemoval puts the user back on a list that no longer shows what they // removed. A removal of the endpoint the failure screen is about leaves that // screen with nothing to retry, so the root menu takes its place. -func (m *model) afterRemoval(rem bridges.Removal) tea.Cmd { - if rem.Endpoint != nil && m.failedEndpoint != nil && config.SameEndpoint(*m.failedEndpoint, *rem.Endpoint) { +func (m *model) afterRemoval(ep config.Endpoint) tea.Cmd { + if ep != nil && m.failedEndpoint == ep { m.clearEndpointFailure() m.resetStack(m.rootMenu()) return tea.ClearScreen diff --git a/internal/tui/removal_test.go b/internal/tui/removal_test.go index a79b27f..bc4b874 100644 --- a/internal/tui/removal_test.go +++ b/internal/tui/removal_test.go @@ -19,8 +19,8 @@ import ( func withFakeDestroy(t *testing.T, fn func(context.Context, config.Bridge) error) { t.Helper() orig := destroyBridge - destroyBridge = func(ctx context.Context, _ bridges.Bridging, rem bridges.Removal, _ func(connection.Event)) error { - return fn(ctx, rem.Bridge) + destroyBridge = func(ctx context.Context, _ *bridges.Machines, b config.Bridge, _ func(connection.Event)) error { + return fn(ctx, b) } t.Cleanup(func() { destroyBridge = orig }) } @@ -61,7 +61,7 @@ func removeRowResult(t *testing.T, m *model, row connectionRow) tea.Msg { func bridgedRow(t *testing.T, m *model) connectionRow { t.Helper() for _, row := range m.connectionRows() { - if row.saved && !row.active && row.ep.BridgeID == "bridge-aaaaaa" { + if row.saved && !row.active && row.bridge.ID == "bridge-aaaaaa" { return row } } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 92fe99b..7c355b3 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -97,7 +97,7 @@ type model struct { preflightErr string forcedToEndpoint bool // true when preflight failure dropped user on endpoints menu bridgeLogs []bridgeLine - failedEndpoint *config.Endpoint + failedEndpoint config.Endpoint connected bool } @@ -156,7 +156,7 @@ func (a *activation) entered(p connection.Phase) bool { // screen is showing something else. func (a *activation) endpoint() config.Endpoint { if a == nil || a.attempt == nil { - return config.Endpoint{} + return nil } return a.attempt.Endpoint } @@ -166,12 +166,9 @@ func (a *activation) cancelable() bool { return a != nil && a.cancel != nil } // overridable 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) overridable() bool { return a.cancelable() && a.endpoint().BridgeID != "" } - -// bridging is the Connection context's service over this program's settings -// and Machines. Stateless, so built where it is used. -func (m *model) bridging() bridges.Bridging { - return bridges.Bridging{Machines: m.machines, Settings: m.g} +func (a *activation) overridable() bool { + _, bridged := a.endpoint().(config.BridgeEndpoint) + return a.cancelable() && bridged } // textField is the shared single-line editor behind the add-endpoint input @@ -216,7 +213,11 @@ func (f *textField) reset() { *f = textField{} } // may not be in settings yet, and it writes it there for the failure screen to // name; for the saved endpoint the two calls are the same. func (m *model) Init() tea.Cmd { - return m.connectVia(m.start, false) + start := m.start + if start == nil { + start = m.g.ActiveEndpoint() + } + return m.connectVia(start, false) } // endpointActivationResult is how an attempt's outcome reaches the update @@ -287,7 +288,7 @@ type quitMsg struct{ Err error } // 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 && config.SameEndpoint(m.act.endpoint(), ep) { + 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) @@ -296,8 +297,8 @@ func (m *model) activateEndpointCmd(ep config.Endpoint) tea.Cmd { // 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 := m.bridging().Begin(ep, switchTailnet, replacing) +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) } @@ -321,17 +322,17 @@ func (m *model) startAttempt(a *bridges.Attempt) tea.Cmd { act := &activation{ id: m.activationSeq, attempt: a, - label: "Checking " + a.Endpoint.URL + " ...", + label: "Checking " + a.Endpoint.URL() + " ...", started: time.Now(), cancel: cancel, } m.act = act - bridging := m.bridging() + machines := m.machines - if a.Endpoint.BridgeID == "" { + if _, bridged := a.Endpoint.(config.BridgeEndpoint); !bridged { run := func() tea.Msg { defer cancel() - v, err := bridging.Run(ctx, a, nil) + v, err := a.Run(ctx, machines, nil) return endpointActivationResult{id: act.id, verified: v, err: err} } return tea.Batch(run, activationTick(act.id)) @@ -340,14 +341,14 @@ func (m *model) startAttempt(a *bridges.Attempt) tea.Cmd { ch := make(chan bridgeLine, 32) act.logCh = ch act.logCtx = ctx - act.label = "Connecting bridge " + a.Bridge().Name + " to " + a.Endpoint.URL + " ..." + 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() - v, err := bridging.Run(ctx, a, emit) + v, err := a.Run(ctx, machines, emit) return endpointActivationResult{id: act.id, verified: v, err: err} } return tea.Batch(run, waitBridgeLog(ctx, ch), activationTick(act.id)) @@ -377,7 +378,7 @@ func (m *model) discardActivation() error { return nil } m.stopActivation() - return m.bridging().Abandon(act.attempt) + return act.attempt.Abandon(m.g) } // cancelActivation abandons the attempt on screen and returns to the menu the @@ -396,10 +397,11 @@ func (m *model) cancelActivation() (tea.Model, tea.Cmd) { } m.act = nil m.step = stepMenu - if len(m.stack) == 0 || !m.connected && m.g.ActiveEndpoint().BridgeID != "" { + _, 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.failedEndpoint = endpoint m.resetStack(m.setupGuideMenu()) } return m, tea.ClearScreen @@ -412,14 +414,15 @@ func (m *model) overrideActivationURL(value string) (tea.Model, tea.Cmd) { if act == nil { return m, nil } - next, err := config.ParseEndpoint(value, act.endpoint().BridgeID) + 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 } - if config.SameEndpoint(next, act.endpoint()) { + next := act.endpoint().WithURL(url) + if next == act.endpoint() { act.override.reset() return m, nil } @@ -434,7 +437,7 @@ func (m *model) retargetActivation(next config.Endpoint) tea.Cmd { return m.connect(next, false, nil) } m.stopActivation() - a, err := m.bridging().Retarget(act.attempt, next) + a, err := act.attempt.Retarget(m.g, next) if err != nil { m.errMsg = err.Error() m.step = stepError @@ -527,23 +530,22 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.act.cancel = nil - bridging := m.bridging() - failed := m.act.endpoint() + a := m.act.attempt if msg.err != nil { - if bridging.Fail(m.act.attempt) { + if a.TargetsActive { m.connected = false } m.preflightErr = msg.err.Error() m.forcedToEndpoint = true - m.failedEndpoint = &failed + m.failedEndpoint = a.Endpoint m.step = stepMenu m.resetStack(m.setupGuideMenu()) return m, nil } - if err := bridging.Commit(m.act.attempt, msg.verified); err != nil { + if err := a.Commit(m.g, msg.verified); err != nil { m.preflightErr = err.Error() m.forcedToEndpoint = true - m.failedEndpoint = &failed + m.failedEndpoint = a.Endpoint m.step = stepMenu m.resetStack(m.setupGuideMenu()) return m, nil @@ -1278,7 +1280,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 != "" { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 59e0ad4..5b0547c 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -90,7 +90,7 @@ 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", @@ -130,7 +130,7 @@ 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", @@ -155,7 +155,7 @@ 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 @@ -179,16 +179,16 @@ 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(), }, }, } @@ -419,7 +419,7 @@ func TestPreflightFailure_ShowsSetupGuide(t *testing.T) { step: stepPreflight, } m.activationSeq = 1 - m.act = &activation{id: 1, attempt: &bridges.Attempt{Endpoint: config.Endpoint{URL: "http://ai"}}} + 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") @@ -436,7 +436,7 @@ func TestPreflightFailure_ShowsSetupGuide(t *testing.T) { func TestEndpointActivationFailure_ShowsSetupGuide(t *testing.T) { withFakeTailscale(t, tsConnected) withFakeClients(t, nil) - ep := config.Endpoint{URL: "http://ai"} + ep := config.Direct("http://ai") m := &model{ g: &config.Global{ApertureHost: "http://ai"}, } @@ -514,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")}, }, }} @@ -533,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://other"}}, + Endpoints: []config.Endpoint{config.Direct("http://other")}, }}, step: stepMenu, } @@ -570,8 +570,8 @@ func TestEndpointBridgeMenu_AddsFirstBridgeInline(t *testing.T) { if m.step != stepPreflight { t.Fatalf("step = %v, want stepPreflight", m.step) } - want := config.Endpoint{URL: config.DefaultLocation, BridgeID: bridgeID} - if m.act == nil || !config.SameEndpoint(m.act.endpoint(), want) { + 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() { @@ -580,7 +580,7 @@ func TestEndpointBridgeMenu_AddsFirstBridgeInline(t *testing.T) { 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" { + if got := m.g.ActiveEndpoint().URL(); got != "http://other" { t.Errorf("active endpoint = %q, want the previous one until discovery verifies", got) } } @@ -593,7 +593,7 @@ func TestEndpointBridgeMenu_ConnectsExistingBridgeWithoutPrompting(t *testing.T) m := &model{ g: &config.Global{Settings: config.Settings{ Bridges: []config.Bridge{bridge}, - Endpoints: []config.Endpoint{{URL: "http://other"}}, + Endpoints: []config.Endpoint{config.Direct("http://other")}, }}, step: stepMenu, } @@ -609,7 +609,7 @@ func TestEndpointBridgeMenu_ConnectsExistingBridgeWithoutPrompting(t *testing.T) if m.step != stepPreflight { t.Fatalf("step = %v, want stepPreflight", m.step) } - if m.act == nil || m.act.endpoint().URL != config.DefaultLocation || m.act.endpoint().BridgeID != bridge.ID { + 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.overridable() { @@ -625,8 +625,8 @@ func TestInitOpensOnTheStartEndpoint(t *testing.T) { t.Setenv("HOME", tmp) t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} - saved := config.Endpoint{URL: "http://saved"} - named := config.Endpoint{URL: "http://named", BridgeID: bridge.ID} + 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}, @@ -635,13 +635,13 @@ func TestInitOpensOnTheStartEndpoint(t *testing.T) { if cmd := m.Init(); cmd == nil { t.Fatal("Init did not start a connection") } - if m.act == nil || !config.SameEndpoint(m.act.endpoint(), named) { + 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(); !config.SameEndpoint(got, saved) { + if got := m.g.ActiveEndpoint(); got != saved { t.Errorf("active endpoint = %+v, want %+v until the attempt succeeds", got, saved) } } @@ -652,14 +652,14 @@ func TestInitOpensOnTheSavedEndpointWhenNothingIsNamed(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") - saved := config.Endpoint{URL: "http://saved"} + 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 || !config.SameEndpoint(m.act.endpoint(), saved) { + if m.act == nil || m.act.endpoint() != saved { t.Fatalf("activation = %+v, want %+v", m.act, saved) } if m.act.attempt.Ephemeral() { @@ -672,7 +672,7 @@ func TestPreflightOverrideReplacesGuessedEndpoint(t *testing.T) { t.Setenv("HOME", tmp) t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} - previous := config.Endpoint{URL: "http://other"} + previous := config.Direct("http://other") m := &model{ g: &config.Global{Settings: config.Settings{ Bridges: []config.Bridge{bridge}, @@ -695,15 +695,15 @@ func TestPreflightOverrideReplacesGuessedEndpoint(t *testing.T) { if guessed.cancel != nil { t.Error("guessed attempt was not cancelled") } - want := config.Endpoint{URL: "http://aperture.example.ts.net", BridgeID: bridge.ID} - if m.act == nil || !config.SameEndpoint(m.act.endpoint(), want) { + 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 || !config.SameEndpoint(got[0], previous) || !config.SameEndpoint(got[1], want) { + 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) } } @@ -716,7 +716,7 @@ func TestPreflightOverrideRejectsBadURLWithoutStoppingTheAttempt(t *testing.T) { m := &model{ g: &config.Global{Settings: config.Settings{ Bridges: []config.Bridge{bridge}, - Endpoints: []config.Endpoint{{URL: "http://other"}}, + Endpoints: []config.Endpoint{config.Direct("http://other")}, }}, step: stepMenu, } @@ -766,7 +766,7 @@ func TestPreflightEscapeAbandonsDiscovery(t *testing.T) { t.Setenv("HOME", tmp) t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} - previous := config.Endpoint{URL: "http://other"} + previous := config.Direct("http://other") m := &model{ g: &config.Global{Settings: config.Settings{ Bridges: []config.Bridge{bridge}, @@ -786,7 +786,7 @@ func TestPreflightEscapeAbandonsDiscovery(t *testing.T) { 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 || !config.SameEndpoint(got[0], previous) { + 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. @@ -800,7 +800,7 @@ func TestPreflightEscapeAtStartupShowsSetupGuide(t *testing.T) { withFakeTailscale(t, tsConnected) m := &model{g: &config.Global{ ApertureHost: "http://ai", - Settings: config.Settings{Endpoints: []config.Endpoint{{URL: "http://ai"}}}, + Settings: config.Settings{Endpoints: []config.Endpoint{config.Direct("http://ai")}}, }} m.Init() @@ -813,10 +813,10 @@ func TestPreflightEscapeAtStartupShowsSetupGuide(t *testing.T) { func TestSetupGuideEditPrefillsFailedURL(t *testing.T) { withFakeTailscale(t, tsConnected) - target := config.Endpoint{URL: "http://aperture.example.ts.net"} + target := config.Direct("http://aperture.example.ts.net") m := &model{ - g: &config.Global{ApertureHost: target.URL}, - failedEndpoint: &target, + g: &config.Global{ApertureHost: target.URL()}, + failedEndpoint: target, } guide := m.setupGuideMenu() for _, it := range guide.Items { @@ -824,8 +824,8 @@ func TestSetupGuideEditPrefillsFailedURL(t *testing.T) { continue } it.Action() - if m.input.value != target.URL { - t.Fatalf("edit field = %q, want the failed URL %q", m.input.value, target.URL) + if m.input.value != target.URL() { + t.Fatalf("edit field = %q, want the failed URL %q", m.input.value, target.URL()) } return } @@ -840,10 +840,10 @@ func TestEndpointsMenu_EditRetargetsWorkingEndpoint(t *testing.T) { t.Setenv("HOME", tmp) t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} - connected := config.Endpoint{URL: config.DefaultLocation, BridgeID: bridge.ID} + connected := config.Bridged(config.DefaultLocation, bridge.ID) m := &model{ g: &config.Global{ - ApertureHost: connected.URL, + ApertureHost: connected.URL(), Settings: config.Settings{ Bridges: []config.Bridge{bridge}, Endpoints: []config.Endpoint{connected}, @@ -866,17 +866,17 @@ func TestEndpointsMenu_EditRetargetsWorkingEndpoint(t *testing.T) { } 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) + 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.Endpoint{URL: "http://aperture.example.ts.net", BridgeID: bridge.ID} + 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 || !config.SameEndpoint(m.act.endpoint(), want) { + 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, verified: bridges.Verified{Gateway: "http://127.0.0.1:12345"}}) @@ -903,8 +903,8 @@ func pickerModel(t *testing.T) *model { {ID: "bridge-bbbbbb", Name: "Home"}, }, Endpoints: []config.Endpoint{ - {URL: config.DefaultLocation}, - {URL: config.DefaultLocation, BridgeID: "bridge-aaaaaa"}, + config.Direct(config.DefaultLocation), + config.Bridged(config.DefaultLocation, "bridge-aaaaaa"), }, }, }, @@ -974,8 +974,8 @@ func TestConnectionPicker_ConnectsViaUnusedBridge(t *testing.T) { connect, _ := findItem(t, m.top().Items, "Connect") m.activate(connect) - want := config.Endpoint{URL: config.DefaultLocation, BridgeID: "bridge-bbbbbb"} - if m.act == nil || !config.SameEndpoint(m.act.endpoint(), want) { + 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) { @@ -998,7 +998,7 @@ func TestConnectionPicker_SwitchTailnetConfirmsThenReconnects(t *testing.T) { yes, _ := findItem(t, m.top().Items, "Switch tailnet") m.activate(yes) - if m.act == nil || m.act.endpoint().BridgeID != "bridge-aaaaaa" { + 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. @@ -1069,7 +1069,7 @@ func TestConnectionPicker_DeleteKeyRemovesRowUnderCursor(t *testing.T) { 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].BridgeID != "" { + 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) } } @@ -1100,15 +1100,15 @@ func TestBridgesMenu_ConnectsThroughBridge(t *testing.T) { idx, _ := findItem(t, m.top().Items, "Home") m.activate(idx) - want := config.Endpoint{URL: config.DefaultLocation, BridgeID: "bridge-bbbbbb"} - if m.act == nil || !config.SameEndpoint(m.act.endpoint(), want) { + 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.Endpoint{URL: config.DefaultLocation, BridgeID: bridge.ID} + target := config.Bridged(config.DefaultLocation, bridge.ID) m := &model{ g: &config.Global{ ApertureHost: config.DefaultLocation, @@ -1117,7 +1117,7 @@ func TestSetupGuideExplainsDefaultLocationGuess(t *testing.T) { Endpoints: []config.Endpoint{target}, }, }, - failedEndpoint: &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) @@ -1144,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{ @@ -1163,8 +1163,8 @@ func TestBridgeEndpointFailureKeepsPreviousEndpointActive(t *testing.T) { if res.Cmd == nil { t.Fatal("selecting the bridge did not begin activation") } - want := config.Endpoint{URL: config.DefaultLocation, BridgeID: bridge.ID} - if got := m.g.ActiveEndpoint(); !config.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) { @@ -1176,11 +1176,11 @@ func TestBridgeEndpointFailureKeepsPreviousEndpointActive(t *testing.T) { if !ok { t.Fatalf("activation message = %T", msg) } - if !config.SameEndpoint(m.act.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(); !config.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" { @@ -1205,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", @@ -1217,11 +1217,11 @@ func TestDirectEndpointIsPromotedOnlyAfterModelsSucceed(t *testing.T) { m.addEndpointConnectionMenu().Items[0].Action() cmd := m.inputOnSave(srv.URL) - if got := m.g.ActiveEndpoint(); !config.SameEndpoint(got, old) { + if got := m.g.ActiveEndpoint(); got != old { t.Fatalf("active endpoint changed before /v1/models: %+v", got) } m.Update(activationResult(t, cmd)) - if got := m.g.ActiveEndpoint(); got.URL != srv.URL { + 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" { @@ -1439,7 +1439,7 @@ func TestAuthFooterCopyKey(t *testing.T) { act: &activation{ id: 3, authURL: testAuthURL, - attempt: &bridges.Attempt{Endpoint: config.Endpoint{BridgeID: "b1"}}, + attempt: &bridges.Attempt{Endpoint: config.Bridged("", "b1")}, cancel: func() {}, }, } @@ -1573,7 +1573,7 @@ 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, @@ -1598,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) } @@ -1610,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, @@ -1674,8 +1674,8 @@ func TestRemoveConnectionRowTakesTheBridgeWithIt(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) m := &model{g: &config.Global{Settings: config.Settings{ Endpoints: []config.Endpoint{ - {URL: "http://active"}, - {URL: "http://ai", BridgeID: "b1"}, + config.Direct("http://active"), + config.Bridged("http://ai", "b1"), }, Bridges: []config.Bridge{{ID: "b1", Name: "work"}}, }}} @@ -1690,8 +1690,8 @@ func TestRemoveConnectionRowTakesTheBridgeWithIt(t *testing.T) { 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 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) @@ -1704,9 +1704,9 @@ func TestRemoveConnectionRowKeepsASharedBridge(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) m := &model{g: &config.Global{Settings: config.Settings{ Endpoints: []config.Endpoint{ - {URL: "http://active"}, - {URL: "http://ai", BridgeID: "b1"}, - {URL: "http://other", BridgeID: "b1"}, + config.Direct("http://active"), + config.Bridged("http://ai", "b1"), + config.Bridged("http://other", "b1"), }, Bridges: []config.Bridge{{ID: "b1", Name: "work"}}, }}} From 6d3e1d4cdb055c5c93ba0c7f4e6f968ca91f57d5 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Mon, 21 Sep 2026 16:24:41 +0000 Subject: [PATCH 49/69] connection: let an Event's fields say what it is, and keep the wording out of it Kind duplicated what the populated field already said, so it is gone: a Phase, a Link or a Note is set and that is the kind. Text meant nothing and is Note. Login read as logging in and is LoginRequired. Phase.String and Event.String were the connect screen's sentences, and a screen is not a concept this package has; they are now the phase's name and the event's representation, and the sentences live in the TUI with the line flattening they exist for. Phase's zero value means no phase, which is what lets a field stand in for a kind. --- internal/bridges/bringup_test.go | 2 +- internal/bridges/events.go | 16 ++-- internal/bridges/helpers_test.go | 11 +++ internal/bridges/machine_test.go | 10 +-- internal/bridges/node.go | 2 +- internal/bridges/security_test.go | 4 +- internal/connection/event.go | 120 +++++++++++++----------------- internal/connection/event_test.go | 34 +++++---- internal/tui/tui.go | 52 ++++++++++--- internal/tui/tui_test.go | 18 ++--- 10 files changed, 151 insertions(+), 118 deletions(-) diff --git a/internal/bridges/bringup_test.go b/internal/bridges/bringup_test.go index d058392..174c8fd 100644 --- a/internal/bridges/bringup_test.go +++ b/internal/bridges/bringup_test.go @@ -59,7 +59,7 @@ func TestBringUpReportsFromTheWatchItWaitsOn(t *testing.T) { reported := strings.Join(got, "\n") for _, line := range []string{ connection.AwaitingLoginLink.String(), - "Authorize this bridge at " + url, + connection.LoginRequired(mustLink(t, url)).String(), connection.JoiningTailnet.String(), } { if !strings.Contains(reported, line) { diff --git a/internal/bridges/events.go b/internal/bridges/events.go index 7c63efb..6518e64 100644 --- a/internal/bridges/events.go +++ b/internal/bridges/events.go @@ -57,13 +57,13 @@ func sink(emit func(connection.Event)) events { // killed halfway through. Notes are 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 e.Kind { - case connection.PhaseEntered: + switch { + case e.Phase != 0: slog.Info("bridge phase", "phase", e.Phase) - case connection.LoginRequired: + case e.Link != nil: slog.Info("bridge needs login") default: - slog.Debug("bridge note", "text", redactDiagnostic(e.Text)) + slog.Debug("bridge note", "text", redactDiagnostic(e.Note)) } } @@ -75,10 +75,10 @@ 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) login(link connection.LoginLink) { e(connection.Login(link)) } +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 is the part of an endpoint URL safe for the run log: scheme and // host. ParseEndpointURL accepts userinfo and a query, and a run log is the diff --git a/internal/bridges/helpers_test.go b/internal/bridges/helpers_test.go index c286c5b..28bae43 100644 --- a/internal/bridges/helpers_test.go +++ b/internal/bridges/helpers_test.go @@ -2,6 +2,7 @@ package bridges import ( "context" + "testing" "github.com/tailscale/aperture-cli/internal/config" "github.com/tailscale/aperture-cli/internal/connection" @@ -48,3 +49,13 @@ func tailnetOf(ms *Machines, bridgeID string) string { } 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/machine_test.go b/internal/bridges/machine_test.go index 2c323db..a8f3584 100644 --- a/internal/bridges/machine_test.go +++ b/internal/bridges/machine_test.go @@ -323,7 +323,7 @@ func TestActivateLogsTheLoginLinkBeforeItIsUsable(t *testing.T) { t.Error(err) return } - ev.login(link) + ev.loginRequired(link) } m := NewMachines(false) @@ -815,8 +815,8 @@ func TestLoginReporterSplitsTheTwoNeedsLoginWaits(t *testing.T) { want := []string{ connection.AwaitingLoginLink.String(), connection.AwaitingAuthorization.String(), - "Authorize this bridge at " + url, - "Authorize this bridge at " + url, + connection.LoginRequired(mustLink(t, url)).String(), + connection.LoginRequired(mustLink(t, url)).String(), connection.JoiningTailnet.String(), connection.FindingEndpoint.String(), } @@ -836,7 +836,7 @@ func TestLoginReporterRejectsAnUnusableLink(t *testing.T) { 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], "Authorize this bridge at") { + if strings.Contains(got[0], "LoginRequired(") { t.Errorf("an http link was offered to the browser: %q", got[0]) } } @@ -930,7 +930,7 @@ func TestSinkLogsEveryEvent(t *testing.T) { var seen []connection.Event ev := sink(func(e connection.Event) { seen = append(seen, e) }) ev.enter(connection.StartingMachine) - ev.login(link) + 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)) diff --git a/internal/bridges/node.go b/internal/bridges/node.go index 68af021..7b4b159 100644 --- a/internal/bridges/node.go +++ b/internal/bridges/node.go @@ -165,7 +165,7 @@ func (r *loginReporter) notify(n *ipn.Notify) { return } r.enter(connection.AwaitingAuthorization) - r.ev.login(link) + r.ev.loginRequired(link) } r.health(n.Health) } diff --git a/internal/bridges/security_test.go b/internal/bridges/security_test.go index a15d443..c85e76d 100644 --- a/internal/bridges/security_test.go +++ b/internal/bridges/security_test.go @@ -138,8 +138,8 @@ func TestRunLogOmitsLoginCapabilities(t *testing.T) { if err != nil { t.Fatal(err) } - var shown connection.LoginLink - sink(func(e connection.Event) { shown = e.Link })(connection.Login(link)) + 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") } diff --git a/internal/connection/event.go b/internal/connection/event.go index dd7881b..2ed6b58 100644 --- a/internal/connection/event.go +++ b/internal/connection/event.go @@ -1,11 +1,12 @@ -// Package connection carries what a connection attempt reports while it runs, -// so the producers of those events do not own their vocabulary. Most come from -// the bridge manager, but the attempt is wider than the bridge: the model fetch -// after bring-up is part of the same wait, and the user cannot tell the halves -// apart. +// Package connection is the vocabulary a Machine reports in while a connection +// attempt uses it: the wait it is in, the login link it needs visited, and the +// odd line for the user that is neither. 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 that produces most +// of it. // // Nothing here may reference tsnet, ipn or ipnstate. Translating the tailnet's -// vocabulary into this one is the bridge manager's job. +// vocabulary into this one is internal/bridges' job. package connection import ( @@ -15,7 +16,8 @@ import ( ) // Phase is what an attempt is waiting on, named for what the user is waiting -// for rather than for the backend state underneath it. +// for rather than for the backend state underneath it. 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 @@ -24,9 +26,9 @@ import ( type Phase int // Phases in the order an attempt passes through them. The order is load -// bearing: a phase only ever moves forward, and Entered compares them. +// bearing: a phase only ever moves forward, and consumers compare them. const ( - StartingMachine Phase = iota + StartingMachine Phase = iota + 1 AwaitingLoginLink AwaitingAuthorization JoiningTailnet @@ -34,22 +36,20 @@ const ( AskingForModels ) -// String is what the connect screen shows, so it names the wait from the -// user's side. The attempt's elapsed clock supplies the "how long". +var phaseNames = [...]string{ + StartingMachine: "StartingMachine", + AwaitingLoginLink: "AwaitingLoginLink", + AwaitingAuthorization: "AwaitingAuthorization", + JoiningTailnet: "JoiningTailnet", + FindingEndpoint: "FindingEndpoint", + AskingForModels: "AskingForModels", +} + +// String is the phase's name, for logs and errors. What the user reads for it +// is the presentation layer's. func (p Phase) String() string { - switch p { - case StartingMachine: - return "Starting the bridge" - case AwaitingLoginLink: - return "Waiting for a login link" - case AwaitingAuthorization: - return "Waiting for you to authorize this bridge" - case JoiningTailnet: - return "Joining the tailnet" - case FindingEndpoint: - return "Looking for the Aperture on the tailnet" - case AskingForModels: - return "Asking the Aperture for its models" + if p > 0 && int(p) < len(phaseNames) { + return phaseNames[p] } return fmt.Sprintf("Phase(%d)", int(p)) } @@ -86,63 +86,47 @@ func ParseLoginLink(raw string) (LoginLink, error) { return LoginLink{url: raw}, nil } -// Kind distinguishes the events an attempt publishes. -type Kind int - -const ( - // Noted is diagnostics with no domain meaning: tsnet backend chatter, dial - // detail, a health warning. The only kind a consumer may drop. - Noted Kind = iota - // PhaseEntered is the attempt moving to a new wait. - PhaseEntered - // LoginRequired is a machine asking to be authorized at a link. - LoginRequired -) - -// Event is what an attempt publishes as it proceeds. It replaces a sink of -// plain strings that had the TUI opening a browser on a phrase from inside a -// vendored package, where a reworded log line silently stranded the user. +// Event is one thing a Machine reports while an attempt uses it. Exactly one +// field is set, and which one says what happened: the attempt entered Phase, +// the Machine needs authorizing at Link, or Note is a line for the user with +// no domain meaning. It replaced a sink of plain strings that had the TUI +// opening a browser on a phrase from inside a vendored package, where a +// reworded log line silently stranded the user. type Event struct { - Kind Kind - Phase Phase // Kind == PhaseEntered - Link LoginLink // Kind == LoginRequired - Text string // Kind == Noted + Phase Phase + Link *LoginLink + Note string } -// Note reports diagnostics, flattened to one line. -// -// Flattened here because String promises one line and the connect screen wraps -// and indents each itself: an embedded newline lands unindented and miscounts -// the rows to repaint. Control plane errors carry their request ID on a second -// line, so this is the normal shape of a failure. -func Note(text string) Event { - return Event{Kind: Noted, Text: strings.Join(strings.Fields(text), " ")} -} +// Note is a line for the user that is neither a phase nor a link: tsnet +// backend chatter, dial detail, a health warning. The only event a consumer +// may drop. +func Note(text string) Event { return Event{Note: text} } -// Notef reports diagnostics, formatted. +// Notef is Note, formatted. func Notef(format string, args ...any) Event { return Note(fmt.Sprintf(format, args...)) } -// Entered reports that the attempt is now waiting on p. -func Entered(p Phase) Event { return Event{Kind: PhaseEntered, Phase: p} } +// Entered is the attempt now waiting on p. +func Entered(p Phase) Event { return Event{Phase: p} } -// Login reports that the machine needs authorizing at link. -func Login(link LoginLink) Event { return Event{Kind: LoginRequired, Link: link} } +// LoginRequired is the Machine needing authorization at link. +func LoginRequired(link LoginLink) Event { return Event{Link: &link} } // Droppable reports whether a consumer under backpressure may discard this -// event. Only diagnostics may go: 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 +// event. Only a Note may go: 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.Kind == Noted } +func (e Event) Droppable() bool { return e.Phase == 0 && e.Link == nil } -// String renders the event as one line of the activation log. +// String is the event's representation, 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 e.Kind { - case PhaseEntered: + switch { + case e.Phase != 0: return e.Phase.String() - case LoginRequired: - return "Authorize this bridge at " + e.Link.String() - default: - return e.Text + 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 index 37633bb..6f61810 100644 --- a/internal/connection/event_test.go +++ b/internal/connection/event_test.go @@ -70,7 +70,7 @@ func TestOnlyNotesAreDroppable(t *testing.T) { {Note("magicsock: home is derp-1"), true}, {Notef("dialing %s", "ai"), true}, {Entered(AwaitingLoginLink), false}, - {Login(link), false}, + {LoginRequired(link), false}, } { if got := tt.event.Droppable(); got != tt.droppable { t.Errorf("%q Droppable() = %t, want %t", tt.event, got, tt.droppable) @@ -94,24 +94,28 @@ func TestPhasesAreOrdered(t *testing.T) { t.Errorf("%v does not sort before %v", ordered[i-1], p) } if p.String() == "" { - t.Errorf("phase %d has no name for the screen", int(p)) + t.Errorf("phase %d has no name", int(p)) } } } -// TestNoteIsOneLine covers the shape control plane errors actually arrive in. -// A register failure carries its request ID on a second line, and the connect -// screen wraps and indents each log line itself: an embedded newline puts -// unindented text mid-block and miscounts the rows the renderer repaints. -func TestNoteIsOneLine(t *testing.T) { - raw := "register request: http 502: backend not found; tn=0\nREQ-2026091717445499013f4d855ec3c0" - got := Note(raw).String() - if strings.Contains(got, "\n") { - t.Errorf("note = %q, want the newline flattened out", got) +// 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) } - for _, want := range []string{"http 502", "REQ-2026091717445499013f4d855ec3c0"} { - if !strings.Contains(got, want) { - t.Errorf("note = %q, want it to keep %q", got, want) - } + 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/tui.go b/internal/tui/tui.go index 7c355b3..adbf1c3 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -240,9 +240,43 @@ type bridgeLine struct { event connection.Event } -// String renders a log line the way the connect screen shows it. +// 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 lands unindented and miscounts 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), l.event) + return fmt.Sprintf("+%-6s %s", l.elapsed.Round(100*time.Millisecond), strings.Join(strings.Fields(describe(l.event)), " ")) +} + +// describe is what 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 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 { @@ -453,9 +487,9 @@ func (m *model) retargetActivation(next config.Endpoint) tea.Cmd { // 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.Kind == connection.Noted { - ev.Text = strings.TrimSpace(ev.Text) - if ev.Text == "" { + if ev.Droppable() { + ev.Note = strings.TrimSpace(ev.Note) + if ev.Note == "" { return } } @@ -566,8 +600,8 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } next := waitBridgeLog(m.act.logCtx, m.act.logCh) - switch msg.line.event.Kind { - case connection.LoginRequired: + 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 @@ -577,7 +611,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.act.authURL = url m.act.copied = false return m, tea.Batch(next, openURLCmd(m.act.id, url)) - case connection.PhaseEntered: + case msg.line.event.Phase != 0: if !m.act.entered(msg.line.event.Phase) { return m, next } @@ -712,7 +746,7 @@ func appendBridgeLog(logs []bridgeLine, line bridgeLine) []bridgeLine { // 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) important() bool { - return l.event.Kind != connection.Noted || importantBridgeLog(l.event.Text) + return !l.event.Droppable() || importantBridgeLog(l.event.Note) } func importantBridgeLog(line string) bool { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 5b0547c..d38f103 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1277,8 +1277,8 @@ func TestBridgeLogSinkStampsElapsed(t *testing.T) { emit(connection.Note(" Bridge connected. ")) line := <-ch - if line.event.Text != "Bridge connected." { - t.Errorf("text = %q, want it trimmed", line.event.Text) + 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) @@ -1314,8 +1314,8 @@ func TestWaitBridgeLogDrainsBufferedLogBeforeCancellation(t *testing.T) { if !ok { t.Fatalf("message = %T, want bridgeLogMsg", msg) } - if logMsg.line.event.Text != "final dial error" { - t.Errorf("line = %q, want final dial error", logMsg.line.event.Text) + if logMsg.line.event.Note != "final dial error" { + t.Errorf("line = %q, want final dial error", logMsg.line.event.Note) } } @@ -1385,12 +1385,12 @@ func TestBridgeAuthURLIsShownOnceAndOpened(t *testing.T) { act: &activation{id: 7, logCh: ch, logCtx: ctx}, } - _, cmd := m.Update(bridgeLogMsg{ch: ch, line: bridgeLine{event: connection.Login(link)}}) + _, 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.Login(link)}}) + _, 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) @@ -1410,7 +1410,7 @@ func TestBridgeAuthURLIsShownOnceAndOpened(t *testing.T) { } m.Update(browserOpenMsg{id: 7, err: errors.New("exec: \"xdg-open\": not found")}) - if len(m.bridgeLogs) != 1 || !strings.Contains(m.bridgeLogs[0].event.Text, "Use the link below") { + 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")}) @@ -1646,7 +1646,7 @@ func TestBridgeLogSinkNeverDropsTheLoginLink(t *testing.T) { } sent := make(chan struct{}) go func() { - emit(connection.Login(link)) + emit(connection.LoginRequired(link)) close(sent) }() @@ -1656,7 +1656,7 @@ func TestBridgeLogSinkNeverDropsTheLoginLink(t *testing.T) { for { select { case line := <-ch: - if line.event.Kind == connection.LoginRequired { + if line.event.Link != nil { <-sent return } From 969e0af017826f0e053dfe16dab53448a9028b8f Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Mon, 21 Sep 2026 16:25:32 +0000 Subject: [PATCH 50/69] config: drop the tool name from the run log cap comment --- internal/config/runlog.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/runlog.go b/internal/config/runlog.go index 7dd2f6f..1849c75 100644 --- a/internal/config/runlog.go +++ b/internal/config/runlog.go @@ -10,7 +10,7 @@ import ( // a long history of them and still cannot grow without bound on a box nobody // prunes. // -// ponytail: truncate at a cap, rotate if anyone ever needs the older runs. +// Truncated at a cap; rotate if anyone ever needs the older runs. const runLogCap = 2 << 20 // RunLogPath returns the file every run writes its diagnostics to. It sits From 4069a7b983cabe68265e8cb9bc31529b7f7301f4 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Mon, 21 Sep 2026 16:25:32 +0000 Subject: [PATCH 51/69] config: name the endpoint the command line asked for, not a Startup Startup was a struct of two flag values with one method, named for when it ran rather than what it was. EndpointFromFlags is the function it was: given the flags, the Endpoint to open on. Nothing else held state, so nothing else needed a type. --- cmd/aperture/main.go | 5 +---- internal/config/startup.go | 31 ++++++++++++------------------- internal/config/startup_test.go | 16 +++++++--------- 3 files changed, 20 insertions(+), 32 deletions(-) diff --git a/cmd/aperture/main.go b/cmd/aperture/main.go index a28197b..7ee22cf 100644 --- a/cmd/aperture/main.go +++ b/cmd/aperture/main.go @@ -198,10 +198,7 @@ func main() { // Before the TUI takes the terminal, so a URL we cannot use exits non-zero // instead of painting an error the script that passed it will never see. - start, err := config.Startup{ - URL: orEnv(*flagEndpoint, "APERTURE_ENDPOINT"), - BridgeName: orEnv(*flagBridge, "APERTURE_BRIDGE"), - }.Resolve(g) + start, err := config.EndpointFromFlags(g, orEnv(*flagEndpoint, "APERTURE_ENDPOINT"), orEnv(*flagBridge, "APERTURE_BRIDGE")) if err != nil { slog.Error("resolving the endpoint to open on", "err", err) reportFailure(err) diff --git a/internal/config/startup.go b/internal/config/startup.go index 9713137..1bf421c 100644 --- a/internal/config/startup.go +++ b/internal/config/startup.go @@ -5,15 +5,8 @@ import ( "strings" ) -// Startup is what the invocation asked the launcher to open. The zero value -// means it asked for nothing. -type Startup struct { - URL string - BridgeName string -} - -// Resolve returns the endpoint to open on, falling back to the saved active -// one when the invocation named nothing. +// EndpointFromFlags is the Endpoint the command line asked the launcher to +// open: the saved active one when it named nothing. // // A URL alone is a direct connection. A bridge alone opens at DefaultLocation, // the same guess the connection picker makes, because naming a bridge usually @@ -22,40 +15,40 @@ type Startup struct { // // Callers resolve before the TUI takes the terminal, so a URL we cannot use is // a line on stderr rather than a full-screen error. -func (s Startup) Resolve(g *Global) (Endpoint, error) { - url := strings.TrimSpace(s.URL) - name := strings.TrimSpace(s.BridgeName) - if url == "" && name == "" { +func EndpointFromFlags(g *Global, rawURL, bridgeName string) (Endpoint, error) { + rawURL = strings.TrimSpace(rawURL) + bridgeName = strings.TrimSpace(bridgeName) + if rawURL == "" && bridgeName == "" { return g.ActiveEndpoint(), nil } // The URL is checked before the bridge is looked up, 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 url != "" { - parsed, err := ParseEndpointURL(url) + if rawURL != "" { + parsed, err := ParseEndpointURL(rawURL) if err != nil { return nil, err } location = parsed } - if name == "" { + if bridgeName == "" { return Direct(location), nil } - bridge, err := s.bridge(g, name) + bridge, err := bridgeNamed(g, bridgeName) if err != nil { return nil, err } return Bridged(location, bridge.ID), nil } -// bridge creates the named bridge if there is none, which is what makes a first +// bridgeNamed creates the named bridge if there is none, which 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 the flag then names neither: picking the // first leaves the other unreachable from the command line, silently. -func (s Startup) bridge(g *Global, name string) (Bridge, error) { +func bridgeNamed(g *Global, name string) (Bridge, error) { var matched []Bridge for _, b := range g.Settings.Bridges { if strings.EqualFold(b.Name, name) { diff --git a/internal/config/startup_test.go b/internal/config/startup_test.go index b4d7e8d..7ad4c4e 100644 --- a/internal/config/startup_test.go +++ b/internal/config/startup_test.go @@ -28,7 +28,7 @@ func loadInto(t *testing.T, s config.Settings) *config.Global { func TestResolveFallsBackToTheSavedOne(t *testing.T) { g := loadInto(t, config.Settings{Endpoints: []config.Endpoint{config.Direct("http://saved")}}) - ep, err := config.Startup{}.Resolve(g) + ep, err := config.EndpointFromFlags(g, "", "") if err != nil { t.Fatalf("Resolve: %v", err) } @@ -40,7 +40,7 @@ func TestResolveFallsBackToTheSavedOne(t *testing.T) { func TestResolveTakesABareHost(t *testing.T) { g := loadInto(t, config.Settings{Endpoints: []config.Endpoint{config.Direct("http://saved")}}) - ep, err := config.Startup{URL: "aperture.example.com"}.Resolve(g) + ep, err := config.EndpointFromFlags(g, "aperture.example.com", "") if err != nil { t.Fatalf("Resolve: %v", err) } @@ -55,7 +55,7 @@ func TestResolveTakesABareHost(t *testing.T) { func TestResolveGuessesTheLocationForANamedBridge(t *testing.T) { g := loadInto(t, config.Settings{Bridges: []config.Bridge{{ID: "bridge-abc123", Name: "Work"}}}) - ep, err := config.Startup{BridgeName: "work"}.Resolve(g) + ep, err := config.EndpointFromFlags(g, "", "work") if err != nil { t.Fatalf("Resolve: %v", err) } @@ -72,7 +72,7 @@ func TestResolveGuessesTheLocationForANamedBridge(t *testing.T) { func TestResolveCreatesAnUnknownBridge(t *testing.T) { g := loadInto(t, config.Settings{}) - ep, err := config.Startup{URL: "http://aperture.example.com", BridgeName: "Work"}.Resolve(g) + ep, err := config.EndpointFromFlags(g, "http://aperture.example.com", "Work") if err != nil { t.Fatalf("Resolve: %v", err) } @@ -97,8 +97,7 @@ func TestResolveCreatesAnUnknownBridge(t *testing.T) { func TestResolveRejectsAUnusableURL(t *testing.T) { g := loadInto(t, config.Settings{}) - s := config.Startup{URL: "ftp://aperture.example.com"} - if _, err := s.Resolve(g); err == nil { + 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") } } @@ -108,8 +107,7 @@ func TestResolveRejectsAUnusableURL(t *testing.T) { func TestResolveRejectsTheURLBeforeCreatingTheBridge(t *testing.T) { g := loadInto(t, config.Settings{}) - s := config.Startup{URL: "ftp://aperture.example.com", BridgeName: "Work"} - if _, err := s.Resolve(g); err == nil { + 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 { @@ -132,7 +130,7 @@ func TestResolveRejectsAnAmbiguousBridgeName(t *testing.T) { {ID: "bridge-bbb222", Name: "work"}, }}) - _, err := config.Startup{BridgeName: "WORK"}.Resolve(g) + _, err := config.EndpointFromFlags(g, "", "WORK") if err == nil { t.Fatal("Resolve picked one of two bridges called work") } From ccbab1eebbb541e97180417f6cc126823840e6eb Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Mon, 21 Sep 2026 16:27:19 +0000 Subject: [PATCH 52/69] bridges: log an endpoint by its origin only ParseEndpointURL accepts userinfo and a query, so an endpoint like https://user:password@host or one carrying a token in its query wrote the credential into aperture.log, which is the file people share when asking for help. The activation record and the proxy error line now carry scheme and host and nothing else. url.Redacted was not enough: it masks the password and keeps the query. --- internal/bridges/route.go | 2 +- internal/bridges/security_test.go | 40 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/internal/bridges/route.go b/internal/bridges/route.go index 2cb15bb..bae4998 100644 --- a/internal/bridges/route.go +++ b/internal/bridges/route.go @@ -99,7 +99,7 @@ func (mc *Machine) openRoute(target *url.URL) (*Route, error) { } 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", target.Redacted(), r.URL.Path, err, err) + 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) } diff --git a/internal/bridges/security_test.go b/internal/bridges/security_test.go index c85e76d..bdfb02d 100644 --- a/internal/bridges/security_test.go +++ b/internal/bridges/security_test.go @@ -173,3 +173,43 @@ func TestRunLogOmitsLoginCapabilities(t *testing.T) { } } } + +// 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) + } + } +} From 5fdfbba7abb92d79e41cdf9f2ff36ee25bd9f937 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Mon, 21 Sep 2026 16:31:08 +0000 Subject: [PATCH 53/69] bridges: refuse a short name that is not a peer instead of asking tsnet Handing a bare name back to tsnet after the peer map failed to produce it reintroduced the wrong-tailnet resolution the peer-map lookup exists to prevent: tsnet's resolver falls through to the host resolver, and a host already on another tailnet answers with that tailnet's node of the same name. The five second wait only delayed the exposure. A bare name is a peer alias and nothing else, so it fails. A qualified name can be a subnet route or the tailnet's own DNS, which only tsnet can resolve, so it still falls through. --- internal/bridges/machine_test.go | 17 ++++++++++------- internal/bridges/route.go | 13 ++++++++++--- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/internal/bridges/machine_test.go b/internal/bridges/machine_test.go index a8f3584..148857a 100644 --- a/internal/bridges/machine_test.go +++ b/internal/bridges/machine_test.go @@ -161,8 +161,11 @@ func TestActivateDebugDiagnostics(t *testing.T) { 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) + 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") @@ -171,7 +174,7 @@ func TestActivateDebugDiagnostics(t *testing.T) { `dns_suffix="example.ts.net"`, `target is not present among visible peers`, `Bridge dial failed`, - `lookup aperture`, + `not a node on this bridge's tailnet`, } { if !strings.Contains(got, want) { t.Errorf("logs missing %q:\n%s", want, got) @@ -406,12 +409,12 @@ func TestDialViaNode(t *testing.T) { } }) - t.Run("falls back to the name when the target is not a peer", func(t *testing.T) { + 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", "ai:80", + context.Background(), node, "tcp", "db.internal.example:5432", collect(&logs), 5*time.Millisecond, time.Millisecond, ) @@ -419,8 +422,8 @@ func TestDialViaNode(t *testing.T) { t.Fatal(err) } conn.Close() - if got := node.dialedAddrs(); len(got) != 1 || got[0] != "ai:80" { - t.Errorf("dialed %v, want [ai:80]", got) + 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) diff --git a/internal/bridges/route.go b/internal/bridges/route.go index bae4998..749e07c 100644 --- a/internal/bridges/route.go +++ b/internal/bridges/route.go @@ -146,9 +146,16 @@ func dialViaNode( if ctx.Err() != nil { return nil, attempts, err } - // Not every target is a tailnet node: a subnet router or the tailnet's - // own DNS can serve it. Those resolve only the way tsnet resolves, so - // fall through and say so, since this path can leave the tailnet. + // 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, + // which resolve only the way tsnet resolves, so fall through and say + // so, since 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 From fc825f28308e623bc8745bc463889863bac3c384 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Mon, 21 Sep 2026 16:31:08 +0000 Subject: [PATCH 54/69] bridges: return from Destroy at its deadline while cleanup keeps the Machine The removal's 45 second bound covered Logout, which takes the context, but not the node's Close after it, which does not. A close that hung held the removal past its deadline and the TUI with it. Destroy now runs its work on its own goroutine and returns when the work is done or the context ends. The Machine stays held until the work finishes, so the next operation on it waits for the close rather than opening the state directory underneath it. --- internal/bridges/lifecycle_test.go | 51 ++++++++++++++++++++++++++++++ internal/bridges/machine.go | 29 ++++++++++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/internal/bridges/lifecycle_test.go b/internal/bridges/lifecycle_test.go index db98a2f..b6d7b64 100644 --- a/internal/bridges/lifecycle_test.go +++ b/internal/bridges/lifecycle_test.go @@ -312,3 +312,54 @@ func TestDestroySkipsABridgeThatNeverStarted(t *testing.T) { 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 index 77a73ea..1cadab4 100644 --- a/internal/bridges/machine.go +++ b/internal/bridges/machine.go @@ -261,6 +261,12 @@ func (mc *Machine) LeaveTailnet(ctx context.Context, emit func(connection.Event) // // The state directory goes last and only on success: it holds the node key, // which is what a later attempt would need to deregister the device. +// +// 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 { @@ -271,7 +277,28 @@ func (mc *Machine) Destroy(ctx context.Context, emit func(connection.Event)) err if err != nil { return err } - defer mc.end() + 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 is Destroy's work, run with the Machine held. +func (mc *Machine) destroyHeld(ctx context.Context, stateDir string, ev events) error { if mc.node == nil && !HasMachine(mc.bridge.ID) { mc.setTailnet("") return nil From dfa2dd7ef5dd09d181945b64a8849494f3d95097 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Mon, 21 Sep 2026 16:31:08 +0000 Subject: [PATCH 55/69] tui: let a removal finish before Ctrl+C quits Quitting during a removal closed the Machines, which cancelled the logout, and could exit before the outcome message dropped the settings records. The next run then named a device that was already gone. Ctrl+C during a removal now marks the intent and the quit happens once the outcome has been applied. --- internal/tui/removal.go | 14 ++++++++----- internal/tui/removal_test.go | 38 ++++++++++++++++++++++++++++++++++++ internal/tui/tui.go | 14 +++++++++++++ 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/internal/tui/removal.go b/internal/tui/removal.go index 7cb5c1f..ea78695 100644 --- a/internal/tui/removal.go +++ b/internal/tui/removal.go @@ -136,23 +136,27 @@ func (m *model) bridgeRemoved(msg bridgeRemovedMsg) (tea.Model, tea.Cmd) { m.step = stepMenu err := bridges.ForgetBridge(m.g, msg.bridge, msg.endpoint, msg.err) var unconfirmed *bridges.Unconfirmed + var cmd tea.Cmd switch { case errors.As(err, &unconfirmed): - cmd := m.afterRemoval(msg.endpoint) + cmd = m.afterRemoval(msg.endpoint) m.step = stepError m.errMsg = m.unconfirmedMessage(unconfirmed) - return m, cmd case err != nil && errors.Is(err, msg.err): m.step = stepError m.errMsg = "Could not remove bridge " + msg.bridge.Name + ": " + err.Error() + "\n\nThe connection is unchanged. Removing it again retries the logout." - return m, nil case err != nil: m.step = stepError m.errMsg = err.Error() - return m, nil + default: + cmd = m.afterRemoval(msg.endpoint) + } + if m.quitAfterRemoval { + m.quitAfterRemoval = false + return m, m.quitCmd() } - return m, m.afterRemoval(msg.endpoint) + return m, cmd } // unconfirmedMessage is what the user needs to finish the job by hand: the diff --git a/internal/tui/removal_test.go b/internal/tui/removal_test.go index bc4b874..ed54ad2 100644 --- a/internal/tui/removal_test.go +++ b/internal/tui/removal_test.go @@ -222,3 +222,41 @@ func TestDestroyTimeoutRemovesLocallyAndNamesTheDevice(t *testing.T) { } } } + +// 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 adbf1c3..a31b550 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -99,6 +99,8 @@ type model struct { bridgeLogs []bridgeLine failedEndpoint config.Endpoint connected bool + // quitAfterRemoval is Ctrl+C pressed while a removal was on the tailnet. + quitAfterRemoval bool } // activation is the connection attempt currently on screen: its identity, its @@ -164,6 +166,10 @@ func (a *activation) endpoint() config.Endpoint { // cancelable reports whether Esc can interrupt this attempt. func (a *activation) cancelable() 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) removing() bool { return a != nil && a.attempt == nil && a.logCh != nil } + // overridable 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) overridable() bool { @@ -538,6 +544,14 @@ func waitBridgeLog(ctx context.Context, ch chan bridgeLine) tea.Cmd { } func (m *model) quitCmd() tea.Cmd { + // A removal's outcome is what drops the records naming the device. + // Closing the Machines now would cancel the logout, and quitting before + // the outcome arrives would leave settings naming a device that may be + // gone. The quit happens when the outcome has been applied. + if m.act.removing() { + m.quitAfterRemoval = true + return nil + } var cancel context.CancelFunc if m.act != nil { cancel = m.act.cancel From c20b8e33e584cc7c6fde2f39e1dd265fd1d7005a Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Mon, 21 Sep 2026 16:32:46 +0000 Subject: [PATCH 56/69] docs: describe Attempt, the removal functions and the two Endpoint types The model said Bridging and Removal; the code, after review, says Attempt, DestroysMachine, Machines.Destroy and ForgetBridge, and the domain model's Succeed/Fail/Cancel rows are Run/Commit/Abandon because that is what they do. ADR 0005 records the renames and why the first names went. ADR 0006 records the Endpoint split, whose forcing reason was a review comment and not a failure, so it is short. --- docs/adr/0005-machine-owns-its-operations.md | 36 ++++++----- docs/adr/0006-endpoint-is-two-types.md | 44 ++++++++++++++ docs/specs/bridge-resource-lifecycle.md | 4 +- docs/specs/connection-context-map.md | 5 +- docs/specs/connection-contracts.md | 6 +- docs/specs/connection-domain-model.md | 63 ++++++++++---------- 6 files changed, 104 insertions(+), 54 deletions(-) create mode 100644 docs/adr/0006-endpoint-is-two-types.md diff --git a/docs/adr/0005-machine-owns-its-operations.md b/docs/adr/0005-machine-owns-its-operations.md index 573d29c..cefee8a 100644 --- a/docs/adr/0005-machine-owns-its-operations.md +++ b/docs/adr/0005-machine-owns-its-operations.md @@ -1,4 +1,4 @@ -# 0005. Machine owns its operations, Machines holds them, Bridging decides between Bridge and Machine +# 0005. Machine owns its operations, Machines holds them, Attempt owns its transitions Status: accepted Date: 2026-09-21 @@ -30,16 +30,20 @@ decide". 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. `Bridging` is a stateless domain service for the transitions that belong - to no single aggregate: an Attempt reaching an Endpoint and committing it, - the tailnet joined recorded on the Bridge, a Bridge removed only after its - Machine is destroyed. -4. `Attempt` (the model's ConnectionAttempt) lives in `internal/bridges`. The - TUI's `activation` holds presentation state only. -5. Every service operation that waits on the network is split from the one - that writes settings. `Run` and `Destroy` may run anywhere and write - nothing; `Begin`, `Commit`, `Fail`, `Abandon`, `Destroys` and `Forget` run - on the update loop. +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: + `DestroysMachine`, `Machines.Destroy`, `ForgetBridge`. 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`, `DestroysMachine` and + `ForgetBridge` run on the update loop. 6. `Manager` is deleted. No compatibility wrapper. ## Consequences @@ -55,12 +59,12 @@ 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`. `Bridging.Tailnet` +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. `Begin` and `Fail` report -the rule's answer; the TUI keeps the bit. Naming the object that owns "the +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 @@ -78,6 +82,6 @@ current Gateway and whether it is verified" is the next modelling step. ## Revisit when APT-330 lands: `Machines` and `Machine` become one launcher's view of a shared -helper, and `Bridging` should survive that with its signatures. Or an object +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 `Fail` lose their reason to report a bool. +and `InvalidatesActive` and `TargetsActive` lose their reason to exist. diff --git a/docs/adr/0006-endpoint-is-two-types.md b/docs/adr/0006-endpoint-is-two-types.md new file mode 100644 index 0000000..2f71927 --- /dev/null +++ b/docs/adr/0006-endpoint-is-two-types.md @@ -0,0 +1,44 @@ +# 0006. An Endpoint is one of two types, not a struct with an optional Bridge + +Status: accepted +Date: 2026-09-21 + +## Why? + +`Endpoint` was `{URL, BridgeID}` and a direct connection was the one with an +empty `BridgeID`. Every caller decided which kind it had by testing a string +for emptiness, thirty-six times outside tests, and the two concepts the +context map keeps apart shared one struct. Review on PR 41 called it what it +was: two different things, and an empty field standing in for a type. + +## Decision + +1. `Endpoint` is an interface with two implementations and no third: + `DirectEndpoint` and `BridgeEndpoint`. An unexported method keeps the set + closed. +2. The kind is the type. Callers that differ by kind type-switch; nothing + asks whether a field is empty. +3. Values are comparable, so two Endpoints are the same when `==` says so. + `SameEndpoint` is gone. +4. `settings.json` keeps its shape: `bridgeId` present or absent. The file + predates the split and is not changing under existing users. Decoding + picks the type from the record; each type encodes back to the record. +5. `WithURL` is on the interface: an edit or an inline override keeps its + Bridge, and the caller does not need to know which kind it holds. + +## Consequences + +Constructors (`Direct`, `Bridged`) replace literals, in tests too. A nil +Endpoint is possible where a zero struct was not; the few places that meant +"no endpoint" now say `nil` and are guarded. + +## Rejected + +- **Exported fields with a differently named accessor.** A field and a method + cannot share the name `URL`, and `Location` or `Address` would have put a + second word for the same thing into the language table. +- **A `Kind` field on one struct.** The same smell with a nicer name. + +## Revisit when + +A third way to reach an Aperture exists. diff --git a/docs/specs/bridge-resource-lifecycle.md b/docs/specs/bridge-resource-lifecycle.md index 8c6c570..db1ef46 100644 --- a/docs/specs/bridge-resource-lifecycle.md +++ b/docs/specs/bridge-resource-lifecycle.md @@ -26,7 +26,7 @@ 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 -`bridgeRemoval` and hand it to `remove` (`removal.go`), which is the only place +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 | @@ -48,7 +48,7 @@ login nobody finished, and is the one case with no device to clean up. `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 `Bridging.Destroy`, because +`Machine.Destroy` is the entry point, reached through `Machines.Destroy`, because destruction has to hold the Machine like every other operation on that node. diff --git a/docs/specs/connection-context-map.md b/docs/specs/connection-context-map.md index 0c22eff..4112122 100644 --- a/docs/specs/connection-context-map.md +++ b/docs/specs/connection-context-map.md @@ -16,13 +16,12 @@ The context boundaries below also describe the proposed broader event refactor. | 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, plus which Bridge (if any) reaches it. | The local proxy address. Anything the CLI listens on. | +| 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. | -| Bridging | The service between a Bridge, its Machine and the Endpoints reached through it: connecting, switching tailnet, removing. Stateless. | The Machine's own operations, which stay on the Machine. | | 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. | @@ -41,7 +40,7 @@ it for our node and for every peer in the netmap at once. | Context | Subdomain | Owns | Lives in | |---|---|---|---| -| Connection | Core | Connection Attempt, Phase, Progress, Login Link, Gateway, Route, Machine, Machines, Bridging | `internal/bridges`. `internal/tui` presents and dispatches, and decides nothing. | +| 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` | diff --git a/docs/specs/connection-contracts.md b/docs/specs/connection-contracts.md index 3899d4a..5be6306 100644 --- a/docs/specs/connection-contracts.md +++ b/docs/specs/connection-contracts.md @@ -47,7 +47,7 @@ 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. -`Bridging` composes these for the TUI; see the domain model for its table. +`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 @@ -94,7 +94,7 @@ 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 `Bridging.Commit`. Before that the TUI was loading, +`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. @@ -118,7 +118,7 @@ not, and the gap is deliberate rather than unfinished: | `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 | `Bridging.Commit` carries the fact as a field of `Verified`; no event yet. | +| `TailnetJoined` | Not built | `Attempt.Commit` carries the fact as a field of `Verified`; 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 diff --git a/docs/specs/connection-domain-model.md b/docs/specs/connection-domain-model.md index cc44084..e69c298 100644 --- a/docs/specs/connection-domain-model.md +++ b/docs/specs/connection-domain-model.md @@ -35,8 +35,9 @@ The following is the concrete model for [ADR 0003](../adr/0003-preserve-verified 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`, `bridge config.Bridge`, -`ephemeral bool`, `replaces *config.Endpoint` and `switchTailnet bool`. +`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; @@ -63,9 +64,10 @@ 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. -`Bridging.Begin` marks the attempt as invalidating the active destination when +`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. This is conservative when cancellation beats +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. @@ -94,7 +96,11 @@ Attempt. - `Enter(Phase) Progress` — advance, appending to `Trail`. Rejects a backwards move. - `Authorize(LoginLink)` — record the link and enter `AwaitingAuthorization`. -- `Succeed(Gateway)` / `Fail(error)` / `Cancel()` — terminal, once. +- `Run(ctx, machines, emit) (Verified, 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, Verified) error` — persist a verified 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`. @@ -289,30 +295,27 @@ 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. -## Bridging +## Removing a Bridge -Domain service. Stateless over `Machines` and Settings. It exists because the -transitions it owns belong to no single aggregate: an Attempt reaches an -Endpoint through a Machine and then commits to Settings; joining a tailnet is -a Machine fact recorded on a Bridge; removing a Bridge destroys its Machine -first (ADR 0002). Before it, the TUI decided all three. +The one transition no single aggregate owns: a Bridge record and the Machine +registered for it go together, Machine first (ADR 0002). Three functions in +`internal/bridges`, named for the nouns they act on, and a Settings rule. | Operation | Runs on | Does | |---|---|---| -| `Begin(ep, switchTailnet, replacing)` | update loop | Writes an unsaved Endpoint as the attempt's candidate, clears the Bridge's recorded tailnet before a switch, marks the attempt as invalidating the active destination. | -| `Retarget(a, next)`, `Edit(current, ep, next)` | update loop | Replace a candidate nobody chose; keep the original of a pending edit. | -| `Run(ctx, a, emit)` | any goroutine | Leaves the tailnet if asked, opens the Machine, routes, asks the Aperture for models. Writes nothing. | -| `Commit(a, verified)` | update loop | One settings write for the edit; records the tailnet on the Bridge; sets the Gateway and providers clients launch against. | -| `Fail(a)` | update loop | Keeps the candidate; reports whether the active destination is now unverified. | -| `Abandon(a)` | update loop | Removes the candidate this attempt added, never the active endpoint. | -| `Destroys(rem)` | update loop | Whether rem takes a device off a tailnet, and whether rem may go at all. | -| `Destroy(ctx, rem, emit)` | any goroutine | The bounded logout (ADR 0002 decision 6). Writes nothing. | -| `Forget(rem, destroyErr)` | update loop | Drops endpoint then bridge, or keeps both when the tailnet refused; an expired wait drops them and returns `*Unconfirmed`. | -| `Tailnet(bridge)` | update loop | What the running Machine reports, else what was saved. | - -The split into a waiting half and a writing half is not stylistic. 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. +| `DestroysMachine(settings, bridge, endpoint)` | update loop | Whether removing the endpoint, or the bare bridge, takes a device off a tailnet: the Bridge's last Endpoint and a Machine that started. An error when it may not go: the active endpoint, or a bare Bridge some Endpoint still reaches through. | +| `Machines.Destroy(ctx, bridge, emit)` | any goroutine | The bounded logout (ADR 0002 decision 6). Returns `*Unconfirmed` when the wait expires. Writes nothing. | +| `ForgetBridge(settings, bridge, endpoint, destroyErr)` | update loop | Drops endpoint then bridge, or keeps both when the tailnet refused; an expired wait drops them and returns the `*Unconfirmed`. | +| `Machines.Tailnet(bridge)` | update loop | What the running Machine reports, else what was saved. | + +`Unconfirmed` is a removal the tailnet did not confirm within the wait: the +records are gone and the device may not be. It carries the Bridge so the user +can be told which device to look for. + +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 @@ -369,9 +372,9 @@ classDiagram +bool Ephemeral +Enter(Phase) Progress +Authorize(LoginLink) - +Succeed(Gateway) - +Fail(error) - +Cancel() + +Run(ctx, machines, emit) Verified + +Commit(settings, Verified) + +Abandon(settings) +Slowest() Progress +Supersedes(ConnectionAttempt) bool } @@ -427,10 +430,10 @@ classDiagram ## Open, not assumed -- Which Gateway is current for the next client launch is now `Bridging.Commit` +- 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 what `Begin` and `Fail` report. No object owns + `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 From b19346c7bca8cce931b18f8cbdf6b01098fad0ba Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Mon, 21 Sep 2026 19:03:03 +0000 Subject: [PATCH 57/69] docs: drop ADR 0006 Splitting Endpoint into two types is a refactor the code and its doc comments already explain. Nothing about it needed a decision record, and review on PR 41 said so. --- docs/adr/0006-endpoint-is-two-types.md | 44 -------------------------- 1 file changed, 44 deletions(-) delete mode 100644 docs/adr/0006-endpoint-is-two-types.md diff --git a/docs/adr/0006-endpoint-is-two-types.md b/docs/adr/0006-endpoint-is-two-types.md deleted file mode 100644 index 2f71927..0000000 --- a/docs/adr/0006-endpoint-is-two-types.md +++ /dev/null @@ -1,44 +0,0 @@ -# 0006. An Endpoint is one of two types, not a struct with an optional Bridge - -Status: accepted -Date: 2026-09-21 - -## Why? - -`Endpoint` was `{URL, BridgeID}` and a direct connection was the one with an -empty `BridgeID`. Every caller decided which kind it had by testing a string -for emptiness, thirty-six times outside tests, and the two concepts the -context map keeps apart shared one struct. Review on PR 41 called it what it -was: two different things, and an empty field standing in for a type. - -## Decision - -1. `Endpoint` is an interface with two implementations and no third: - `DirectEndpoint` and `BridgeEndpoint`. An unexported method keeps the set - closed. -2. The kind is the type. Callers that differ by kind type-switch; nothing - asks whether a field is empty. -3. Values are comparable, so two Endpoints are the same when `==` says so. - `SameEndpoint` is gone. -4. `settings.json` keeps its shape: `bridgeId` present or absent. The file - predates the split and is not changing under existing users. Decoding - picks the type from the record; each type encodes back to the record. -5. `WithURL` is on the interface: an edit or an inline override keeps its - Bridge, and the caller does not need to know which kind it holds. - -## Consequences - -Constructors (`Direct`, `Bridged`) replace literals, in tests too. A nil -Endpoint is possible where a zero struct was not; the few places that meant -"no endpoint" now say `nil` and are guarded. - -## Rejected - -- **Exported fields with a differently named accessor.** A field and a method - cannot share the name `URL`, and `Location` or `Address` would have put a - second word for the same thing into the language table. -- **A `Kind` field on one struct.** The same smell with a nicer name. - -## Revisit when - -A third way to reach an Aperture exists. From aab6811ab93a4dc03e2637a053ec32b3dd92f4a2 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Mon, 21 Sep 2026 19:03:19 +0000 Subject: [PATCH 58/69] docs: SetActiveEndpoint takes replacing as an Endpoint, not a pointer The contract said *Endpoint while the signature has taken the interface, with nil for absence, since Endpoint became one. Whoever reads the contract first would write a pointer. --- docs/specs/connection-contracts.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/specs/connection-contracts.md b/docs/specs/connection-contracts.md index 5be6306..b6cfa1c 100644 --- a/docs/specs/connection-contracts.md +++ b/docs/specs/connection-contracts.md @@ -34,11 +34,12 @@ persistence schema are otherwise unchanged. See [ADR 0004](../adr/0004-contain-c ## Lifecycle correction contracts -`Global.SetActiveEndpoint(ep Endpoint, replacing *Endpoint) error` atomically -persists `ep` first, removes duplicate `ep` entries and the optional original, -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. +`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 From 10f739908c2a867562d33b0067f361dbb612351c Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Mon, 21 Sep 2026 19:04:14 +0000 Subject: [PATCH 59/69] aperture: an empty -bridge= or -endpoint= overrides the environment Both flags fell through to APERTURE_BRIDGE and APERTURE_ENDPOINT whenever their value was empty, so a shell with the variable exported had no way to ask for the saved endpoint. The README promises flag over environment. flag.Visit tells a passed flag from an absent one; the value cannot. --- cmd/aperture/main.go | 16 +++++++++------- cmd/aperture/main_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 7 deletions(-) create mode 100644 cmd/aperture/main_test.go diff --git a/cmd/aperture/main.go b/cmd/aperture/main.go index 7ee22cf..d90700b 100644 --- a/cmd/aperture/main.go +++ b/cmd/aperture/main.go @@ -157,12 +157,14 @@ func reportFailure(err error) { } } -// orEnv 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. -func orEnv(value, key string) string { - if value != "" { - return value +// flagOrEnv lets a dotfile, container or systemd unit make the same selection a +// typed invocation can. A flag that was passed wins, even empty, 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) } @@ -198,7 +200,7 @@ func main() { // Before the TUI takes the terminal, so a URL we cannot use exits non-zero // instead of painting an error the script that passed it will never see. - start, err := config.EndpointFromFlags(g, orEnv(*flagEndpoint, "APERTURE_ENDPOINT"), orEnv(*flagBridge, "APERTURE_BRIDGE")) + 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) 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) + } + }) + } +} From 7dc8008aa9c2e45406fcaff85bedc8fbe028aefe Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Mon, 21 Sep 2026 19:05:28 +0000 Subject: [PATCH 60/69] bridges: put the models path on the URL's path, not after its query ParseEndpointURL accepts a query, so a saved endpoint can carry one, and string concatenation turned http://host?token=x into http://host?token=x/v1/models: a GET of / with a mangled token. url.JoinPath keeps the query where it was. --- internal/bridges/bridging_test.go | 17 +++++++++++++++++ internal/bridges/fetch.go | 9 ++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/internal/bridges/bridging_test.go b/internal/bridges/bridging_test.go index 39fb9fd..bd70d4f 100644 --- a/internal/bridges/bridging_test.go +++ b/internal/bridges/bridging_test.go @@ -89,3 +89,20 @@ func modelsServerWithHandler(t *testing.T, check func(*http.Request)) *httptest. 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/fetch.go b/internal/bridges/fetch.go index db43991..4b03e5d 100644 --- a/internal/bridges/fetch.go +++ b/internal/bridges/fetch.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "time" @@ -20,7 +21,13 @@ const ( // AskingForModels phase and the verification everything else waits on. func fetchProviders(ctx context.Context, host string, timeout time.Duration) ([]config.ProviderInfo, error) { client := &http.Client{Timeout: timeout} - url := strings.TrimRight(host, "/") + "/v1/models" + // On 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 + } + url := base.JoinPath("v1", "models").String() req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err From 5137c103d35080323ed91e93af28df9258f9af38 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Mon, 21 Sep 2026 19:05:28 +0000 Subject: [PATCH 61/69] bridges: Abandon stays ephemeral until the candidate is actually dropped Clearing the flag before DropEndpoint meant a failed write left the candidate in settings with nothing that would ever try again: the next Abandon saw ephemeral false and returned nil. --- internal/bridges/attempt.go | 5 ++- internal/bridges/attempt_test.go | 55 ++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 internal/bridges/attempt_test.go diff --git a/internal/bridges/attempt.go b/internal/bridges/attempt.go index 2e98e14..29b5ada 100644 --- a/internal/bridges/attempt.go +++ b/internal/bridges/attempt.go @@ -219,6 +219,9 @@ func (a *Attempt) Abandon(g *config.Global) error { if a == nil || !a.ephemeral { return nil } + if err := g.DropEndpoint(a.Endpoint); err != nil { + return err + } a.ephemeral = false - return g.DropEndpoint(a.Endpoint) + return nil } diff --git a/internal/bridges/attempt_test.go b/internal/bridges/attempt_test.go new file mode 100644 index 0000000..8a850da --- /dev/null +++ b/internal/bridges/attempt_test.go @@ -0,0 +1,55 @@ +package bridges + +import ( + "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 DropEndpoint 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) + } +} From a5815ca736a25b8e378911e6a9bae36b839c97b8 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Mon, 21 Sep 2026 19:06:33 +0000 Subject: [PATCH 62/69] tests: isolate APPDATA too, so Windows runs cannot touch real settings os.UserConfigDir reads APPDATA on Windows and ignores HOME and XDG_CONFIG_HOME, so the TestMain isolation added after the 2026-09-21 overwrite protected only Linux and macOS. --- internal/bridges/main_test.go | 2 ++ internal/tui/main_test.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/internal/bridges/main_test.go b/internal/bridges/main_test.go index 13a3458..7c23b0a 100644 --- a/internal/bridges/main_test.go +++ b/internal/bridges/main_test.go @@ -15,6 +15,8 @@ func TestMain(m *testing.M) { } 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/main_test.go b/internal/tui/main_test.go index ee3630a..a30de7f 100644 --- a/internal/tui/main_test.go +++ b/internal/tui/main_test.go @@ -15,6 +15,8 @@ func TestMain(m *testing.M) { } 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) From 6bfb938fcc0d088290d15b741fb0e29d242692b5 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Mon, 21 Sep 2026 19:08:59 +0000 Subject: [PATCH 63/69] bridges: keep the records when a logout fails or times out Dropping settings on a timed-out logout orphaned the login on disk: the state directory survived, the bridge record naming it did not, and nothing was left to retry the cleanup through. Every failure now keeps the endpoint and bridge, reports the failure with the device name and lets the user remove the connection again, which retries the logout. Unconfirmed is gone with the special case. A Ctrl+C deferred past a removal that then fails shows the failure instead of quitting, since quitting would exit 0 with the only message naming the device gone. Removable, DestroysMachine, ForgetBridge, bridgeUsed and through are renamed CheckRemovable, WillDestroyMachine, RemoveFromSettings, endpointsThroughBridge and isReachableThroughBridge: predicates start with a verb and the settings function says which layer it writes. --- ...002-bridge-removal-destroys-the-machine.md | 9 +- docs/adr/0005-machine-owns-its-operations.md | 7 +- docs/specs/connection-domain-model.md | 22 +-- internal/bridges/remove.go | 142 +++++++----------- internal/tui/removal.go | 110 +++++++------- internal/tui/removal_test.go | 53 ++++++- 6 files changed, 178 insertions(+), 165 deletions(-) diff --git a/docs/adr/0002-bridge-removal-destroys-the-machine.md b/docs/adr/0002-bridge-removal-destroys-the-machine.md index b1e2198..d29974d 100644 --- a/docs/adr/0002-bridge-removal-destroys-the-machine.md +++ b/docs/adr/0002-bridge-removal-destroys-the-machine.md @@ -37,14 +37,17 @@ Destroying the last Bridge reference destroys its Machine. 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, and on timeout the local records go anyway and the - user is told which device is still theirs to delete. +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, so "removed" will sometimes mean "removed locally". +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, diff --git a/docs/adr/0005-machine-owns-its-operations.md b/docs/adr/0005-machine-owns-its-operations.md index cefee8a..2dd3818 100644 --- a/docs/adr/0005-machine-owns-its-operations.md +++ b/docs/adr/0005-machine-owns-its-operations.md @@ -37,13 +37,14 @@ decide". 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: - `DestroysMachine`, `Machines.Destroy`, `ForgetBridge`. No service type. + `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`, `DestroysMachine` and - `ForgetBridge` run on the update loop. + nothing; `BeginAttempt`, `Commit`, `Abandon`, `CheckRemovable`, + `WillDestroyMachine` and `RemoveFromSettings` run on the update loop. 6. `Manager` is deleted. No compatibility wrapper. ## Consequences diff --git a/docs/specs/connection-domain-model.md b/docs/specs/connection-domain-model.md index e69c298..ba6bebc 100644 --- a/docs/specs/connection-domain-model.md +++ b/docs/specs/connection-domain-model.md @@ -298,19 +298,21 @@ 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). Three functions in -`internal/bridges`, named for the nouns they act on, and a Settings rule. +registered for it go together, Machine first (ADR 0002). Four functions in +`internal/bridges` and a Settings rule. | Operation | Runs on | Does | |---|---|---| -| `DestroysMachine(settings, bridge, endpoint)` | update loop | Whether removing the endpoint, or the bare bridge, takes a device off a tailnet: the Bridge's last Endpoint and a Machine that started. An error when it may not go: the active endpoint, or a bare Bridge some Endpoint still reaches through. | -| `Machines.Destroy(ctx, bridge, emit)` | any goroutine | The bounded logout (ADR 0002 decision 6). Returns `*Unconfirmed` when the wait expires. Writes nothing. | -| `ForgetBridge(settings, bridge, endpoint, destroyErr)` | update loop | Drops endpoint then bridge, or keeps both when the tailnet refused; an expired wait drops them and returns the `*Unconfirmed`. | -| `Machines.Tailnet(bridge)` | update loop | What the running Machine reports, else what was saved. | - -`Unconfirmed` is a removal the tailnet did not confirm within the wait: the -records are gone and the device may not be. It carries the Bridge so the user -can be told which device to look for. +| `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 diff --git a/internal/bridges/remove.go b/internal/bridges/remove.go index bf4858c..d32d0a4 100644 --- a/internal/bridges/remove.go +++ b/internal/bridges/remove.go @@ -15,44 +15,43 @@ import ( // place, so a removal cannot wait on it indefinitely (ADR 0002, decision 6). var destroyTimeout = 45 * time.Second -// Unconfirmed is a removal the tailnet did not confirm within the wait. The -// local records are gone; the device may not be, and the user has to be told -// where to look for it. -type Unconfirmed struct { - Bridge config.Bridge - Wait time.Duration - Err error -} - -func (e *Unconfirmed) Error() string { - return fmt.Sprintf("the tailnet did not confirm within %s: %v", e.Wait, e.Err) +// 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 } -func (e *Unconfirmed) Unwrap() error { return e.Err } - -// DestroysMachine reports whether removing ep, or the bare bridge when ep is -// nil, takes a Machine off a tailnet: ep is the Bridge's last Endpoint and the -// Bridge has started a Machine. A Bridge that never started has no device, and -// must not start one to find out. An error means it may not be removed at all. -func DestroysMachine(g *config.Global, bridge config.Bridge, ep config.Endpoint) (bool, error) { - if err := removable(g, bridge, ep); err != nil { - return false, err - } +// 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, nil + return false } - for _, other := range g.Settings.Endpoints { - if through(other, bridge.ID) && other != ep { - return false, nil + for _, other := range endpointsThroughBridge(g, bridge.ID) { + if other != endpoint { + return false } } - return true, nil + return true } -// Destroy takes the bridge's Machine off its tailnet, waiting at most -// destroyTimeout for the tailnet to confirm. Settings are untouched: -// ForgetBridge drops them once the caller has the outcome, because they are -// the only record that the device exists. +// 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 { @@ -61,16 +60,16 @@ func (ms *Machines) Destroy(ctx context.Context, bridge config.Bridge, emit func ctx, cancel := context.WithTimeout(ctx, destroyTimeout) defer cancel() err = mc.Destroy(ctx, emit) - if err != nil && ctx.Err() != nil { - return &Unconfirmed{Bridge: bridge, Wait: destroyTimeout, Err: err} + if errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("the tailnet did not answer within %s: %w", destroyTimeout, err) } return err } -// Tailnet is the network a Bridge reaches, preferring what its running -// Machine reports to what was saved: a bridge that switched tailnets this -// session leaves a stale name on disk until the next verified connection -// rewrites it. +// 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 != "" { @@ -80,67 +79,42 @@ func (ms *Machines) Tailnet(bridge config.Bridge) string { return bridge.Tailnet } -// ForgetBridge drops the records a removal covers, endpoint first: a Bridge -// an Endpoint still points at cannot be removed. destroyErr is Destroy's -// outcome, nil for a removal with nothing to destroy. A refusal keeps -// everything and is returned as is: the device is still on the tailnet and -// settings are the only thing naming it. A wait that expired drops the -// records and returns the *Unconfirmed, because the device may have outlived -// the wait. +// 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. // -// Settings hold two objects where the picker shows one row, so removing the -// endpoint alone left the bridge re-listed as a bare "Connect via" row: to the -// user the row moved instead of going. A bridge two endpoints reach through -// stays. -func ForgetBridge(g *config.Global, bridge config.Bridge, ep config.Endpoint, destroyErr error) error { - var unconfirmed *Unconfirmed - if destroyErr != nil && !errors.As(destroyErr, &unconfirmed) { - return destroyErr - } - if err := removable(g, bridge, ep); err != nil { +// 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 ep != nil { - if err := g.DropEndpoint(ep); err != nil { + if endpoint != nil { + if err := g.DropEndpoint(endpoint); err != nil { return err } } - if bridge.ID != "" && !bridgeUsed(g, bridge.ID) { - if err := g.RemoveBridge(bridge.ID); err != nil { - return err - } - } - return destroyErr -} - -// removable is why ep, or the bare bridge, may not go: it is the active -// endpoint, which is the connection the user falls back to, or a Bridge some -// Endpoint still reaches through. -func removable(g *config.Global, bridge config.Bridge, ep config.Endpoint) error { - if ep != nil && ep == g.ActiveEndpoint() { - return errors.New("connect to another endpoint before removing the active one") - } - if ep == nil && bridge.ID != "" { - for _, other := range g.Settings.Endpoints { - if through(other, bridge.ID) { - return fmt.Errorf("bridge %s is used by endpoint %s; remove that connection instead", bridge.Name, other.URL()) - } - } + if bridge.ID != "" && len(endpointsThroughBridge(g, bridge.ID)) == 0 { + return g.RemoveBridge(bridge.ID) } return nil } -func bridgeUsed(g *config.Global, bridgeID string) bool { - for _, ep := range g.Settings.Endpoints { - if through(ep, bridgeID) { - return true +// 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 false + return users } -// through reports whether ep is reached through bridgeID. -func through(ep config.Endpoint, bridgeID string) bool { - bridged, ok := ep.(config.BridgeEndpoint) +// 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/tui/removal.go b/internal/tui/removal.go index ea78695..e5b342c 100644 --- a/internal/tui/removal.go +++ b/internal/tui/removal.go @@ -2,7 +2,6 @@ package tui import ( "context" - "errors" "time" tea "github.com/charmbracelet/bubbletea" @@ -12,9 +11,8 @@ import ( "github.com/tailscale/aperture-cli/internal/menu" ) -// bridgeRemovedMsg carries the outcome of the tailnet round trip back to the -// update loop, where the records can be dropped. endpoint is nil for a bare -// bridge. +// 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 @@ -22,14 +20,16 @@ type bridgeRemovedMsg struct { err error } -// destroyBridge is the tailnet round trip a removal makes. A seam for the -// tests: pickerModel has no Machines, and a real Destroy would want a tailnet. +// 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 deletes what a picker row stands for. Shared by the row's page and -// the "d" key, which have to agree on what removing a row means. +// 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 { @@ -38,8 +38,9 @@ func (m *model) removeRow(row connectionRow) menu.Result { return m.remove(row.bridge, ep) } -// bridgeOf is the Bridge a saved endpoint is reached through, zero for a -// direct one. The setup guide holds an endpoint rather than a picker row. +// 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()) @@ -48,17 +49,16 @@ func (m *model) bridgeOf(ep config.Endpoint) config.Bridge { return config.Bridge{} } -// remove confirms before a removal that takes a device off a tailnet, and -// otherwise drops the records at once. Every delete in the TUI comes through -// here: the machine outlives settings, so a site that skips this leaves a -// device on the user's tailnet that nothing names any more. +// 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 { - destroys, err := bridges.DestroysMachine(m.g, bridge, ep) - if err != nil { + if err := bridges.CheckRemovable(m.g, bridge, ep); err != nil { return errResult(err.Error()) } - if !destroys { - if err := bridges.ForgetBridge(m.g, bridge, ep, nil); err != nil { + 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)} @@ -66,9 +66,9 @@ func (m *model) remove(bridge config.Bridge, ep config.Endpoint) menu.Result { return menu.Result{Next: m.removeBridgeMenu(bridge, ep)} } -// removeBridgeMenu is the confirmation. Removal is irreversible from here and -// takes a device off the user's tailnet, so the screen names the device by the -// name the admin console shows it under. +// 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 != "" { @@ -95,10 +95,10 @@ func (m *model) removeBridgeMenu(bridge config.Bridge, ep config.Endpoint) *menu } } -// destroyBridgeCmd puts the logout on the connect screen, which is where this -// program already shows slow bridge work and its log tail. The attempt 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 disagree. +// 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 @@ -126,56 +126,52 @@ func (m *model) destroyBridgeCmd(bridge config.Bridge, ep config.Endpoint) tea.C return tea.Batch(destroy, waitBridgeLog(ctx, ch), activationTick(act.id)) } -// bridgeRemoved shows the outcome. Whether the records go is the service's -// call; this only decides which screen says what happened. +// 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 := bridges.ForgetBridge(m.g, msg.bridge, msg.endpoint, msg.err) - var unconfirmed *bridges.Unconfirmed - var cmd tea.Cmd - switch { - case errors.As(err, &unconfirmed): - cmd = m.afterRemoval(msg.endpoint) - m.step = stepError - m.errMsg = m.unconfirmedMessage(unconfirmed) - case err != nil && errors.Is(err, msg.err): - m.step = stepError - m.errMsg = "Could not remove bridge " + msg.bridge.Name + ": " + err.Error() + - "\n\nThe connection is unchanged. Removing it again retries the logout." - case err != nil: + 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 = err.Error() - default: - cmd = m.afterRemoval(msg.endpoint) + m.errMsg = m.removalFailedMessage(msg.bridge, err) + return m, nil } if m.quitAfterRemoval { m.quitAfterRemoval = false return m, m.quitCmd() } - return m, cmd + return m, m.afterRemoval(msg.endpoint) } -// unconfirmedMessage is what the user needs to finish the job by hand: the -// device name, and where to look for it. A bare "timed out" leaves them -// hunting for a machine whose name this program chose. -func (m *model) unconfirmedMessage(u *bridges.Unconfirmed) string { - msg := "Bridge " + u.Bridge.Name + " was removed here, but the tailnet did not confirm within " + - u.Wait.String() + ".\n\nThe device " + bridges.MachineName(u.Bridge.ID) - if name := m.machines.Tailnet(u.Bridge); name != "" { - msg += " may still be on " + name +// 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 += " may still be registered" + msg += " is still registered" } - return msg + ". Delete it from the Tailscale admin console if it is." + return msg + " after that, delete it from the Tailscale admin console." } -// afterRemoval puts the user back on a list that no longer shows what they -// removed. A removal of the endpoint the failure screen is about leaves that -// screen with nothing to retry, so the root menu takes its place. +// 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() diff --git a/internal/tui/removal_test.go b/internal/tui/removal_test.go index ed54ad2..2ab0a2a 100644 --- a/internal/tui/removal_test.go +++ b/internal/tui/removal_test.go @@ -3,10 +3,10 @@ package tui import ( "context" "errors" + "fmt" "os" "strings" "testing" - "time" tea "github.com/charmbracelet/bubbletea" "github.com/tailscale/aperture-cli/internal/bridges" @@ -201,28 +201,65 @@ func TestBridgesMenuDeleteRefusesAReferencedBridge(t *testing.T) { } } -// Point 6 of ADR 0002: the wait is bounded, and what survives it is named. -func TestDestroyTimeoutRemovesLocallyAndNamesTheDevice(t *testing.T) { +// 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, b config.Bridge) error { - return &bridges.Unconfirmed{Bridge: b, Wait: 50 * time.Millisecond, Err: context.DeadlineExceeded} + 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 kept local records: %+v", m.g.Settings) + 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"} { + 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 From 7451db865299cc0af4c7c2fbc4a381fa36da67df Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Mon, 21 Sep 2026 19:11:39 +0000 Subject: [PATCH 64/69] bridges: keep a tailnet switch until its logout has succeeded Retry cleared switchTailnet unconditionally and Retarget never copied it, so a logout the control plane refused, or one cancelled by a typed URL, was followed by an attempt that opened the credentials still on disk and reconnected to the tailnet the user asked to leave. Run now records when LeaveTailnet succeeded and both Retry and Retarget carry the switch until then. Retry builds the new Attempt field by field because the flag is an atomic.Bool, written on Run's goroutine and read on the update loop. Verified becomes Gateway. Verified is a participle, not a thing; Gateway is the domain model's name for where a client sends requests, and the struct holds exactly that plus the tailnet and providers it came with. --- docs/specs/connection-contracts.md | 2 +- docs/specs/connection-domain-model.md | 28 ++--- internal/bridges/attempt.go | 159 +++++++++++++++----------- internal/bridges/attempt_test.go | 56 +++++++++ internal/tui/connection_test.go | 2 +- internal/tui/tui.go | 16 +-- 6 files changed, 171 insertions(+), 92 deletions(-) diff --git a/docs/specs/connection-contracts.md b/docs/specs/connection-contracts.md index b6cfa1c..d833410 100644 --- a/docs/specs/connection-contracts.md +++ b/docs/specs/connection-contracts.md @@ -119,7 +119,7 @@ not, and the gap is deliberate rather than unfinished: | `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 `Verified`; no event yet. | +| `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 diff --git a/docs/specs/connection-domain-model.md b/docs/specs/connection-domain-model.md index ba6bebc..267e47d 100644 --- a/docs/specs/connection-domain-model.md +++ b/docs/specs/connection-domain-model.md @@ -96,8 +96,8 @@ Attempt. - `Enter(Phase) Progress` — advance, appending to `Trail`. Rejects a backwards move. - `Authorize(LoginLink)` — record the link and enter `AwaitingAuthorization`. -- `Run(ctx, machines, emit) (Verified, 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, Verified) error` — persist a verified attempt: one settings write for the edit, the tailnet recorded on the Bridge, the Gateway and providers clients launch against. +- `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. @@ -212,20 +212,16 @@ upstream in `validPopBrowserURLLocked`; ours is the second gate, not the first. ## Gateway -Value object. Where a client sends requests. +Value object, `bridges.Gateway`. What a successful Attempt produced and what +`Commit` makes current. -| Field | Type | -|---|---| -| `URL` | `string` | -| `ViaBridge` | `bool` | - -Behaviors: `DirectGateway(Endpoint) Gateway`, `RoutedGateway(Route) Gateway`, -`String()`. +| 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: non-empty absolute URL with scheme and host. `ViaBridge` is true -if and only if the URL is a Route's local end. Nothing outside the Connection -context needs `ViaBridge`; it exists so a log or an error can say which of the -two a URL is, which `ApertureHost` cannot. +Invariants: `URL` is a non-empty absolute URL with scheme and host. ## Machine @@ -374,8 +370,8 @@ classDiagram +bool Ephemeral +Enter(Phase) Progress +Authorize(LoginLink) - +Run(ctx, machines, emit) Verified - +Commit(settings, Verified) + +Run(ctx, machines, emit) Gateway + +Commit(settings, Gateway) +Abandon(settings) +Slowest() Progress +Supersedes(ConnectionAttempt) bool diff --git a/internal/bridges/attempt.go b/internal/bridges/attempt.go index 29b5ada..c16af12 100644 --- a/internal/bridges/attempt.go +++ b/internal/bridges/attempt.go @@ -5,46 +5,53 @@ import ( "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 +// 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 that abandoning it can take that back -// out, and which Endpoint it is an edit of, so that committing can replace -// the original in the same write (ADR 0003). +// 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. Everything else reads or writes settings and runs where settings -// are read, which for the TUI is its update loop: nothing else serializes -// access to config.Global. +// 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 reports that starting this attempt leaves the active - // destination unverified: the Machine it launches through is being logged - // out, and cancellation cannot prove the logout did not run (ADR 0003). + // 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 reports that this attempt is at the active Endpoint, so + // 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: BeginAttempt wrote Endpoint into settings so the failure - // screen has something to name, retry and edit. Abandon removes it; - // failure keeps it. + // 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; 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: an abandoned login would -// otherwise leave the picker naming a tailnet the bridge has already left. +// 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") @@ -74,9 +81,10 @@ func BeginAttempt(g *config.Global, ep config.Endpoint, switchTailnet bool, repl return a, nil } -// EditAttempt verifies next before removing ep, keeping ep until it does -// (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. +// 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) @@ -84,10 +92,11 @@ func EditAttempt(g *config.Global, current *Attempt, ep, next config.Endpoint) ( return BeginAttempt(g, next, false, ep) } -// Retarget swaps the Endpoint this attempt probes for one 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. The same Endpoint again is a retry. +// 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 @@ -110,21 +119,37 @@ func (a *Attempt) Retarget(g *config.Global, next config.Endpoint) (*Attempt, er 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 is the same attempt again. A tailnet switch is not repeated: it ran, -// or failed, the first time, and the retry is about reaching the Endpoint. +// 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 { - next := *a - next.switchTailnet = false - next.InvalidatesActive = false - return &next + 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 is the Bridge this attempt connects through, zero for a direct -// Endpoint. +// 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 @@ -134,66 +159,67 @@ 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 } -// Verified is what a successful attempt produced: the Gateway a client sends -// requests to, the providers it answered with and, through a Bridge, the -// tailnet the Machine joined. -type Verified struct { - Gateway string +// 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 verified Gateway or an error, reporting each -// wait on emit. It writes nothing: Commit does, once the caller knows the -// result is still wanted. -func (a *Attempt) Run(ctx context.Context, machines *Machines, emit func(connection.Event)) (Verified, error) { +// 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 Verified{}, err + return Gateway{}, err } - return Verified{Gateway: a.Endpoint.URL(), Providers: provs}, nil + return Gateway{URL: a.Endpoint.URL(), Providers: provs}, nil } - // Stamps the moment the user committed. Without it the first bridge line + // 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 Verified{}, err + return Gateway{}, err } - // The switch shares the attempt's cancellation and event sink: the new + // 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 Verified{}, err + return Gateway{}, err } + a.tailnetLeft.Store(true) } if err := mc.Open(ctx, emit); err != nil { - return Verified{}, err + return Gateway{}, err } route, err := mc.RouteTo(ctx, bridged.URL(), emit) if err != nil { - return Verified{}, err + return Gateway{}, err } - // The longest silent stretch of the attempt: the bridge is up, so tsnet + // 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 Verified{}, fmt.Errorf("bridge %s could not reach %s: %w", a.bridge.Name, bridged.URL(), err) + return Gateway{}, fmt.Errorf("bridge %s could not reach %s: %w", a.bridge.Name, bridged.URL(), err) } - return Verified{Gateway: route.LocalURL, Tailnet: mc.Tailnet(), Providers: provs}, nil + return Gateway{URL: route.LocalURL, Tailnet: mc.Tailnet(), Providers: provs}, nil } -// Commit makes a verified 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 and providers -// become what clients launch against. -func (a *Attempt) Commit(g *config.Global, v Verified) error { +// 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) @@ -201,20 +227,21 @@ func (a *Attempt) Commit(g *config.Global, v Verified) error { } a.replaces = nil a.ephemeral = false - if a.bridge.ID != "" && v.Tailnet != "" { + if a.bridge.ID != "" && gw.Tailnet != "" { // A failed write is not worth interrupting a connection that worked. - if err := g.SetBridgeTailnet(a.bridge.ID, v.Tailnet); err != nil { + 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 = v.Gateway - g.Providers = v.Providers + g.ApertureHost = gw.URL + g.Providers = gw.Providers return nil } -// Abandon is the user giving up on the attempt. The candidate it added comes -// back out of settings, so nothing the user did not choose is left behind. A -// failed attempt is not abandoned: its candidate stays for retry and edit. +// 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 diff --git a/internal/bridges/attempt_test.go b/internal/bridges/attempt_test.go index 8a850da..6344514 100644 --- a/internal/bridges/attempt_test.go +++ b/internal/bridges/attempt_test.go @@ -1,6 +1,8 @@ package bridges import ( + "context" + "errors" "os" "path/filepath" "testing" @@ -53,3 +55,57 @@ func TestAbandonStaysEphemeralWhenTheDropFails(t *testing.T) { 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/tui/connection_test.go b/internal/tui/connection_test.go index 6d103cf..2327b9e 100644 --- a/internal/tui/connection_test.go +++ b/internal/tui/connection_test.go @@ -36,7 +36,7 @@ func TestEndpointEditPreservesVerifiedConnection(t *testing.T) { } else { m.Update(tea.KeyMsg{Type: tea.KeyEsc}) // The cancelled request can still deliver a success already queued. - m.Update(endpointActivationResult{id: m.activationSeq, verified: bridges.Verified{Gateway: srv.URL}}) + 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) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index a31b550..cedfc3d 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -232,9 +232,9 @@ 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 - verified bridges.Verified - err error + id int + gateway bridges.Gateway + err error } // bridgeLine is one thing the attempt reported and how far into the attempt it @@ -372,8 +372,8 @@ func (m *model) startAttempt(a *bridges.Attempt) tea.Cmd { if _, bridged := a.Endpoint.(config.BridgeEndpoint); !bridged { run := func() tea.Msg { defer cancel() - v, err := a.Run(ctx, machines, nil) - return endpointActivationResult{id: act.id, verified: v, err: err} + gw, err := a.Run(ctx, machines, nil) + return endpointActivationResult{id: act.id, gateway: gw, err: err} } return tea.Batch(run, activationTick(act.id)) } @@ -388,8 +388,8 @@ func (m *model) startAttempt(a *bridges.Attempt) tea.Cmd { emit := bridgeLogSink(ctx, ch, act.started) run := func() tea.Msg { defer cancel() - v, err := a.Run(ctx, machines, emit) - return endpointActivationResult{id: act.id, verified: v, err: err} + gw, err := a.Run(ctx, machines, emit) + return endpointActivationResult{id: act.id, gateway: gw, err: err} } return tea.Batch(run, waitBridgeLog(ctx, ch), activationTick(act.id)) } @@ -590,7 +590,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.resetStack(m.setupGuideMenu()) return m, nil } - if err := a.Commit(m.g, msg.verified); err != nil { + if err := a.Commit(m.g, msg.gateway); err != nil { m.preflightErr = err.Error() m.forcedToEndpoint = true m.failedEndpoint = a.Endpoint From 452d2d2195c017f79b6588f893bcd3cb95fb8667 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Mon, 21 Sep 2026 19:11:39 +0000 Subject: [PATCH 65/69] tui: quit on a failed shutdown instead of showing an error the user cannot leave Machines.Close memoizes its result, so the error screen's q called Close again, got the same error and never quit. The TUI quits regardless and lets main call Close, which returns the memoized error and reports it on stderr with exit 1 once the terminal is back. --- internal/tui/tui.go | 9 ++++++--- internal/tui/tui_test.go | 17 ++++++++++++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index cedfc3d..b99a044 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -9,6 +9,7 @@ package tui import ( "context" "fmt" + "log/slog" "strings" "time" "unicode" @@ -668,10 +669,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index d38f103..5eb2b5e 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -879,7 +879,7 @@ func TestEndpointsMenu_EditRetargetsWorkingEndpoint(t *testing.T) { 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, verified: bridges.Verified{Gateway: "http://127.0.0.1:12345"}}) + 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) } @@ -1726,3 +1726,18 @@ func TestRemoveConnectionRowKeepsASharedBridge(t *testing.T) { } } } + +// 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()) + } +} From 5a434e0ac86619282abc485adba81ee9e7b5af30 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Mon, 21 Sep 2026 19:12:41 +0000 Subject: [PATCH 66/69] bridges, connection: say what each thing does in its doc comment Review on PR 41 read liveEvents, events and Event as glued clauses. Each sentence now names its subject and gives it a finite verb. liveEvents is eventRelay and use is forwardTo, which is what it does. --- internal/bridges/events.go | 62 +++++++++++++++------------ internal/bridges/machine.go | 10 ++--- internal/connection/event.go | 83 +++++++++++++++++++----------------- 3 files changed, 82 insertions(+), 73 deletions(-) diff --git a/internal/bridges/events.go b/internal/bridges/events.go index 6518e64..789a5f5 100644 --- a/internal/bridges/events.go +++ b/internal/bridges/events.go @@ -9,40 +9,45 @@ import ( "github.com/tailscale/aperture-cli/internal/connection" ) -// liveEvents points a node's long-lived reporting at whichever connection is -// using it now. Nodes and proxies outlive the connection that built them, and -// closures that captured that connection's sink went on writing to a channel +// 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 it when a connection ends: a finished sink discards what it is -// given, and a clear needs a lifecycle hook only the Attempt can own. -type liveEvents struct { +// 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 - ev events + to events } -func (l *liveEvents) use(ev events) { - l.mu.Lock() - defer l.mu.Unlock() - l.ev = ev +// 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 callers keep note and notef. -func (l *liveEvents) emit(e connection.Event) { - l.mu.Lock() - ev := l.ev - l.mu.Unlock() +// 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 is where a bridge reports what it is doing. This package translates -// the tailnet's vocabulary into it and publishes nothing else, so no caller has -// to recover meaning by matching prose from inside a vendored package. +// 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 returns a usable events, so callers that want none can pass nil. +// 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) @@ -52,9 +57,9 @@ func sink(emit func(connection.Event)) events { } } -// logEvent copies a connection event into 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 are debug: under -debug they carry tsnet's +// 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 { @@ -67,8 +72,9 @@ func logEvent(e connection.Event) { } } -// Backend diagnostics can repeat authorization capabilities. Keep the link in -// the interactive event only; even debug logs are routinely shared for support. +// 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 { @@ -80,9 +86,9 @@ func (e events) notef(format string, args ...any) { e(connection.Notef(fo func (e events) enter(p connection.Phase) { e(connection.Entered(p)) } func (e events) loginRequired(link connection.LoginLink) { e(connection.LoginRequired(link)) } -// redactURL is the part of an endpoint URL safe for the run log: scheme and -// host. ParseEndpointURL accepts userinfo and a query, and a run log is the -// file people share when asking for help. +// 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 == "" { diff --git a/internal/bridges/machine.go b/internal/bridges/machine.go index 1cadab4..a201d68 100644 --- a/internal/bridges/machine.go +++ b/internal/bridges/machine.go @@ -45,7 +45,7 @@ type Machine struct { // node: idle after a logout, or never started. node tailnetNode routes map[string]*Route - ev *liveEvents + ev *eventRelay } func newMachine(bridge config.Bridge, ms *Machines) *Machine { @@ -54,7 +54,7 @@ func newMachine(bridge config.Bridge, ms *Machines) *Machine { of: ms, turn: make(chan struct{}, 1), routes: make(map[string]*Route), - ev: &liveEvents{}, + ev: &eventRelay{}, } } @@ -143,7 +143,7 @@ func (mc *Machine) Open(ctx context.Context, emit func(connection.Event)) error } defer mc.end() if mc.node != nil { - mc.ev.use(ev) + mc.ev.forwardTo(ev) return nil } if err := mc.initNode(ev); err != nil { @@ -192,7 +192,7 @@ func (mc *Machine) RouteTo(ctx context.Context, remoteURL string, emit func(conn if mc.node == nil { return nil, fmt.Errorf("bridge %s is not open", mc.bridge.Name) } - mc.ev.use(ev) + mc.ev.forwardTo(ev) // Reported here for a reused Machine too, which would otherwise say // nothing while the first dial waits for the target to appear in its @@ -339,7 +339,7 @@ func (mc *Machine) Close() error { // initNode constructs a node without waiting for login. Called with the turn // held; only Open follows it with BringUp. func (mc *Machine) initNode(ev events) error { - mc.ev.use(ev) + mc.ev.forwardTo(ev) if mc.node != nil { return nil } diff --git a/internal/connection/event.go b/internal/connection/event.go index 2ed6b58..9f2b522 100644 --- a/internal/connection/event.go +++ b/internal/connection/event.go @@ -1,12 +1,12 @@ -// Package connection is the vocabulary a Machine reports in while a connection -// attempt uses it: the wait it is in, the login link it needs visited, and the -// odd line for the user that is neither. 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 that produces most -// of it. +// 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. Translating the tailnet's -// vocabulary into this one is internal/bridges' job. +// Nothing here may reference tsnet, ipn or ipnstate. internal/bridges +// translates the tailnet's vocabulary into this one. package connection import ( @@ -15,18 +15,18 @@ import ( "strings" ) -// Phase is what an attempt is waiting on, named for what the user is waiting -// for rather than for the backend state underneath it. The zero value is no -// phase. +// 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". +// 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 is load -// bearing: a phase only ever moves forward, and consumers compare them. +// 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 @@ -45,8 +45,8 @@ var phaseNames = [...]string{ AskingForModels: "AskingForModels", } -// String is the phase's name, for logs and errors. What the user reads for it -// is the presentation layer's. +// 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] @@ -54,17 +54,17 @@ func (p Phase) String() string { return fmt.Sprintf("Phase(%d)", int(p)) } -// LoginLink is the URL that authorizes a machine on a tailnet. +// 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 a login link and is the only way to make one. The +// 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 not a link we were asked to follow. -// Tailscale applies the same rules in validPopBrowserURLLocked. +// 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 == "" { @@ -86,41 +86,44 @@ func ParseLoginLink(raw string) (LoginLink, error) { return LoginLink{url: raw}, nil } -// Event is one thing a Machine reports while an attempt uses it. Exactly one -// field is set, and which one says what happened: the attempt entered Phase, -// the Machine needs authorizing at Link, or Note is a line for the user with -// no domain meaning. It replaced a sink of plain strings that had the TUI -// opening a browser on a phrase from inside a vendored package, where a -// reworded log line silently stranded the user. +// 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 is a line for the user that is neither a phase nor a link: tsnet -// backend chatter, dial detail, a health warning. The only event a consumer -// may drop. +// 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 is Note, formatted. +// Notef returns Note with the text formatted. func Notef(format string, args ...any) Event { return Note(fmt.Sprintf(format, args...)) } -// Entered is the attempt now waiting on p. +// Entered returns the Event for an attempt entering p. func Entered(p Phase) Event { return Event{Phase: p} } -// LoginRequired is the Machine needing authorization at link. +// 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 go: 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. +// 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 is the event's representation, for logs and test failures: the -// phase's name, the link marked as one, or the note's text. +// 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: From 12573bf6942afb8ddef941807de29e42b715ebdb Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Mon, 21 Sep 2026 19:17:10 +0000 Subject: [PATCH 67/69] bridges: plain doc comments, and names that say what they are Guy's review of PR 41 read the bridges comments as clauses glued together. Every sentence now names its subject and gives it a finite verb. Machine.of is Machine.machines (a preposition is not a name), notifier is ipnBusWatch (the thing bringUp reads, not a role) and loginReporter is bringUpProgress (the state it holds). fetchProviders no longer shadows the url package. --- internal/bridges/fetch.go | 17 +-- internal/bridges/machine.go | 174 +++++++++++++++--------------- internal/bridges/machine_test.go | 12 +-- internal/bridges/machines.go | 18 ++-- internal/bridges/node.go | 112 ++++++++++--------- internal/bridges/route.go | 60 +++++------ internal/bridges/security_test.go | 4 +- 7 files changed, 206 insertions(+), 191 deletions(-) diff --git a/internal/bridges/fetch.go b/internal/bridges/fetch.go index 4b03e5d..677c8f9 100644 --- a/internal/bridges/fetch.go +++ b/internal/bridges/fetch.go @@ -17,18 +17,19 @@ const ( bridgeProviderFetchTimeout = 30 * time.Second ) -// fetchProviders asks an Aperture what it serves. This is the attempt's -// AskingForModels phase and the verification everything else waits on. +// 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} - // On the path, not the string: ParseEndpointURL accepts a query, and - // appending to "host?token=x" put the models path inside the query. + // 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 } - url := base.JoinPath("v1", "models").String() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + target := base.JoinPath("v1", "models").String() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) if err != nil { return nil, err } @@ -44,9 +45,9 @@ func fetchProviders(ctx context.Context, host string, timeout time.Duration) ([] 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) + 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, url) + return nil, fmt.Errorf("unexpected status %d from %s", resp.StatusCode, target) } body, err := io.ReadAll(resp.Body) if err != nil { diff --git a/internal/bridges/machine.go b/internal/bridges/machine.go index a201d68..79b82ba 100644 --- a/internal/bridges/machine.go +++ b/internal/bridges/machine.go @@ -15,34 +15,35 @@ import ( "github.com/tailscale/aperture-cli/internal/connection" ) -// Machine is what 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. It outlives any one connection attempt -// and is the aggregate root for its Routes. +// 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. // -// 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 a first one is still closing. An -// operation waiting its turn can be cancelled through its context without -// disturbing the one running; Close cancels the one running. +// 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 - // of is the collection this Machine belongs to, which holds the node - // factory and dial tuning shared by every member. - of *Machines + // 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. A one-slot channel - // rather than a mutex so that waiting for it can be cancelled. + // 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 what another goroutine reads or sets while an operation holds - // the turn: the running operation's cancel, closed and tailnet. + // 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 - // Owned by whoever holds the turn. A Machine in the collection may have no - // node: idle after a logout, or never started. + // 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 @@ -50,23 +51,24 @@ type Machine struct { func newMachine(bridge config.Bridge, ms *Machines) *Machine { return &Machine{ - bridge: bridge, - of: ms, - turn: make(chan struct{}, 1), - routes: make(map[string]*Route), - ev: &eventRelay{}, + bridge: bridge, + machines: ms, + turn: make(chan struct{}, 1), + routes: make(map[string]*Route), + ev: &eventRelay{}, } } -// MachineName is the hostname this bridge's node registers under, and so the -// device name the tailnet shows. Removal has to name the same thing the admin -// console does, or a user told to go delete it by hand cannot find it. +// 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 this bridge ever started a node. tsnet creates -// the state directory on first use, so its absence is the only durable -// evidence that nothing was ever registered: Bridge.Tailnet is a display hint, -// saved after verification and cleared before a switch. +// 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 { @@ -76,8 +78,8 @@ func HasMachine(bridgeID string) bool { return !errors.Is(err, fs.ErrNotExist) } -// begin takes the Machine for one operation and returns the context it runs -// under, which Close can cancel. +// 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 @@ -117,8 +119,8 @@ func (mc *Machine) end() { <-mc.turn } -// Tailnet is the network the Machine joined, empty until it has or after it -// left. +// 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() @@ -132,9 +134,10 @@ func (mc *Machine) setTailnet(name string) { } // Open brings the node up, logging in if it has to, and reports each wait on -// emit. An open Machine returns at once with its reporting pointed 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. +// 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) @@ -152,13 +155,13 @@ func (mc *Machine) Open(ctx context.Context, emit func(connection.Event)) error ev.enter(connection.StartingMachine) - // BringUp blocks until the node is Running, which for a bridge that has - // never logged in means blocking until the user visits a link nothing has - // shown them yet. It reports the wait off the watch it is waiting on. + // 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. // - // Timed because this is the wait every "it just sat there" report is - // about, and the number is the difference between a slow control plane and - // a login link the user never saw. + // 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 { @@ -167,17 +170,18 @@ func (mc *Machine) Open(ctx context.Context, emit func(connection.Event)) error } slog.Info("bridge node up", "bridge", mc.bridge.ID, "after", time.Since(start)) - // The login status names the tailnet this bridge reaches at no extra - // call. The connection picker shows it on rows not connected to yet. + // 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 opens, or returns, the Route to remoteURL through this Machine. The -// Machine must be open: a Route can only be created through an open Machine, -// and this never starts a node to satisfy one. +// 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 { @@ -194,11 +198,11 @@ func (mc *Machine) RouteTo(ctx context.Context, remoteURL string, emit func(conn } mc.ev.forwardTo(ev) - // Reported here for a reused Machine too, which would otherwise say - // nothing while the first dial waits for the target to appear in its - // peer map. + // 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.of.debug { + 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. @@ -225,10 +229,10 @@ func (mc *Machine) RouteTo(ctx context.Context, remoteURL string, emit func(conn return route, nil } -// LeaveTailnet logs the Machine out of the tailnet it is on 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. +// 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) @@ -251,22 +255,22 @@ func (mc *Machine) LeaveTailnet(ctx context.Context, emit func(connection.Event) return nil } -// Destroy removes the Machine from its tailnet and discards the state -// directory holding its login. It touches no settings: the Bridge record is -// the only thing naming the device, so the caller drops it after this returns -// nil and keeps it otherwise (ADR 0002). +// 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 is what would demand the interactive login being removed. +// 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 is what a later attempt would need to deregister the device. +// The state directory goes last and only on success. It holds the node key, +// which a later attempt needs to deregister the device. // -// 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. +// 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 { @@ -297,7 +301,7 @@ func (mc *Machine) Destroy(ctx context.Context, emit func(connection.Event)) err } } -// destroyHeld is Destroy's work, run with the Machine held. +// 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("") @@ -321,9 +325,10 @@ func (mc *Machine) destroyHeld(ctx context.Context, stateDir string, ev events) return nil } -// Close ends the Machine for the process: it interrupts the operation running, -// waits for it to finish cleaning up, closes the node and every Route, and -// refuses further operations. Safe to call more than once. +// 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 @@ -336,39 +341,40 @@ func (mc *Machine) Close() error { return mc.shutdownNode() } -// initNode constructs a node without waiting for login. Called with the turn -// held; only Open follows it with BringUp. +// 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.of.newNode == 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. A no-op rather than nil, - // because tsnet falls back to log.Printf, which writes over the TUI. + // 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.of.debug { + if mc.machines.debug { events(mc.ev.emit).notef(format, args...) } } - mc.node = mc.of.newNode(mc.bridge, stateDir, logNotes, logNotes) + 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. -// Called with the turn held: it must finish before a new node can open the -// same state directory. +// 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 { diff --git a/internal/bridges/machine_test.go b/internal/bridges/machine_test.go index 148857a..2fbd110 100644 --- a/internal/bridges/machine_test.go +++ b/internal/bridges/machine_test.go @@ -805,7 +805,7 @@ func browse(u string) *ipn.Notify { return &ipn.Notify{BrowseToURL: &u} } func TestLoginReporterSplitsTheTwoNeedsLoginWaits(t *testing.T) { const url = "https://login.tailscale.com/a/28ba393017981" var got []string - r := &loginReporter{ev: collect(&got)} + r := &bringUpProgress{ev: collect(&got)} r.notify(state(ipn.NeedsLogin)) r.notify(state(ipn.NeedsLogin)) // the bus repeats itself @@ -830,7 +830,7 @@ func TestLoginReporterSplitsTheTwoNeedsLoginWaits(t *testing.T) { func TestLoginReporterRejectsAnUnusableLink(t *testing.T) { var got []string - r := &loginReporter{ev: collect(&got)} + 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. @@ -850,7 +850,7 @@ func TestLoginReporterRejectsAnUnusableLink(t *testing.T) { // name. Untranslated it emits nothing and the screen goes silent. func TestLoginReporterNamesTheWaitBeforeTheControlPlaneAnswers(t *testing.T) { var lines []string - reporter := loginReporter{ev: collect(&lines)} + 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 @@ -969,7 +969,7 @@ func TestNotifyLogsWhatTheScreenCollapses(t *testing.T) { t.Cleanup(func() { slog.SetDefault(orig) }) var lines []string - r := &loginReporter{ev: collect(&lines)} + r := &bringUpProgress{ev: collect(&lines)} r.notify(state(ipn.NoState)) r.notify(state(ipn.NeedsLogin)) r.notify(browse("http://evil.example.com/a/x")) @@ -1009,7 +1009,7 @@ func TestLoginReporterReportsALoginThatIsFailing(t *testing.T) { const text = "You are logged out. The last login error was: register request: http 502" var lines []string - r := &loginReporter{ev: collect(&lines)} + r := &bringUpProgress{ev: collect(&lines)} r.notify(state(ipn.NeedsLogin)) r.notify(unhealthyLogin(text)) @@ -1039,7 +1039,7 @@ func TestLoginReporterReportsALoginThatIsFailing(t *testing.T) { // this attempt's business. func TestLoginReporterIgnoresWarningsThatAreNotTheLogin(t *testing.T) { var lines []string - r := &loginReporter{ev: collect(&lines)} + 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"}, diff --git a/internal/bridges/machines.go b/internal/bridges/machines.go index 032bf3a..5e76adf 100644 --- a/internal/bridges/machines.go +++ b/internal/bridges/machines.go @@ -18,10 +18,10 @@ const ( bridgePeerWaitInterval = 250 * time.Millisecond ) -// Machines is the process's Machines, one per Bridge, and 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. +// 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 @@ -37,7 +37,7 @@ type Machines struct { } // NewMachines returns an empty collection. When debug is true, verbose tsnet -// backend logs are also reported to whichever attempt is using a Machine. +// backend logs are also reported to the attempt using a Machine. func NewMachines(debug bool) *Machines { ms := &Machines{ byBridge: make(map[string]*Machine), @@ -71,8 +71,8 @@ func (ms *Machines) For(bridge config.Bridge) (*Machine, error) { return mc, nil } -// lookup is For without the creation, for callers that only want to read a -// Machine that already exists. +// 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 @@ -104,8 +104,8 @@ func (ms *Machines) close() error { 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 +// 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-") diff --git a/internal/bridges/node.go b/internal/bridges/node.go index 7b4b159..c51b1da 100644 --- a/internal/bridges/node.go +++ b/internal/bridges/node.go @@ -27,24 +27,24 @@ type tsnetNode struct { server *tsnet.Server } -// BringUp waits for the node to be usable and reports what it is waiting on, -// off 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, which arrives as a terminal "IPN bus consumer fell behind" on a login +// 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. // -// Taking the wait means taking what Up did with it: a terminal ErrMessage, and -// the check that a Running node actually has an address. resetServeStateOnce -// is not ours to keep; nothing here sets a serve config. +// 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 too: health changes reach every watcher regardless of - // mask, but a login already broken before this watch started shows up only - // in the initial one, which is the reused node case. + // 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 @@ -65,18 +65,19 @@ func (n *tsnetNode) DialContext(ctx context.Context, network, address string) (n return n.server.Dial(ctx, network, address) } -// notifier is the part of an IPN bus watch the bring-up reads, so the loop can -// be exercised against a recorded bus. -type notifier interface { +// 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, naming each wait as it is entered. -// The link comes off the bus rather than tsnet's five second poll loop, which -// 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 notifier, statusOf func(context.Context) (*ipnstate.Status, error), ev events) (*ipnstate.Status, error) { - reporter := loginReporter{ev: ev} +// 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 { @@ -85,7 +86,7 @@ func bringUp(ctx context.Context, w notifier, statusOf func(context.Context) (*i if notify.ErrMessage != nil { return nil, fmt.Errorf("bridge backend: %s", *notify.ErrMessage) } - reporter.notify(¬ify) + progress.notify(¬ify) if notify.State == nil || *notify.State != ipn.Running { continue } @@ -100,23 +101,25 @@ func bringUp(ctx context.Context, w notifier, statusOf func(context.Context) (*i } } -// loginReporter turns IPN bus notifications into the phases a connection -// attempt reports, holding the last one because the bus repeats states. +// 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 the control plane has not answered and there is nothing to do, -// after it everything is waiting on the user. Reporting the backend state made -// a 29 second registration indistinguishable from someone who wandered off. -type loginReporter struct { +// 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 is 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 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 *loginReporter) enter(p connection.Phase) { +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 { @@ -126,26 +129,29 @@ func (r *loginReporter) enter(p connection.Phase) { r.ev.enter(p) } -func (r *loginReporter) notify(n *ipn.Notify) { +func (r *bringUpProgress) notify(n *ipn.Notify) { if n == nil { return } if n.State != nil { - // The raw state, not just the phase: NoState and NeedsLogin are one - // phase on screen on purpose and the whole question in a log. NoState - // means control has not answered the register yet. + // 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, and NoState is the one that matters: a bridge that never - // logged in sits there for the whole of POST /machine/register, so - // it is the wait and not a not-started-yet. Tailscale's own comment - // reads "UIs should print Loading..." (ipnlocal/local.go). + // 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: - // 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. + // 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) @@ -156,10 +162,10 @@ func (r *loginReporter) notify(n *ipn.Notify) { if n.BrowseToURL != nil { link, err := connection.ParseLoginLink(*n.BrowseToURL) if err != nil { - // Record the rejection reason, never the authorization capability. + // Log the rejection reason, never the link itself. slog.Error("unusable login link from the control plane", "err", err) - // Not fatal to the login: tsnet keeps printing its own copy, and - // the user can still finish by hand. Worth saying, because the + // 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 @@ -171,13 +177,14 @@ func (r *loginReporter) notify(n *ipn.Notify) { } // health reports a login that is failing rather than merely slow. A register -// answered with a 502 leaves the node in NeedsLogin sending no BrowseToURL, so +// 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. +// backoff. The error is not a vizerror, so it never reaches Notify.ErrMessage. // -// login-state only. The other warnables describe a node that is up and -// imperfect, and would bury the one line that is this attempt's business. -func (r *loginReporter) health(state *health.State) { +// 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 } @@ -208,8 +215,9 @@ func (n *tsnetNode) Close() error { return n.server.Close() } -// newTSNetNode is how a Machine gets its node in production: a tsnet.Server on -// the bridge's state directory, named the way the admin console will show it. +// 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{ diff --git a/internal/bridges/route.go b/internal/bridges/route.go index 749e07c..aa65f72 100644 --- a/internal/bridges/route.go +++ b/internal/bridges/route.go @@ -15,18 +15,17 @@ import ( "tailscale.com/ipn/ipnstate" ) -// Route is the local door to one Endpoint through one Machine: a loopback -// listener reverse-proxying over the Machine's node. LocalURL is the Gateway a -// client is told to use. A Route belongs to exactly one Machine and closes -// with it. +// 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. Already closed is not a failure: Close -// and LeaveTailnet can both reach the same Route. +// 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) { @@ -53,13 +52,13 @@ func parseTarget(raw string) (*url.URL, error) { return target, nil } -// openRoute builds the reverse proxy for one target on the Machine's node. It -// reports through the Machine because the Route is cached and will still be -// serving long after the connection that asked for it has gone. Called with -// the Machine's turn held. +// 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.of.debug + debug := mc.machines.debug ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return nil, err @@ -77,8 +76,8 @@ func (mc *Machine) openRoute(target *url.URL) (*Route, error) { network, address, ev, - mc.of.peerWait, - mc.of.peerWaitInterval, + mc.machines.peerWait, + mc.machines.peerWaitInterval, ) elapsed := time.Since(start).Round(time.Millisecond) if err != nil { @@ -117,14 +116,14 @@ func (mc *Machine) openRoute(target *url.URL) (*Route, error) { type bridgeDialFunc func(context.Context, string, string) (net.Conn, error) -// dialViaNode dials address over the bridge's node, resolving a name against -// the node's own peer map first and dialing the IP it finds. +// 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 is what made a first connection hang for 30s: until -// the netmap lands its resolver falls through to the host resolver, which on a -// machine already on a tailnet answers with a same-named node on the wrong one. -// Short aliases use this node's current tailnet suffix; a shared peer requires -// its full name. +// 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, @@ -149,13 +148,13 @@ func dialViaNode( // 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. + // 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, - // which resolve only the way tsnet resolves, so fall through and say - // so, since this path can leave the tailnet. + // 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 @@ -165,9 +164,9 @@ func dialViaNode( 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. +// 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, @@ -206,9 +205,10 @@ func waitForPeerAddr( } } -// peerAddr resolves short names only within the current tailnet's MagicDNS -// suffix. A shared-in peer can have the same first label but belongs to another -// tailnet; reaching it requires its explicit full name. +// 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 diff --git a/internal/bridges/security_test.go b/internal/bridges/security_test.go index bdfb02d..f26ce7d 100644 --- a/internal/bridges/security_test.go +++ b/internal/bridges/security_test.go @@ -144,10 +144,10 @@ func TestRunLogOmitsLoginCapabilities(t *testing.T) { t.Fatal("interactive consumer lost the authorization URL") } case "rejected link": - r := loginReporter{ev: sink(nil)} + r := bringUpProgress{ev: sink(nil)} r.notify(browse("http://login.tailscale.com/a/" + secret)) case "health warning": - r := loginReporter{ev: sink(nil)} + r := bringUpProgress{ev: sink(nil)} r.notify(unhealthyLogin("request failed: " + authURL)) case "backend and startup error": m := NewMachines(true) From e08ebb8d742fe84b86c86d68d16aedc51a41c213 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Mon, 21 Sep 2026 19:18:33 +0000 Subject: [PATCH 68/69] tui: plain doc comments, and predicates that start with a verb Same review as the bridges pass. cancelable, removing, overridable and important are canCancel, isRemoving, canOverride and isImportant. --- internal/tui/browser.go | 14 ++-- internal/tui/menus.go | 30 +++---- internal/tui/tui.go | 164 ++++++++++++++++++++------------------- internal/tui/tui_test.go | 4 +- 4 files changed, 109 insertions(+), 103 deletions(-) diff --git a/internal/tui/browser.go b/internal/tui/browser.go index c28e829..892787c 100644 --- a/internal/tui/browser.go +++ b/internal/tui/browser.go @@ -10,18 +10,18 @@ import ( // openURL asks the desktop to open a link. Overridable in tests. var openURL = platformOpenURL -// copyToClipboard puts s on the clipboard of whatever terminal is displaying -// this TUI, over OSC 52. A local helper (xclip, pbcopy) writes to the clipboard -// of the host aperture runs on, which over SSH is the wrong computer and the -// case where the user most needs the link. Overridable in tests. +// 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, so the - // passthrough wrapping is what gets this to the outer terminal. tmux sets - // TERM to a screen-* value of its own, so it has to be checked first. + // 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() diff --git a/internal/tui/menus.go b/internal/tui/menus.go index bdba330..5368322 100644 --- a/internal/tui/menus.go +++ b/internal/tui/menus.go @@ -237,10 +237,10 @@ func (m *model) bridgeRowDescription(bridge config.Bridge) string { return bridge.ID } -// endpointsMenu is the connection picker: every Aperture this launcher can -// reach, one row each, saved endpoint or 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. +// 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 { rows := m.connectionRows() items := make([]menu.MenuItem, 0, len(rows)+4) @@ -311,9 +311,9 @@ func (m *model) endpointsMenu() *menu.Menu { } } -// connectionRow is one line on the connection picker. A bridge nothing points -// at yet is a row too, described by the endpoint it would create: that is how a -// second tailnet gets reached the first time. +// 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 @@ -382,9 +382,9 @@ func (m *model) connectionDescription(row connectionRow) string { return "tailnet not known yet" } -// connectionMenu is 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. +// 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 { @@ -842,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/tui.go b/internal/tui/tui.go index b99a044..252f12e 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1,9 +1,9 @@ // 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 ( @@ -40,9 +40,9 @@ var ( errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) dimStyle = lipgloss.NewStyle().Faint(true) greenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) - // authStyle is the login link at the foot of the connect screen: the - // palette's bright green on a dark terminal, its plain green on a light - // one, where bright green is unreadable. + // 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("●") @@ -100,16 +100,18 @@ type model struct { bridgeLogs []bridgeLine failedEndpoint config.Endpoint connected bool - // quitAfterRemoval is Ctrl+C pressed while a removal was on the tailnet. + // 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 } -// activation is the connection attempt currently 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. +// 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 attempts that cannot be interrupted (the post-launch -// re-check), which is what makes Esc and the inline override inert there. +// 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 @@ -120,13 +122,13 @@ type activation struct { cancel context.CancelFunc logCh chan bridgeLine logCtx context.Context - // phase is the wait this attempt is in, and phaseSet distinguishes "not - // started" from StartingMachine, which is the zero value. + // 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 surfaced for this attempt. - // The control plane can re-send it on the bus, so this is what keeps the - // browser from being opened again on each repeat. + // 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 @@ -155,8 +157,8 @@ func (a *activation) entered(p connection.Phase) bool { return true } -// endpoint is the Endpoint the attempt on screen is trying, zero when the -// screen is showing something else. +// 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 @@ -164,18 +166,18 @@ func (a *activation) endpoint() config.Endpoint { return a.attempt.Endpoint } -// cancelable reports whether Esc can interrupt this attempt. -func (a *activation) cancelable() bool { return a != nil && a.cancel != nil } +// 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) removing() bool { return a != nil && a.attempt == nil && a.logCh != nil } +func (a *activation) isRemoving() bool { return a != nil && a.attempt == nil && a.logCh != nil } -// overridable reports whether the attempt accepts a typed URL in place of the +// 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) overridable() bool { +func (a *activation) canOverride() bool { _, bridged := a.endpoint().(config.BridgeEndpoint) - return a.cancelable() && bridged + return a.canCancel() && bridged } // textField is the shared single-line editor behind the add-endpoint input @@ -216,9 +218,9 @@ func (f *textField) backspace() { func (f *textField) reset() { *f = textField{} } -// Init opens on m.start. connectVia because an endpoint off the command line -// may not be in settings yet, and it writes it there for the failure screen to -// name; for the saved endpoint the two calls are the same. +// 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 { @@ -227,8 +229,8 @@ func (m *model) Init() tea.Cmd { return m.connectVia(start, false) } -// endpointActivationResult is how an attempt's outcome reaches the update -// loop, where settings may be written. +// 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 @@ -238,10 +240,11 @@ type endpointActivationResult struct { err error } -// bridgeLine is one thing the attempt reported and how far into the attempt it -// was. The elapsed time is why 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. +// 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 @@ -249,13 +252,14 @@ type bridgeLine struct { // 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 lands unindented and miscounts the rows to -// repaint, and control plane errors carry their request ID on a second line. +// 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 is what the user reads for an event. +// describe returns the text the user reads for an event. func describe(e connection.Event) string { switch { case e.Phase != 0: @@ -487,11 +491,12 @@ func (m *model) retargetActivation(next config.Endpoint) tea.Cmd { return m.startAttempt(a) } -// bridgeLogSink is where the attempt's events land 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. +// 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() { @@ -545,11 +550,12 @@ func waitBridgeLog(ctx context.Context, ch chan bridgeLine) tea.Cmd { } func (m *model) quitCmd() tea.Cmd { - // A removal's outcome is what drops the records naming the device. - // Closing the Machines now would cancel the logout, and quitting before - // the outcome arrives would leave settings naming a device that may be - // gone. The quit happens when the outcome has been applied. - if m.act.removing() { + // 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 } @@ -749,7 +755,7 @@ func appendBridgeLog(logs []bridgeLine, line bridgeLine) []bridgeLine { for len(logs) > bridgeLogLimit { drop := 0 for i, line := range logs { - if !line.important() { + if !line.isImportant() { drop = i break } @@ -762,7 +768,7 @@ func appendBridgeLog(logs []bridgeLine, line bridgeLine) []bridgeLine { // 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) important() bool { +func (l bridgeLine) isImportant() bool { return !l.event.Droppable() || importantBridgeLog(l.event.Note) } @@ -891,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) } @@ -970,13 +976,13 @@ func (m *model) updatePreflight(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if msg.String() == "ctrl+y" && m.act != nil && m.act.authURL != "" { return m, copyURLCmd(m.act.id, m.act.authURL) } - if !m.act.cancelable() { + if !m.act.canCancel() { return m, nil } if msg.String() == "esc" { return m.cancelActivation() } - if !m.act.overridable() { + if !m.act.canOverride() { return m, nil } switch msg.String() { @@ -995,9 +1001,9 @@ func (m *model) updatePreflight(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } -// authCopyHint and authCopiedHint are the line under the login link, before -// and after ctrl+y. The key has to be named on screen: nothing about a URL -// suggests which chord copies it. +// 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" @@ -1007,10 +1013,11 @@ const ( // 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, and prose sharing those lines lands -// in the selection when the user drags across them; a browser strips a newline -// out of a URL but not an indent or a label. Every line carries the same OSC 8 -// hyperlink, id-tagged so terminals rejoin the halves and ctrl-click survives. +// 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 == "" { @@ -1021,7 +1028,7 @@ func (m *model) authFooter() string { hint = authCopiedHint } var sb strings.Builder - // Styled a line at a time: lipgloss pads a multi-line block out to its + // 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)) @@ -1066,7 +1073,7 @@ func (m *model) viewPreflight() string { sb.WriteString("\n") } switch { - case m.act.overridable(): + 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") @@ -1077,7 +1084,7 @@ func (m *model) viewPreflight() string { } sb.WriteString("\n") sb.WriteString(dimStyle.Render("Enter to switch · Esc to cancel\n")) - case m.act.cancelable(): + case m.act.canCancel(): sb.WriteString("\n") sb.WriteString(dimStyle.Render("Esc to cancel\n")) } @@ -1205,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 @@ -1257,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++ { @@ -1317,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 { @@ -1455,7 +1461,7 @@ func (m *model) promptForInput(title, prompt, initial string, onSave func(value // --- 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 5eb2b5e..92380fe 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -612,7 +612,7 @@ func TestEndpointBridgeMenu_ConnectsExistingBridgeWithoutPrompting(t *testing.T) 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.overridable() { + if !m.act.canOverride() { t.Error("bridge discovery should accept a typed URL while it runs") } } @@ -1443,7 +1443,7 @@ func TestAuthFooterCopyKey(t *testing.T) { cancel: func() {}, }, } - if !m.act.overridable() { + if !m.act.canOverride() { t.Fatal("the override editor is inert here, so this does not test the collision it is about") } From e5983dbafeb2eff8880c6abea4adac4f3f5feab7 Mon Sep 17 00:00:00 2001 From: Guy J Grigsby Date: Mon, 21 Sep 2026 19:18:33 +0000 Subject: [PATCH 69/69] config, aperture: plain doc comments, one verb for removing an endpoint DropEndpoint and RemoveEndpoint were two verbs for one action split by argument type. RemoveEndpoint now takes the Endpoint, which every caller outside the package had; the index form is removeEndpointAt and private. --- cmd/aperture/main.go | 44 ++++++++------- internal/bridges/attempt.go | 2 +- internal/bridges/attempt_test.go | 2 +- internal/bridges/remove.go | 2 +- internal/config/endpoint.go | 49 +++++++++-------- internal/config/global.go | 94 ++++++++++++++++---------------- internal/config/runlog.go | 17 +++--- internal/config/settings.go | 23 ++++---- internal/config/startup.go | 34 ++++++------ internal/config/state.go | 16 +++--- internal/config/state_test.go | 2 +- 11 files changed, 148 insertions(+), 137 deletions(-) diff --git a/cmd/aperture/main.go b/cmd/aperture/main.go index d90700b..f37997e 100644 --- a/cmd/aperture/main.go +++ b/cmd/aperture/main.go @@ -18,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" @@ -54,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 } @@ -114,15 +114,15 @@ func gitCommitHeightInDir(dir string) string { return height } -// startRunLog points slog at the run log and returns its closer. Records are -// written straight through, so the os.Exit paths that skip the close lose -// nothing; the close is there to be tidy, not to flush. +// 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. It falls back to discarding them rather than to +// 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 +// 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() { @@ -143,12 +143,11 @@ func startRunLog(verbose bool) func() { } } -// reportFailure puts a failure back in front of the user. Every diagnostic now -// goes to the run log, which is the right place for a running TUI and the -// wrong one for a run that just died: without this, a launch that fails prints -// nothing and exits 1. +// 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 +// 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) @@ -157,9 +156,11 @@ func reportFailure(err error) { } } -// flagOrEnv lets a dotfile, container or systemd unit make the same selection a -// typed invocation can. A flag that was passed wins, even empty, so a one-off -// run can override the shell it started in: -bridge= means no bridge. +// 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 }) @@ -181,9 +182,9 @@ func main() { os.Exit(0) } - // Before anything that logs. slog's default handler writes to stderr, - // which on a TUI that owns the terminal means a line painted over the - // screen, so until this runs every diagnostic is either damage or lost. + // 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() @@ -198,8 +199,9 @@ func main() { // Register Claude Desktop on supported platforms (darwin, windows). profiles.RegisterIfSupported() - // Before the TUI takes the terminal, so a URL we cannot use exits non-zero - // instead of painting an error the script that passed it will never see. + // 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) diff --git a/internal/bridges/attempt.go b/internal/bridges/attempt.go index c16af12..afccc59 100644 --- a/internal/bridges/attempt.go +++ b/internal/bridges/attempt.go @@ -246,7 +246,7 @@ func (a *Attempt) Abandon(g *config.Global) error { if a == nil || !a.ephemeral { return nil } - if err := g.DropEndpoint(a.Endpoint); err != nil { + if err := g.RemoveEndpoint(a.Endpoint); err != nil { return err } a.ephemeral = false diff --git a/internal/bridges/attempt_test.go b/internal/bridges/attempt_test.go index 6344514..976fb65 100644 --- a/internal/bridges/attempt_test.go +++ b/internal/bridges/attempt_test.go @@ -22,7 +22,7 @@ func settingsGlobal(t *testing.T, s config.Settings) (*config.Global, string) { return &config.Global{Settings: s}, filepath.Join(dir, "aperture") } -// A failed DropEndpoint has to leave the attempt still ephemeral: otherwise +// 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")}}) diff --git a/internal/bridges/remove.go b/internal/bridges/remove.go index d32d0a4..f65e0b2 100644 --- a/internal/bridges/remove.go +++ b/internal/bridges/remove.go @@ -91,7 +91,7 @@ func RemoveFromSettings(g *config.Global, bridge config.Bridge, endpoint config. return err } if endpoint != nil { - if err := g.DropEndpoint(endpoint); err != nil { + if err := g.RemoveEndpoint(endpoint); err != nil { return err } } diff --git a/internal/config/endpoint.go b/internal/config/endpoint.go index b25afe7..378805f 100644 --- a/internal/config/endpoint.go +++ b/internal/config/endpoint.go @@ -7,33 +7,33 @@ import ( "strings" ) -// DefaultLocation is the well-known Aperture location. It is the first -// candidate every connection attempt tries, direct or bridged, and the -// fallback when the user has no saved settings. +// 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 is a remote Aperture and the way to it. There are two kinds and no -// third: a DirectEndpoint the host reaches itself, and a BridgeEndpoint -// reached through a Bridge's Machine. Values are comparable, so two Endpoints -// are the same when == says so. +// 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 is the same way to a different Aperture: an edit or an inline - // override keeps its Bridge. + // 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 is an Aperture the host reaches over its own network. +// DirectEndpoint reaches an Aperture over the host's own network. type DirectEndpoint struct{ url string } -// BridgeEndpoint is an Aperture reached through the Machine of one Bridge. +// BridgeEndpoint reaches an Aperture through the Machine of one Bridge. type BridgeEndpoint struct{ url, bridgeID string } -// Direct is the Endpoint for an Aperture at url reached without a Bridge. +// Direct returns the Endpoint that reaches url without a Bridge. func Direct(url string) DirectEndpoint { return DirectEndpoint{url: url} } -// Bridged is the Endpoint for an Aperture at url reached through bridgeID. +// Bridged returns the Endpoint that reaches url through bridgeID. func Bridged(url, bridgeID string) BridgeEndpoint { return BridgeEndpoint{url: url, bridgeID: bridgeID} } @@ -47,19 +47,19 @@ 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 is an embedded tsnet node used to reach Aperture without requiring -// Tailscale to run on the host. +// 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 is the network the node logged in to, recorded after a - // successful connection so the connection picker can say which tailnet a - // bridge reaches before it is started again. + // 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 the URL an Endpoint is made from. A -// bare host is assumed to be http, since Aperture is reached over the tailnet. +// 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, "://") { @@ -72,8 +72,8 @@ func ParseEndpointURL(value string) (string, error) { return strings.TrimRight(value, "/"), nil } -// endpointRecord is how an Endpoint is written to settings.json: one shape for -// both kinds, the kind told by whether bridgeId is present. The file predates +// 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"` @@ -101,8 +101,9 @@ func (r endpointRecord) endpoint() Endpoint { func (e DirectEndpoint) MarshalJSON() ([]byte, error) { return json.Marshal(recordOf(e)) } func (e BridgeEndpoint) MarshalJSON() ([]byte, error) { return json.Marshal(recordOf(e)) } -// endpointList is the settings field: a list whose elements are an interface, -// which encoding/json cannot decode without being told the concrete types. +// 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 { diff --git a/internal/config/global.go b/internal/config/global.go index 1f9a2a6..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 { @@ -53,14 +52,14 @@ 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 { @@ -69,10 +68,10 @@ func (g *Global) ActiveEndpoint() Endpoint { 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. replacing is the original endpoint of a verified URL edit, removed -// in the same write. Bridge activation later rewrites ApertureHost to localhost. +// 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 { @@ -90,14 +89,14 @@ func (g *Global) SetActiveEndpoint(ep Endpoint, replacing Endpoint) error { 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(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 existing == ep { @@ -113,8 +112,8 @@ 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 @@ -154,10 +153,10 @@ func (g *Global) ReplaceEndpoint(old, next Endpoint) error { 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 } @@ -176,20 +175,21 @@ func (g *Global) RemoveEndpoint(idx int) error { return nil } -// DropEndpoint removes ep from the list unless it is the active endpoint, -// which is the connection the user falls back to. An endpoint not in the list -// is not an error. -func (g *Global) DropEndpoint(ep Endpoint) error { +// 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.RemoveEndpoint(i) + return g.removeEndpointAt(i) } return nil } -// AddBridge creates, saves, and returns a bridge with a generated stable ID. +// 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 == "" { @@ -209,8 +209,8 @@ func (g *Global) AddBridge(name string) (Bridge, error) { return p, nil } -// SetBridgeTailnet records the tailnet a bridge logged in to and persists it. -// An unknown bridge is not an error: the user may have deleted it while the +// 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 { @@ -229,7 +229,8 @@ func (g *Global) SetBridgeTailnet(id, tailnet string) error { return nil } -// RemoveBridge deletes a bridge if no endpoint still references it. +// 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, ok := ep.(BridgeEndpoint); ok && ep.BridgeID() == id { @@ -252,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 { @@ -262,7 +263,8 @@ 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() @@ -273,8 +275,8 @@ func (g *Global) RecordLaunch(s LaunchState) error { 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 index 1849c75..2e5b0f4 100644 --- a/internal/config/runlog.go +++ b/internal/config/runlog.go @@ -5,18 +5,19 @@ import ( "path/filepath" ) -// runLogCap is the size the run log is allowed to reach before the next run -// starts it over. A connect attempt writes a few hundred bytes, so this holds -// a long history of them and still cannot grow without bound on a box nobody +// 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. // -// Truncated at a cap; rotate if anyone ever needs the older runs. +// 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. It sits -// beside the settings and bridge state rather than in a temp dir, because the -// question it answers ("what was the last run waiting on?") gets asked after a -// reboot as often as before one. +// 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 { diff --git a/internal/config/settings.go b/internal/config/settings.go index e4ffa64..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,13 +16,13 @@ import ( "tailscale.com/atomicfile" ) -// 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 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. @@ -40,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 { diff --git a/internal/config/startup.go b/internal/config/startup.go index 1bf421c..5d85867 100644 --- a/internal/config/startup.go +++ b/internal/config/startup.go @@ -5,25 +5,26 @@ import ( "strings" ) -// EndpointFromFlags is the Endpoint the command line asked the launcher to -// open: the saved active one when it named nothing. +// 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 is 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. +// 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 before the TUI takes the terminal, so a URL we cannot use is -// a line on stderr rather than a full-screen error. +// 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 } - // The URL is checked before the bridge is looked up, 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. + // 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) @@ -42,12 +43,13 @@ func EndpointFromFlags(g *Global, rawURL, bridgeName string) (Endpoint, error) { return Bridged(location, bridge.ID), nil } -// bridgeNamed creates the named bridge if there is none, which is what makes a first -// run scriptable. Matching ignores case: the name is the user's own label and -// nothing keys off it. +// 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 the flag then names neither: picking the -// first leaves the other unreachable from the command line, silently. +// 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 { 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 e38331d..39daca5 100644 --- a/internal/config/state_test.go +++ b/internal/config/state_test.go @@ -239,7 +239,7 @@ func TestGlobal_RemoveInactiveEndpointPreservesRuntimeHost(t *testing.T) { 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" {