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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions core/hosts/hosts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions docs/daemon.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
17 changes: 12 additions & 5 deletions ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
6 changes: 5 additions & 1 deletion ui/connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -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…"
Expand Down
7 changes: 5 additions & 2 deletions ui/hostform.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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) {
Expand Down
71 changes: 71 additions & 0 deletions ui/hostform_start_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
26 changes: 19 additions & 7 deletions ui/secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ui/tailscale.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
91 changes: 81 additions & 10 deletions ui/watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading