Persistent daemon + thin networked client #9
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
63
internal/app/token.go
Normal file
63
internal/app/token.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user