add tcp daemon listener with token auth

This commit is contained in:
2026-05-27 14:19:14 +01:00
parent 5149224000
commit 63cb8a4388
6 changed files with 368 additions and 12 deletions

View File

@@ -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 on demand and attaches a thin terminal client over the unix-socket
transport; `--in-process` or `PATTERM_NO_DAEMON=1` keeps the legacy transport; `--in-process` or `PATTERM_NO_DAEMON=1` keeps the legacy
single-process path available as an escape hatch. 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 - patterm can now keep multiple local projects loaded in one loopback
daemon core, with command-palette entries to switch the current daemon core, with command-palette entries to switch the current
client view or open another project without tearing down processes client view or open another project without tearing down processes

View File

@@ -56,6 +56,11 @@ func main() {
runDaemonCommand() runDaemonCommand()
return 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" { if len(os.Args) >= 2 && os.Args[1] == "ls" {
runDaemonList() runDaemonList()
return return
@@ -233,7 +238,10 @@ func runDaemonCommand() {
runDaemonList() runDaemonList()
return 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() flag.Parse()
cwd, err := os.Getwd() cwd, err := os.Getwd()
if err != nil { if err != nil {
@@ -244,11 +252,69 @@ func runDaemonCommand() {
} else if flag.NArg() > 0 { } else if flag.NArg() > 0 {
cwd = flag.Arg(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) 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() { func runDaemonList() {
projects, err := daemonRequest(protocol.Frame{Type: protocol.FrameList}) projects, err := daemonRequest(protocol.Frame{Type: protocol.FrameList})
if err != nil { if err != nil {

View File

@@ -33,6 +33,7 @@ type ClientOptions struct {
Stdout io.Writer Stdout io.Writer
RawMode bool RawMode bool
AutoStart bool AutoStart bool
Token string
Cols uint16 Cols uint16
Rows uint16 Rows uint16
} }
@@ -66,6 +67,14 @@ func RunAttachedClient(ctx context.Context, opts ClientOptions) error {
return c.run(ctx) 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) { func dialDaemonTransport(projectDir string, autoStart bool) (protocol.Transport, error) {
socket, _, err := RuntimeDaemonPaths() socket, _, err := RuntimeDaemonPaths()
if err != nil { if err != nil {
@@ -120,6 +129,7 @@ type netClient struct {
out io.Writer out io.Writer
raw bool raw bool
projectDir string projectDir string
token string
layout terminalLayout layout terminalLayout
mu sync.Mutex mu sync.Mutex
@@ -141,6 +151,7 @@ func newNetClient(opts ClientOptions) *netClient {
out: opts.Stdout, out: opts.Stdout,
raw: opts.RawMode, raw: opts.RawMode,
projectDir: opts.ProjectDir, projectDir: opts.ProjectDir,
token: opts.Token,
layout: layout, layout: layout,
renderer: newViewportRenderer(layout), renderer: newViewportRenderer(layout),
} }
@@ -204,6 +215,7 @@ func (c *netClient) run(ctx context.Context) error {
func (c *netClient) sendAttach() error { func (c *netClient) sendAttach() error {
f, err := protocol.NewFrame(protocol.FrameAttach, protocol.Attach{ f, err := protocol.NewFrame(protocol.FrameAttach, protocol.Attach{
ProjectPath: c.projectPath(), ProjectPath: c.projectPath(),
Token: c.token,
TermSize: protocol.Size{ TermSize: protocol.Size{
Cols: c.layout.childCols(), Cols: c.layout.childCols(),
Rows: c.layout.childRows(), Rows: c.layout.childRows(),

View File

@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"net" "net"
"os" "os"
"path/filepath" "path/filepath"
@@ -20,11 +21,15 @@ import (
) )
type DaemonOptions struct { type DaemonOptions struct {
ProjectDir string ProjectDir string
SocketPath string SocketPath string
PidPath string PidPath string
Cols uint16 ListenAddr string
Rows uint16 Token string
TokenOut io.Writer
ListenReady chan string
Cols uint16
Rows uint16
} }
type DaemonStatus struct { type DaemonStatus struct {
@@ -113,28 +118,101 @@ func RunDaemon(ctx context.Context, opts DaemonOptions) error {
return err 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 var wg sync.WaitGroup
go func() { go func() {
<-ctx.Done() <-ctx.Done()
_ = ln.Close() _ = 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 { for {
conn, err := ln.Accept() conn, err := ln.Accept()
if err != nil { if err != nil {
if errors.Is(err, net.ErrClosed) || ctx.Err() != nil { if errors.Is(err, net.ErrClosed) || ctx.Err() != nil {
wg.Wait() return
return nil
} }
continue select {
case errCh <- err:
default:
}
return
} }
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() 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) { func prepareDaemonSocket(socketPath, pidPath string) (string, error) {
if err := os.MkdirAll(filepath.Dir(socketPath), 0o700); err != nil { if err := os.MkdirAll(filepath.Dir(socketPath), 0o700); err != nil {
return "", err return "", err
@@ -163,7 +241,7 @@ func syscallSignal0(pid int) error {
return syscall.Kill(pid, 0) 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() defer t.Close()
f, err := t.Recv() f, err := t.Recv()
if err != nil { if err != nil {
@@ -178,6 +256,17 @@ func handleDaemonConn(ctx context.Context, stop func(), registry *ProjectRegistr
stop() stop()
return return
case protocol.FrameAttach: 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) handleDaemonAttach(ctx, registry, t, f)
default: default:
_ = sendProtocolError(t, fmt.Sprintf("first frame must be attach, list, or stop; got %q", f.Type)) _ = sendProtocolError(t, fmt.Sprintf("first frame must be attach, list, or stop; got %q", f.Type))

View File

@@ -3,6 +3,7 @@ package app
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"io"
"net" "net"
"os" "os"
"path/filepath" "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) { func waitForSocket(t *testing.T, socket string, errCh <-chan error) {
t.Helper() t.Helper()
deadline := time.Now().Add(3 * time.Second) deadline := time.Now().Add(3 * time.Second)
@@ -114,6 +189,49 @@ func dialDaemon(t *testing.T, socket string) protocol.Transport {
return protocol.NewConnTransport(conn) 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) { func sendFrame[T any](t *testing.T, tr protocol.Transport, typ protocol.FrameType, payload T) {
t.Helper() t.Helper()
f, err := protocol.NewFrame(typ, payload) f, err := protocol.NewFrame(typ, payload)

63
internal/app/token.go Normal file
View File

@@ -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
}