diff --git a/core/hosts/hosts.go b/core/hosts/hosts.go index f011a7ba..e58f682b 100644 --- a/core/hosts/hosts.go +++ b/core/hosts/hosts.go @@ -44,6 +44,9 @@ type Host struct { // Tailnet reaches the host through enowX's own tailnet device rather // than this machine's network (transport/owntailnet). Tailnet bool `json:"tailnet,omitempty"` + // NoStartConnect leaves the host unconnected when the app starts, until the + // user opens it; by default every saved host connects in the background. + NoStartConnect bool `json:"noStartConnect,omitempty"` } // KindDevice marks a host that is one of the user's computers. diff --git a/docs/daemon.md b/docs/daemon.md index 74fdd827..4c191efe 100644 --- a/docs/daemon.md +++ b/docs/daemon.md @@ -58,6 +58,15 @@ On Windows a session's processes are told by `ENOWX_SESSION` in their environmen enowX keeps its state in `~/.enowx-app` (`transport.HomeName`), on this device and on servers; `ENOWX_HOME` names another (`make dev` uses `~/.enowx-dev`). Its command line is `enowx-app`, linked at `~/.local/bin/enowx-app`. Before, both were `~/.enowx` and `enowx`, which another app uses too: the first start of a newer app or daemon stops the daemon there and moves enowX's own files across (hosts, projects, sessions and their history, worktrees, settings), repairing git's links to the worktrees, and leaves the rest of `~/.enowx` alone. +## Every saved host, from the app's start + +The app connects to every saved host as it starts, in the background, while it shows the one it was on (`ui/watch.go`): each over a connection of its own, at once, so its sessions list under **On other hosts** as soon as it answers, without opening it. Opening one of those sessions connects there. + +- **Nothing waits on it.** Each host connects on its own goroutine, and one that hasn't answered in 2 minutes (an install of the daemon on a slow link included) counts as offline. The app's start and the host shown never wait for them. +- **Quietly.** A host that is down shows "offline · retrying" in the Hosts list, with no dialog; it is tried again after 15 seconds, then twice as long each time up to 5 minutes, and at once when the window comes back into sight or the machine wakes from sleep (its wall clock then jumps ahead of its monotonic one). +- **Never asking.** What would ask the user waits until they open the host: a password not saved, a key's passphrase, a server key not trusted yet, whom to run as, a browser check (Tailscale SSH). The host shows "connect to sign in". A password saved in the keychain is read, once per run; on macOS the system may ask once to allow that, and a refused or missing one isn't asked for again that run. +- **Connect at start**, in Hosts › ⋯ › Edit…, is on by default. Off, the host connects only once the user opens it, and is followed in the background from then on until the app quits (`hosts.Host.NoStartConnect` in `hosts.json`). + ## Far and flaky connections What makes a far server feel close, built in rather than through mosh (which serves one shell over UDP, and needs `mosh-server` and an open port on the server): diff --git a/ui/app.go b/ui/app.go index 07bfff97..2cf85d86 100644 --- a/ui/app.go +++ b/ui/app.go @@ -161,6 +161,7 @@ type App struct { setupPolled time.Time // when the setup's status was last read drift map[string]int // steps a host lacks to be what this device is, by host ID watches map[string]*hostWatch // the hosts not shown, by ID + opened map[string]bool // hosts the user connected to this run: watched even with Connect at start off dsync dataSync // the user's data synced between hosts feats *hostFeatures // the connected host's feature switches noteChatsRead noteChats // notes' conversations with their agents @@ -270,11 +271,14 @@ type App struct { // by keychainMu, which is held while reading. keychainMu sync.Mutex keychainRead map[string]string - peers []tailnet.Peer // the tailnet's devices, if Tailscale runs - peersErr string // why there are none - peersLoaded bool // the tailnet was asked at least once - peersAt time.Time // when the tailnet's devices were asked for, for the hosts menu - tailUser string // SSH user for tailnet devices + // keychainMissed are hosts whose watch found no password there this run: + // they wait for the user, the keychain not asked again. + keychainMissed map[string]bool + peers []tailnet.Peer // the tailnet's devices, if Tailscale runs + peersErr string // why there are none + peersLoaded bool // the tailnet was asked at least once + peersAt time.Time // when the tailnet's devices were asked for, for the hosts menu + tailUser string // SSH user for tailnet devices agents []proto.AgentInfo daemonInfo proto.DaemonInfo // the connected daemon, as it described itself @@ -430,7 +434,10 @@ func (a *App) Open() *mygo.Window { a.win.OnBlur(func() { a.blurred = true; a.unfocused.Store(true) }) setAppMenu(a) a.holdForUnsaved() + stopWakes := make(chan struct{}) + go a.watchWakes(stopWakes) a.win.OnClosed(func() { + close(stopWakes) stopAppearance() a.disconnect() a.stopWatches() diff --git a/ui/connection.go b/ui/connection.go index 6c2f6191..e956e46d 100644 --- a/ui/connection.go +++ b/ui/connection.go @@ -70,7 +70,11 @@ func (a *App) connect(h hosts.Host) { gen := a.gen a.host = h a.connectByApp = false // the timer and the app's start say so after - a.syncWatches() // the host left is watched now, this one shown + if a.opened == nil { + a.opened = map[string]bool{} + } + a.opened[h.ID] = true + a.syncWatches() // the host left is watched now, this one shown a.connecting, a.connErr, a.connFail, a.status, a.connectLink = "Connecting to "+h.Name+"…", "", nil, "", "" if h.ID == hosts.LocalID { a.connecting = "Starting…" diff --git a/ui/hostform.go b/ui/hostform.go index fc11e4b5..cc235d50 100644 --- a/ui/hostform.go +++ b/ui/hostform.go @@ -32,6 +32,8 @@ type addHostForm struct { // regular user ("" lets enowX pick its own, made when missing). asRoot bool runAs string + // startConnect: the host connects in the background as the app starts. + startConnect bool } func (a *App) addHostDialog(c *ui.Context) { @@ -99,6 +101,7 @@ func (a *App) addHostDialog(c *ui.Context) { submit = widget.Input(c, &f.jump, "user@bastion · a server in front of it; ~/.ssh/config's ProxyJump otherwise").FillWidth().Font(theme.MonoFont).Submitted() || submit }) a.runAsField(c, f) + switchRow(c, &f.startConnect, "Connect at start", "Connects in the background when enowX starts, so its sessions show in the list without opening it. A host that needs you (a password not saved, a key to trust) waits until you open it.") switchRow(c, &f.tailnet, "Through enowX's own tailnet", "enowX joins your tailnet as a device of its own, so the server is reached by its tailnet name without Tailscale on this machine. It asks you to sign in once.") if f.editing.PasswordAuth && !f.forgetPassword { ui.Row(c.Key("password")).FillWidth().Shrink(0).Gap(theme.S2).Children(func() { @@ -143,7 +146,7 @@ func (a *App) saveHost() { } h := f.editing // keeps the ID, the run-as choice and a saved password h.Name, h.Address, h.IdentityFile, h.Jump = f.name, f.address, strings.TrimSpace(f.identity), strings.TrimSpace(f.jump) - h.Kind, h.Tailnet = "", f.tailnet + h.Kind, h.Tailnet, h.NoStartConnect = "", f.tailnet, !f.startConnect if f.device { h.Kind = hosts.KindDevice } @@ -181,7 +184,7 @@ func (a *App) saveHost() { // openEditHost opens the host dialog on a saved host. func (a *App) openEditHost(h hosts.Host) { a.addHost = addHostForm{open: true, manual: true, editing: h, name: h.Name, address: h.Address, identity: h.IdentityFile, jump: h.Jump, device: h.Kind == hosts.KindDevice, tailnet: h.Tailnet, - asRoot: h.KeepRoot, runAs: h.RunAs} + asRoot: h.KeepRoot, runAs: h.RunAs, startConnect: !h.NoStartConnect} } func (a *App) storedHost(id string) (hosts.Host, bool) { diff --git a/ui/hostform_start_test.go b/ui/hostform_start_test.go new file mode 100644 index 00000000..0685bfb3 --- /dev/null +++ b/ui/hostform_start_test.go @@ -0,0 +1,71 @@ +package ui + +import ( + "path/filepath" + "testing" + + "github.com/egoist/mygo/ui" + + "github.com/enowx/enowx/core/hosts" + "github.com/enowx/enowx/ui/theme" +) + +// Hosts › Edit… has "Connect at start", on for a host already set up; turned off it +// is saved with the host, and the dialog keeps its switch in its frame however +// narrow the window. +func TestEditHostConnectAtStart(t *testing.T) { + home := t.TempDir() + t.Setenv("ENOWX_HOME", home) + store, err := hosts.Open(filepath.Join(home, "hosts.json")) + if err != nil { + t.Fatal(err) + } + h, err := store.Add(hosts.Host{Name: "vps-sg-1", Address: "root@127.0.0.1:1"}) + if err != nil { + t.Fatal(err) + } + a := New(Options{Hosts: store}) + posted := make(chan func(), 64) + a.onMain = func(fn func()) { posted <- fn } + a.openEditHost(h) + if !a.addHost.startConnect { + t.Fatal("a host already set up isn't set to connect at start") + } + for _, width := range []int{900, 520} { + tt := ui.NewTester(func(c *ui.Context) { + c.SetTheme(theme.Window()) + ui.Column(c).Width(float32(width)).Height(900).Background(theme.Background).Children(func() { a.addHostDialog(c) }) + }, width, 900) + tt.Frame() + tt.Frame() + r, ok := tt.Find("Connect at start") + if !ok { + t.Fatalf("%d: no Connect at start in %q", width, tt.Texts()) + } + if r.X < 0 || r.X+r.W > float32(width) { + t.Fatalf("%d: the switch is out of the window: %+v", width, r) + } + if width == 520 { + // The switch sits at the row's right edge, centered on its words. + for x := r.X + r.W; x < float32(width) && a.addHost.startConnect; x += 12 { + tt.ClickAt(x, r.Y+10) + tt.Frame() + } + } + } + if a.addHost.startConnect { + t.Fatal("the switch didn't turn off") + } + a.saveHost() + saved, _ := store.Get(h.ID) + if !saved.NoStartConnect { + t.Fatal("Connect at start off wasn't saved with the host") + } + again, err := hosts.Open(filepath.Join(home, "hosts.json")) + if err != nil { + t.Fatal(err) + } + if kept, _ := again.Get(h.ID); !kept.NoStartConnect { + t.Fatal("not kept in the host list's file") + } +} diff --git a/ui/secrets.go b/ui/secrets.go index da33908b..98bb69ff 100644 --- a/ui/secrets.go +++ b/ui/secrets.go @@ -38,19 +38,31 @@ func (a *App) savePassword(h hosts.Host, password string) { // asks the user to unlock the keychain, so it runs off the main thread. func (a *App) password(h hosts.Host) (string, error) { return a.readPassword(h) } -// watchPassword is password for a host's watch, which asks the user -// nothing: it never reads the keychain, whose prompt that can bring up, -// and has the password once a connection read it this run. +// watchPassword is password for a host's watch, which never asks the user to type +// one: the one saved in the keychain, read once per run, so a host signed in to by +// password connects at start like any other. One the keychain doesn't give (none +// saved, or the system's prompt refused) waits until the user opens the host. func (a *App) watchPassword(h hosts.Host) (string, error) { if pw, ok := a.passwordsSnapshot()[h.ID]; ok { return pw, nil } a.keychainMu.Lock() - defer a.keychainMu.Unlock() - if pw, ok := a.keychainRead[h.ID]; ok { - return pw, nil + missed := a.keychainMissed[h.ID] + a.keychainMu.Unlock() + if missed { + return "", errNeedsYou + } + pw, err := a.readPassword(h) + if err != nil { + a.keychainMu.Lock() + if a.keychainMissed == nil { + a.keychainMissed = map[string]bool{} + } + a.keychainMissed[h.ID] = true + a.keychainMu.Unlock() + return "", errNeedsYou } - return "", errNeedsYou + return pw, nil } // readPassword returns h's password from memory, else the keychain: read diff --git a/ui/tailscale.go b/ui/tailscale.go index 5bc0eb87..0e3157ce 100644 --- a/ui/tailscale.go +++ b/ui/tailscale.go @@ -48,7 +48,7 @@ func (a *App) tailnetNote() string { } func (a *App) openAddHost() { - a.addHost = addHostForm{open: true} + a.addHost = addHostForm{open: true, startConnect: true} go a.loadPeers() } diff --git a/ui/watch.go b/ui/watch.go index 4ec6f7d7..0e00438d 100644 --- a/ui/watch.go +++ b/ui/watch.go @@ -90,17 +90,19 @@ type hostWatch struct { var errNeedsYou = errors.New("needs you") // syncWatches watches every host but the one shown, and stops watching -// those gone from the list. +// those gone from the list. From the app's start, so every host's sessions +// list without opening it, save one set not to connect at start +// (hosts.Host.NoStartConnect) until the user opens it. func (a *App) syncWatches() { - if a.win == nil { - return + if a.win == nil && a.watchDial == nil { + return // no window to show them in, nor a test's connections } if a.watches == nil { a.watches = map[string]*hostWatch{} } want := map[string]hosts.Host{} for _, h := range a.allHosts() { - if h.ID != a.host.ID { + if h.ID != a.host.ID && (!h.NoStartConnect || a.opened[h.ID]) { want[h.ID] = h } } @@ -181,12 +183,49 @@ func (w *hostWatch) pause(wait *retryWait) bool { return true } -// dialWatch connects w's connection. +// watchConnectMost is how long a watch's connecting may take, an install of the +// daemon on a slow link included, before it counts as offline and is tried later. +var watchConnectMost = 2 * time.Minute + +// errWatchStopped ends a watch's connecting as the watch stops. +var errWatchStopped = errors.New("stopped") + +// dialWatch connects w's connection, in watchConnectMost at most: a host that +// doesn't answer holds no other, each watch connecting on its own goroutine. func (a *App) dialWatch(w *hostWatch) (*client.Client, error) { - if a.watchDial != nil { - return a.watchDial(w.host) + type dialed struct { + cl *client.Client + err error + } + done := make(chan dialed, 1) + go func() { + var d dialed + if a.watchDial != nil { + d.cl, d.err = a.watchDial(w.host) + } else { + d.cl, d.err = hostconn.Connect(w.host, a.watchOptions(w)) + } + done <- d + }() + late := func() { // what comes after giving up is closed + go func() { + if d := <-done; d.cl != nil { + d.cl.Close() + } + }() + } + timer := time.NewTimer(watchConnectMost) + defer timer.Stop() + select { + case d := <-done: + return d.cl, d.err + case <-timer.C: + late() + return nil, fmt.Errorf("no answer in %s", watchConnectMost) + case <-w.stop: + late() + return nil, errWatchStopped } - return hostconn.Connect(w.host, a.watchOptions(w)) } // retryNow has w try its host now, not when its wait ends. @@ -254,16 +293,48 @@ func (a *App) poll(w *hostWatch, cl *client.Client) { } } -// pokeWatches has every watch ask now: the window is back in sight. It runs on the main thread. +// pokeWatches has every watch ask now, and one waiting to try again try now: the +// window is back in sight, or the machine woke. It runs on the main thread. func (a *App) pokeWatches() { for _, w := range a.watches { select { case w.poke <- struct{}{}: default: } + if w.status.state != hostOnline && w.status.state != hostNeedsYou { + w.retryNow() + } } } +// wakeEvery is how often the app looks whether the machine slept: its wall +// clock then moved on further than its monotonic clock, which sleep stops. +const wakeEvery = 30 * time.Second + +// watchWakes pokes the watches each time the machine wakes from sleep, when +// connections dropped and networks changed, until stop. +func (a *App) watchWakes(stop <-chan struct{}) { + tick := time.NewTicker(wakeEvery) + defer tick.Stop() + last := time.Now() + for { + select { + case <-stop: + return + case now := <-tick.C: + if slept(last, now) { + a.onMain(a.pokeWatches) + } + last = now + } + } +} + +// slept says the machine slept between two readings of the clock. +func slept(before, now time.Time) bool { + return now.Round(0).Sub(before.Round(0))-now.Sub(before) > wakeEvery +} + // watchOptions connect a watch without asking anything: a password only // when it is saved, no new host trusted, no user picked. func (a *App) watchOptions(w *hostWatch) hostconn.Options { @@ -420,7 +491,7 @@ func hostSummary(st hostStatus) string { case hostConnecting: return "checking…" } - return "offline" + return "offline · retrying" } // hostUsageTip is a host's CPU, memory and disk in words. diff --git a/ui/watch_start_test.go b/ui/watch_start_test.go new file mode 100644 index 00000000..a96ffd52 --- /dev/null +++ b/ui/watch_start_test.go @@ -0,0 +1,115 @@ +//go:build !windows + +package ui + +import ( + "errors" + "net" + "sync" + "testing" + "time" + + "github.com/enowx/enowx/client" + "github.com/enowx/enowx/core/hosts" + "github.com/enowx/enowx/daemon" + "github.com/enowx/enowx/proto" +) + +// At the app's start every saved host connects in the background, at once and each on +// its own, and its sessions list as soon as it is up: one that doesn't answer holds no +// other and counts as offline when its time is up, one that needs the user waits for +// them, and one set not to connect at start waits until the user opens it. +func TestHostsConnectAtStart(t *testing.T) { + srv, err := daemon.NewServer(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + x, y := net.Pipe() + go srv.ServeConn(x) + maker, err := client.New(y, nil) + if err != nil { + t.Fatal(err) + } + defer maker.Close() + s, err := maker.CreateSession(proto.SessionCreateParams{Command: []string{"sh"}, Dir: t.TempDir(), Cols: 80, Rows: 10}) + if err != nil { + t.Fatal(err) + } + + most := watchConnectMost + watchConnectMost = 700 * time.Millisecond + t.Cleanup(func() { watchConnectMost = most }) + a, run := watchedApp(t) + hang := make(chan struct{}) + defer close(hang) + var mu sync.Mutex + dialed := map[string]int{} + a.watchDial = func(h hosts.Host) (*client.Client, error) { + mu.Lock() + dialed[h.ID]++ + mu.Unlock() + switch h.ID { + case "down": + <-hang + return nil, errors.New("gone") + case "needs-you": + return nil, errNeedsYou + } + time.Sleep(400 * time.Millisecond) // a server across the world + p, q := net.Pipe() + go srv.ServeConn(p) + return client.New(q, nil) + } + a.hostList = []hosts.Host{ + {ID: "vps-a", Name: "vps-a"}, {ID: "vps-b", Name: "vps-b"}, {ID: "vps-c", Name: "vps-c"}, + {ID: "down", Name: "down"}, {ID: "needs-you", Name: "needs-you"}, + {ID: "later", Name: "later", NoStartConnect: true}, + } + a.host = LocalHost() + began := time.Now() + a.syncWatches() + defer a.stopWatches() + + online := func(id string) bool { w := a.watches[id]; return w != nil && w.status.state == hostOnline } + run(func() bool { return online("vps-a") && online("vps-b") && online("vps-c") }, "the hosts online", 10*time.Second) + if took := time.Since(began); took > 1200*time.Millisecond { + t.Fatalf("three hosts of 400 ms each took %s: not connected at once", took) + } + listed := func() bool { + for _, info := range a.watches["vps-b"].status.sessions { + if info.ID == s.ID { + return true + } + } + return false + } + run(listed, "a host's sessions listed once it is up", 5*time.Second) + run(func() bool { return a.watches["down"].status.state == hostOffline }, "a host that doesn't answer offline", 5*time.Second) + if got := hostSummary(a.watches["down"].status); got != "offline · retrying" { + t.Fatalf("a host down reads %q", got) + } + run(func() bool { return a.watches["needs-you"].status.state == hostNeedsYou }, "a host that needs the user", 5*time.Second) + if _, ok := a.watches["later"]; ok { + t.Fatal("a host set not to connect at start was connected to") + } + + // Back in sight, or awake: a host offline tries again now, not when its wait ends; + // one that needs the user doesn't, nothing having changed for it. + mu.Lock() + downBefore, needsBefore := dialed["down"], dialed["needs-you"] + mu.Unlock() + a.pokeWatches() + tried := func() bool { mu.Lock(); defer mu.Unlock(); return dialed["down"] > downBefore } + run(tried, "the host down tried again at once", 3*time.Second) + mu.Lock() + if dialed["needs-you"] != needsBefore { + t.Fatal("a host that needs the user was tried again by itself") + } + mu.Unlock() + + // Opened once, it is watched from then on like the others. + a.opened = map[string]bool{"later": true} + a.syncWatches() + run(func() bool { return online("later") }, "a host the user opened watched", 5*time.Second) +}