From 63cb8a438846acbd014ae6db2fe172ed210e91a2 Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Wed, 27 May 2026 14:19:14 +0100 Subject: [PATCH] add tcp daemon listener with token auth --- CHANGELOG.md | 8 +++ cmd/patterm/main.go | 70 ++++++++++++++++++- internal/app/client_net.go | 12 ++++ internal/app/daemon_net.go | 109 ++++++++++++++++++++++++++--- internal/app/daemon_net_test.go | 118 ++++++++++++++++++++++++++++++++ internal/app/token.go | 63 +++++++++++++++++ 6 files changed, 368 insertions(+), 12 deletions(-) create mode 100644 internal/app/token.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c574ce..94c725c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). on demand and attaches a thin terminal client over the unix-socket transport; `--in-process` or `PATTERM_NO_DAEMON=1` keeps the legacy single-process path available as an escape hatch. +- `patterm daemon --listen HOST:PORT` can now opt into a TCP listener + for remote human clients, with the unix socket still enabled for + local clients. +- `patterm connect --host HOST:PORT [--token TOKEN]` attaches the thin + client to a remote daemon over the same transport protocol. +- TCP attaches now require a lightweight bearer token stored under + `$XDG_DATA_HOME/patterm/clients/token`; local unix-socket attaches + remain exempt and rely on socket file permissions. - patterm can now keep multiple local projects loaded in one loopback daemon core, with command-palette entries to switch the current client view or open another project without tearing down processes diff --git a/cmd/patterm/main.go b/cmd/patterm/main.go index 82e4270..7709ad3 100644 --- a/cmd/patterm/main.go +++ b/cmd/patterm/main.go @@ -56,6 +56,11 @@ func main() { runDaemonCommand() return } + if len(os.Args) >= 2 && os.Args[1] == "connect" { + os.Args = append(os.Args[:1], os.Args[2:]...) + runConnectCommand() + return + } if len(os.Args) >= 2 && os.Args[1] == "ls" { runDaemonList() return @@ -233,7 +238,10 @@ func runDaemonCommand() { runDaemonList() return } - var projectDir = flag.String("project", "", "initial project directory (default $PWD)") + var ( + projectDir = flag.String("project", "", "initial project directory (default $PWD)") + listenAddr = flag.String("listen", "", "optional TCP listen address for remote human clients (for example 127.0.0.1:2488, 0.0.0.0:2488, or 2488)") + ) flag.Parse() cwd, err := os.Getwd() if err != nil { @@ -244,11 +252,69 @@ func runDaemonCommand() { } else if flag.NArg() > 0 { cwd = flag.Arg(0) } - if err := app.RunDaemon(context.Background(), app.DaemonOptions{ProjectDir: cwd}); err != nil { + if err := app.RunDaemon(context.Background(), app.DaemonOptions{ProjectDir: cwd, ListenAddr: *listenAddr}); err != nil { die("daemon: %v", err) } } +func runConnectCommand() { + var ( + host = flag.String("host", "", "remote daemon host:port") + token = flag.String("token", "", "remote daemon token (default PATTERM_TOKEN or stored token file)") + projectDir = flag.String("project", "", "project directory to request on the daemon") + ) + flag.Parse() + if *host == "" && flag.NArg() > 0 { + *host = flag.Arg(0) + } + if *host == "" { + die("connect: --host HOST:PORT is required") + } + tok := *token + if tok == "" { + tok = os.Getenv("PATTERM_TOKEN") + } + if tok == "" { + if stored, err := app.LoadClientToken(); err == nil { + tok = stored + } + } + if tok == "" { + die("connect: token required via --token, PATTERM_TOKEN, or %s", mustTokenPath()) + } + cwd := *projectDir + if cwd == "" { + var err error + cwd, err = os.Getwd() + if err != nil { + die("getwd: %v", err) + } + } + tr, err := app.DialTCPTransport(*host) + if err != nil { + die("connect: %v", err) + } + defer tr.Close() + if err := app.RunAttachedClient(context.Background(), app.ClientOptions{ + ProjectDir: cwd, + Transport: tr, + Stdin: os.Stdin, + Stdout: os.Stdout, + RawMode: true, + Token: tok, + }); err != nil { + die("connect: %v", err) + } +} + +func mustTokenPath() string { + path, err := app.ClientTokenPath() + if err != nil { + return "$XDG_DATA_HOME/patterm/clients/token" + } + return path +} + func runDaemonList() { projects, err := daemonRequest(protocol.Frame{Type: protocol.FrameList}) if err != nil { diff --git a/internal/app/client_net.go b/internal/app/client_net.go index 9169477..033bd84 100644 --- a/internal/app/client_net.go +++ b/internal/app/client_net.go @@ -33,6 +33,7 @@ type ClientOptions struct { Stdout io.Writer RawMode bool AutoStart bool + Token string Cols uint16 Rows uint16 } @@ -66,6 +67,14 @@ func RunAttachedClient(ctx context.Context, opts ClientOptions) error { return c.run(ctx) } +func DialTCPTransport(addr string) (protocol.Transport, error) { + conn, err := net.Dial("tcp", addr) + if err != nil { + return nil, err + } + return protocol.NewConnTransport(conn), nil +} + func dialDaemonTransport(projectDir string, autoStart bool) (protocol.Transport, error) { socket, _, err := RuntimeDaemonPaths() if err != nil { @@ -120,6 +129,7 @@ type netClient struct { out io.Writer raw bool projectDir string + token string layout terminalLayout mu sync.Mutex @@ -141,6 +151,7 @@ func newNetClient(opts ClientOptions) *netClient { out: opts.Stdout, raw: opts.RawMode, projectDir: opts.ProjectDir, + token: opts.Token, layout: layout, renderer: newViewportRenderer(layout), } @@ -204,6 +215,7 @@ func (c *netClient) run(ctx context.Context) error { func (c *netClient) sendAttach() error { f, err := protocol.NewFrame(protocol.FrameAttach, protocol.Attach{ ProjectPath: c.projectPath(), + Token: c.token, TermSize: protocol.Size{ Cols: c.layout.childCols(), Rows: c.layout.childRows(), diff --git a/internal/app/daemon_net.go b/internal/app/daemon_net.go index 2e7a2a4..4163bd3 100644 --- a/internal/app/daemon_net.go +++ b/internal/app/daemon_net.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net" "os" "path/filepath" @@ -20,11 +21,15 @@ import ( ) type DaemonOptions struct { - ProjectDir string - SocketPath string - PidPath string - Cols uint16 - Rows uint16 + ProjectDir string + SocketPath string + PidPath string + ListenAddr string + Token string + TokenOut io.Writer + ListenReady chan string + Cols uint16 + Rows uint16 } type DaemonStatus struct { @@ -113,28 +118,101 @@ func RunDaemon(ctx context.Context, opts DaemonOptions) error { return err } + var tcpLn net.Listener + tcpToken := opts.Token + if opts.ListenAddr != "" { + addr := normalizeListenAddr(opts.ListenAddr) + tcpToken, err = ensureDaemonToken(tcpToken) + if err != nil { + return err + } + tcpLn, err = net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("daemon: listen tcp %s: %w", addr, err) + } + defer tcpLn.Close() + if opts.ListenReady != nil { + select { + case opts.ListenReady <- tcpLn.Addr().String(): + default: + } + } + out := opts.TokenOut + if out == nil { + out = os.Stderr + } + fmt.Fprintf(out, "patterm daemon listening on %s\npatterm token: %s\n", tcpLn.Addr().String(), tcpToken) + } + var wg sync.WaitGroup go func() { <-ctx.Done() _ = ln.Close() + if tcpLn != nil { + _ = tcpLn.Close() + } }() + errCh := make(chan error, 2) + go acceptDaemonLoop(ctx, &wg, ln, "", cancel, registry, errCh) + if tcpLn != nil { + go acceptDaemonLoop(ctx, &wg, tcpLn, tcpToken, cancel, registry, errCh) + } + select { + case <-ctx.Done(): + case err := <-errCh: + cancel() + wg.Wait() + return err + } + wg.Wait() + return nil +} + +func acceptDaemonLoop(ctx context.Context, wg *sync.WaitGroup, ln net.Listener, authToken string, stop func(), registry *ProjectRegistry, errCh chan<- error) { for { conn, err := ln.Accept() if err != nil { if errors.Is(err, net.ErrClosed) || ctx.Err() != nil { - wg.Wait() - return nil + return } - continue + select { + case errCh <- err: + default: + } + return } wg.Add(1) go func() { defer wg.Done() - handleDaemonConn(ctx, cancel, registry, protocol.NewConnTransport(conn)) + handleDaemonConn(ctx, stop, registry, protocol.NewConnTransport(conn), authToken) }() } } +func normalizeListenAddr(addr string) string { + addr = strings.TrimSpace(addr) + if addr == "" { + return "" + } + if _, _, err := net.SplitHostPort(addr); err == nil { + return addr + } + if strings.HasPrefix(addr, ":") { + return addr + } + if _, err := strconv.Atoi(addr); err == nil { + return ":" + addr + } + return addr +} + +func ensureDaemonToken(token string) (string, error) { + if strings.TrimSpace(token) != "" { + return strings.TrimSpace(token), nil + } + return LoadOrCreateClientToken() +} + func prepareDaemonSocket(socketPath, pidPath string) (string, error) { if err := os.MkdirAll(filepath.Dir(socketPath), 0o700); err != nil { return "", err @@ -163,7 +241,7 @@ func syscallSignal0(pid int) error { return syscall.Kill(pid, 0) } -func handleDaemonConn(ctx context.Context, stop func(), registry *ProjectRegistry, t protocol.Transport) { +func handleDaemonConn(ctx context.Context, stop func(), registry *ProjectRegistry, t protocol.Transport, authToken string) { defer t.Close() f, err := t.Recv() if err != nil { @@ -178,6 +256,17 @@ func handleDaemonConn(ctx context.Context, stop func(), registry *ProjectRegistr stop() return case protocol.FrameAttach: + if authToken != "" { + attach, err := protocol.Decode[protocol.Attach](f) + if err != nil { + _ = sendProtocolError(t, err.Error()) + return + } + if attach.Token != authToken { + _ = sendProtocolError(t, "auth denied") + return + } + } handleDaemonAttach(ctx, registry, t, f) default: _ = sendProtocolError(t, fmt.Sprintf("first frame must be attach, list, or stop; got %q", f.Type)) diff --git a/internal/app/daemon_net_test.go b/internal/app/daemon_net_test.go index d8f439e..5060af9 100644 --- a/internal/app/daemon_net_test.go +++ b/internal/app/daemon_net_test.go @@ -3,6 +3,7 @@ package app import ( "context" "encoding/json" + "io" "net" "os" "path/filepath" @@ -85,6 +86,80 @@ func TestDaemonDetachReattachPreservesProcess(t *testing.T) { } } +func TestDaemonTCPTokenAuthAndUnixExemption(t *testing.T) { + root := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "config")) + t.Setenv("XDG_DATA_HOME", filepath.Join(root, "data")) + t.Setenv("XDG_RUNTIME_DIR", filepath.Join(root, "runtime")) + projectDir := filepath.Join(root, "project") + if err := os.MkdirAll(projectDir, 0o700); err != nil { + t.Fatalf("mkdir project: %v", err) + } + socket := filepath.Join(root, "runtime", "patterm", "daemon.sock") + pid := filepath.Join(root, "runtime", "patterm", "daemon.pid") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + errCh := make(chan error, 1) + ready := make(chan string, 1) + go func() { + errCh <- RunDaemon(ctx, DaemonOptions{ + ProjectDir: projectDir, + SocketPath: socket, + PidPath: pid, + ListenAddr: "127.0.0.1:0", + Token: "secret-token", + TokenOut: io.Discard, + ListenReady: ready, + Cols: 80, + Rows: 24, + }) + }() + waitForSocket(t, socket, errCh) + tcpAddr := waitForTCPAddr(t, ready, errCh) + + assertTCPAttachDenied(t, tcpAddr, "") + assertTCPAttachDenied(t, tcpAddr, "wrong-token") + + tcpClient := dialTCPDaemon(t, tcpAddr) + defer tcpClient.Close() + sendFrame(t, tcpClient, protocol.FrameAttach, protocol.Attach{ + Token: "secret-token", + ProjectPath: projectDir, + TermSize: protocol.Size{Cols: 80, Rows: 24}, + }) + expectFrame(t, tcpClient, protocol.FrameHello) + expectFrame(t, tcpClient, protocol.FrameProjectList) + expectFrame(t, tcpClient, protocol.FrameChrome) + data, _ := json.Marshal(map[string]any{ + "argv": []string{"sh", "-c", "trap 'exit 0' TERM; echo TCP-SNAPSHOT; sleep 30"}, + "name": "tcp-survivor", + }) + sendFrame(t, tcpClient, protocol.FramePaletteCommand, protocol.PaletteCommand{ + Kind: "spawn_command", + Data: data, + }) + expectFrame(t, tcpClient, protocol.FramePaneSnapshot) + + unixClient := dialDaemon(t, socket) + defer unixClient.Close() + sendFrame(t, unixClient, protocol.FrameAttach, protocol.Attach{ + ProjectPath: projectDir, + TermSize: protocol.Size{Cols: 80, Rows: 24}, + }) + expectFrame(t, unixClient, protocol.FrameHello) + + cancel() + select { + case err := <-errCh: + if err != nil { + t.Fatalf("daemon returned error: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatalf("daemon did not stop") + } +} + func waitForSocket(t *testing.T, socket string, errCh <-chan error) { t.Helper() deadline := time.Now().Add(3 * time.Second) @@ -114,6 +189,49 @@ func dialDaemon(t *testing.T, socket string) protocol.Transport { return protocol.NewConnTransport(conn) } +func dialTCPDaemon(t *testing.T, addr string) protocol.Transport { + t.Helper() + conn, err := net.Dial("tcp", addr) + if err != nil { + t.Fatalf("dial tcp daemon: %v", err) + } + return protocol.NewConnTransport(conn) +} + +func waitForTCPAddr(t *testing.T, ready <-chan string, errCh <-chan error) string { + t.Helper() + select { + case addr := <-ready: + return addr + case err := <-errCh: + if err != nil && strings.Contains(err.Error(), "operation not permitted") { + t.Skipf("tcp sockets unavailable in this sandbox: %v", err) + } + t.Fatalf("daemon exited before TCP listener was ready: %v", err) + case <-time.After(3 * time.Second): + t.Fatalf("tcp listener was not ready") + } + return "" +} + +func assertTCPAttachDenied(t *testing.T, addr, token string) { + t.Helper() + client := dialTCPDaemon(t, addr) + defer client.Close() + sendFrame(t, client, protocol.FrameAttach, protocol.Attach{ + Token: token, + TermSize: protocol.Size{Cols: 80, Rows: 24}, + }) + f := expectFrame(t, client, protocol.FrameError) + msg, err := protocol.Decode[protocol.Error](f) + if err != nil { + t.Fatalf("decode error frame: %v", err) + } + if !strings.Contains(msg.Message, "auth denied") { + t.Fatalf("error message = %q, want auth denied", msg.Message) + } +} + func sendFrame[T any](t *testing.T, tr protocol.Transport, typ protocol.FrameType, payload T) { t.Helper() f, err := protocol.NewFrame(typ, payload) diff --git a/internal/app/token.go b/internal/app/token.go new file mode 100644 index 0000000..e563be6 --- /dev/null +++ b/internal/app/token.go @@ -0,0 +1,63 @@ +package app + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" +) + +func ClientTokenPath() (string, error) { + base := os.Getenv("XDG_DATA_HOME") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + base = filepath.Join(home, ".local", "share") + } + return filepath.Join(base, "patterm", "clients", "token"), nil +} + +func LoadClientToken() (string, error) { + path, err := ClientTokenPath() + if err != nil { + return "", err + } + b, err := os.ReadFile(path) + if err != nil { + return "", err + } + return strings.TrimSpace(string(b)), nil +} + +func LoadOrCreateClientToken() (string, error) { + if token, err := LoadClientToken(); err == nil && token != "" { + return token, nil + } + token, err := generateClientToken() + if err != nil { + return "", err + } + path, err := ClientTokenPath() + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return "", err + } + if err := os.WriteFile(path, []byte(token+"\n"), 0o600); err != nil { + return "", err + } + return token, nil +} + +func generateClientToken() (string, error) { + var b [32]byte + if _, err := rand.Read(b[:]); err != nil { + return "", fmt.Errorf("token: random: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b[:]), nil +}