tool for detachable ptys with optional tailscale discovery
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483package remote
import ( "bytes" "context" "embed" "encoding/json" "errors" "fmt" "io" "net" "os" "os/exec" "os/user" "path/filepath" "runtime" "slices" "strconv" "strings" "sync" "syscall" "time"
"github.com/klauspost/compress/zstd" "github.com/samber/lo" "tobi/internal/session" "tobi/internal/target")
//go:embed payloads/*var embeddedPayloads embed.FS
var hostMu sync.Mutex
func hostMapPath() string { base, _ := os.UserConfigDir() if base == "" { return "" } return filepath.Join(base, "tobi", "hosts.json")}
func loadHosts() map[string][]string { data, _ := os.ReadFile(hostMapPath()) var m map[string][]string if json.Unmarshal(data, &m) == nil && m != nil { return m } var old map[string]string _ = json.Unmarshal(data, &old) m = make(map[string][]string, len(old)) for host, dest := range old { if dest != "" { m[host] = []string{dest} } } return m}
func remembered(host string) []string { hostMu.Lock() defer hostMu.Unlock() return loadHosts()[host]}
func peerEnv() string { u := os.Getenv("USER") if u == "" { if cur, err := user.Current(); err == nil { u = cur.Username } } host := session.ShortHostname() if u == "" || host == "" || host == "localhost" { return "" } return "TOBI_PEER=" + u + "@" + host + " "}
func RememberHost(dest string) { t := target.Parse(dest) if t.User == "" || t.Host == "" { return } hostMu.Lock() defer hostMu.Unlock() m := loadHosts() if len(m[t.Host]) > 0 && m[t.Host][0] == dest { return } m[t.Host] = append([]string{dest}, slices.DeleteFunc(m[t.Host], func(d string) bool { return d == dest })...) m[t.Host] = m[t.Host][:min(len(m[t.Host]), 8)] saveHosts(m)}
func saveHosts(m map[string][]string) { p := hostMapPath() data, err := json.Marshal(m) if p == "" || err != nil || os.MkdirAll(filepath.Dir(p), 0700) != nil { return } _ = os.WriteFile(p, data, 0600)}
func hostDest(host string) string { if target.Parse(host).User != "" { RememberHost(host) return host } if d := remembered(host); len(d) > 0 { return d[0] } return host}
func KnownDests(host string) []string { return lo.Uniq(append([]string{host}, slices.DeleteFunc(remembered(host), func(d string) bool { return d == "" || d == host })...))}
func ProbeDests(host string) []string { d := KnownDests(host) return lo.Ternary(len(d) > 1, d[1:], []string{host})}
func QueryHost(ctx context.Context, host string) []session.Session { t := target.Parse(host) dests := ProbeDests(t.Host) if t.User != "" { dests = lo.Uniq(append([]string{host}, dests...)) } var found []session.Session for _, dest := range dests { if list, err := QueryRemote(ctx, dest); err == nil { found = append(found, list...) } } return lo.UniqBy(found, func(s session.Session) string { return strings.ToLower(target.Parse(s.Host).Host + "." + s.ID) })}
const ( uploadMarker = "__TOBI_UPLOADED__" spawnMarker = "__TOBI_SPAWNED__" killMarker = "__TOBI_KILLED__" remotePathEnv = `PATH="$HOME/.local/bin:$PATH:$HOME/.nix-profile/bin:/opt/homebrew/bin:/usr/local/bin:$HOME/go/bin"` uploadCmd = "mkdir -p ~/.local/bin && cat > ~/.local/bin/tobi.tmp && chmod 755 ~/.local/bin/tobi.tmp && mv -f ~/.local/bin/tobi.tmp ~/.local/bin/tobi")
func rpcCommand(op, id string) string { cmd := remotePathEnv + " " + peerEnv() + "TOBI_RPC=" + op if id != "" { cmd += " TOBI_ID=" + id } return cmd + " tobi"}
var archAlias = map[string]string{"x86_64": "amd64", "aarch64": "arm64"}
var ( remoteInstalled sync.Map // the declared release; bump it (or append "+n" for a same-day // re-release) to ship, remotes refuse older builds Version = "2026.09.27+3")
// verTuple parses a declared version - "2026.09.21" or "2026.09.21+2", the// suffix counting same-day re-releases; undated builds sort before dated onesfunc verTuple(v string) (string, int) { if len(v) >= 10 && v[4] == '.' && v[7] == '.' { if len(v) == 10 { return v, 0 } if v[10] == '+' { if n, err := strconv.Atoi(v[11:]); err == nil && n >= 0 { return v[:10], n } } } return "", 0}
type sshOpts struct { cmd string batch, master bool timeout string}
func sshCommand(ctx context.Context, dest string, opts sshOpts) *exec.Cmd { bin, err := exec.LookPath("tssh") if err != nil { bin = "ssh" } args := []string{"-T", "-o", "ConnectTimeout=" + opts.timeout} if opts.batch { args = append(args, "-o", "BatchMode=yes") } if home, err := os.UserHomeDir(); err == nil && home != "" { ctl := filepath.Join(home, ".ssh", "tobi-control-%r@%h:%p") args = append(args, "-o", "ControlMaster="+lo.Ternary(opts.master, "auto", "no"), "-o", "ControlPath="+ctl, "-o", "ControlPersist=60") } c := exec.CommandContext(ctx, bin, append(args, dest, opts.cmd)...) c.WaitDelay = 500 * time.Millisecond if opts.batch { // no controlling terminal: tssh ignores BatchMode and prompts for // unknown host keys on /dev/tty, eating the picker's keystrokes. // the new session is also a process group, so a timeout takes the // ProxyCommand down with ssh instead of orphaning it c.SysProcAttr = &syscall.SysProcAttr{Setsid: true} c.Cancel = func() error { return syscall.Kill(-c.Process.Pid, syscall.SIGKILL) } } return c}
var ErrUnreachable = errors.New("unreachable")
func sshRun(ctx context.Context, dest string, in io.Reader, opts sshOpts) (string, error) { c := sshCommand(ctx, dest, opts) c.Stdin = in var out, errBuf bytes.Buffer c.Stdout, c.Stderr = &out, &errBuf if err := c.Run(); err != nil { var ee *exec.ExitError if (errors.As(err, &ee) && ee.ExitCode() == 255) || ctx.Err() != nil { err = fmt.Errorf("%w: %w", ErrUnreachable, err) } if msg := strings.TrimSpace(errBuf.String()); msg != "" { return out.String(), fmt.Errorf("%w: %s", err, msg) } return out.String(), err } return out.String(), nil}
// sshConfirmed runs opts.cmd and wants marker in its output: a remote that// exits clean without printing it did not do the workfunc sshConfirmed(ctx context.Context, dest string, in io.Reader, opts sshOpts, marker, missing string) error { out, err := sshRun(ctx, dest, in, opts) if err == nil && !strings.Contains(out, marker) { return errors.New(missing) } return err}
func getBinaryPayload(goos, arch string) ([]byte, error) { if data, err := embeddedPayloads.ReadFile(fmt.Sprintf("payloads/tobi-%s-%s.zst", goos, arch)); err == nil && len(data) > 0 { if zr, err := zstd.NewReader(bytes.NewReader(data)); err == nil { defer zr.Close() return io.ReadAll(zr) } } if goos == runtime.GOOS && arch == runtime.GOARCH { return os.ReadFile(session.Executable()) } return nil, fmt.Errorf("no binary available for %s/%s (build the carrier slices: nix build or scripts/build-universal.sh)", goos, arch)}
func EnsureRemote(ctx context.Context, host string) error { dest := hostDest(host) if _, ok := remoteInstalled.Load(dest); ok { return nil } // the PATH prefix is a per-command assignment: it must wrap the version // rpc too, or the check 127s on hosts where tobi is not in default PATH check := remotePathEnv + " " + peerEnv() + `command -v tobi >/dev/null 2>&1 && ` + remotePathEnv + ` TOBI_RPC=version tobi` if out, err := sshRun(ctx, dest, nil, sshOpts{cmd: check, batch: true, master: true, timeout: "3"}); err == nil { remoteVer := strings.TrimSpace(out) ldate, lseq := verTuple(Version) rdate, rseq := verTuple(remoteVer) newer := rdate != "" && (rdate > ldate || (rdate == ldate && rseq > lseq)) if ldate == "" || rdate != "" && (rdate > ldate || rdate == ldate && rseq >= lseq) { if ldate == "" && rdate != "" { fmt.Fprintf(os.Stderr, "not downgrading %s: remote tobi %s is versioned, the local build is not\n", dest, remoteVer) } else if newer { fmt.Fprintf(os.Stderr, "not downgrading %s: remote tobi %s is newer than local %s\n", dest, remoteVer, Version) } remoteInstalled.Store(dest, true) return nil } } out, err := sshRun(ctx, dest, nil, sshOpts{cmd: "uname -s && uname -m", batch: true, timeout: "3"}) if err != nil { return fmt.Errorf("detect %s: %w", dest, err) } osName, arch, ok := strings.Cut(strings.TrimSpace(out), "\n") if !ok { return fmt.Errorf("unexpected uname from %s: %q", dest, out) } osName, arch = strings.ToLower(osName), strings.ToLower(arch) if a := archAlias[arch]; a != "" { arch = a } bin, err := getBinaryPayload(osName, arch) if err != nil { return fmt.Errorf("prepare tobi for %s (%s/%s): %w", dest, osName, arch, err) } upload := sshOpts{cmd: uploadCmd + " && echo " + uploadMarker, batch: true, timeout: "5"} if err := sshConfirmed(ctx, dest, bytes.NewReader(bin), upload, uploadMarker, "upload did not complete"); err != nil { return fmt.Errorf("upload tobi to %s: %w", dest, err) } remoteInstalled.Store(dest, true) return nil}
func QueryRemote(ctx context.Context, host string) ([]session.Session, error) { dest := hostDest(host) out, err := sshRun(ctx, dest, nil, sshOpts{cmd: rpcCommand("list", ""), batch: true, timeout: "10"}) if err != nil { return nil, err } var list []session.Session if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &list); err != nil { return nil, err } for i := range list { list[i].Host = dest } return list, nil}
func AttachRemote(host, id string) (io.ReadWriteCloser, error) { ctx := context.Background() dest := hostDest(host) if err := EnsureRemote(ctx, dest); err != nil { return nil, err } c := sshCommand(ctx, dest, sshOpts{cmd: rpcCommand("attach", id), batch: true, timeout: "2"}) in, err := c.StdinPipe() if err != nil { return nil, err } out, err := c.StdoutPipe() if err != nil { return nil, err } c.Stderr = nil // unreachable peers are expected; their stderr is noise if err := c.Start(); err != nil { return nil, err } return &Stream{Reader: out, WriteCloser: in, cmd: c}, nil}
type Stream struct { io.Reader io.WriteCloser cmd *exec.Cmd once sync.Once}
func (s *Stream) Close() error { s.once.Do(func() { _ = s.WriteCloser.Close() _ = s.cmd.Wait() }) return nil}
type tsPeer struct { Online bool OS, DNSName, HostName string}
type Peer struct { Name string Online bool}
func DiscoverPeers(ctx context.Context) []Peer { c := exec.CommandContext(ctx, "tailscale", "status", "--json") c.WaitDelay = 500 * time.Millisecond out, err := c.Output() if err != nil { return nil } var st struct{ Peer map[string]tsPeer } if json.Unmarshal(out, &st) != nil { return nil } self := strings.ToLower(session.ShortHostname()) peers := lo.FilterMap(lo.Values(st.Peer), func(p tsPeer, _ int) (Peer, bool) { if p.OS != "linux" && p.OS != "macOS" && p.OS != "darwin" { return Peer{}, false } name, _, _ := strings.Cut(p.DNSName, ".") if name == "" { name = p.HostName } return Peer{Name: name, Online: p.Online}, name != "" && !strings.EqualFold(name, self) }) slices.SortFunc(peers, func(a, b Peer) int { return strings.Compare(a.Name, b.Name) }) return peers}
func RunRPC(op, id string) error { if t := target.Parse(os.Getenv("TOBI_PEER")); t.User != "" && t.Host != "" && !strings.EqualFold(t.Host, session.ShortHostname()) { RememberHost(t.Dest()) } switch op { case "list": return json.NewEncoder(os.Stdout).Encode(session.ListLocal()) case "spawn": return runSpawnRPC(os.Stdin) case "version": fmt.Println(Version) return nil case "kill": if err := session.Kill(id); err != nil { return err } fmt.Println(killMarker) return nil } conn, err := net.Dial("unix", session.SocketPath(id)) if err != nil { return err } defer conn.Close() done := make(chan struct{}, 2) pump := func(dst io.Writer, src io.Reader) { _, _ = io.Copy(dst, src) _ = conn.Close() done <- struct{}{} } go pump(conn, os.Stdin) go pump(os.Stdout, conn) <-done return nil}
type spawnRequest struct { ID string `json:"id"` Argv []string `json:"argv"`}
func validateSpawnRequest(req spawnRequest) error { if !session.ValidateID(req.ID) { return fmt.Errorf("invalid session id %q", req.ID) } if len(req.Argv) == 0 || len(req.Argv) > 128 { return fmt.Errorf("invalid argv length %d", len(req.Argv)) } return nil}
func runSpawnRPC(r io.Reader) error { var req spawnRequest if err := json.NewDecoder(io.LimitReader(r, 64<<10)).Decode(&req); err != nil { return fmt.Errorf("spawn request: %w", err) } if err := validateSpawnRequest(req); err != nil { return err } return session.EnsureOwner(req.ID, req.Argv)}
// an older remote tobi takes "kill" for an attach and exits clean, so only the marker proves itfunc KillRemote(ctx context.Context, host, id string) error { dest := hostDest(host) kill := sshOpts{cmd: rpcCommand("kill", id), batch: true, timeout: "5", master: true} if err := sshConfirmed(ctx, dest, nil, kill, killMarker, "remote tobi is too old to kill sessions"); err != nil { return fmt.Errorf("kill %s.%s: %w", dest, id, err) } return nil}
func SpawnRemote(host, id string, cmdArgs []string) error { req := spawnRequest{ID: id, Argv: cmdArgs} if err := validateSpawnRequest(req); err != nil { return err } dest := hostDest(host) if err := EnsureRemote(context.Background(), dest); err != nil { return err } body, _ := json.Marshal(req) // strings only, it cannot fail spawn := sshOpts{cmd: rpcCommand("spawn", "") + " && echo " + spawnMarker, timeout: "5", master: true} if err := sshConfirmed(context.Background(), dest, bytes.NewReader(append(body, '\n')), spawn, spawnMarker, "did not confirm"); err != nil { return fmt.Errorf("spawn on %s: %w", dest, err) } return nil}