diff --git a/cmd/olliesrv/internal/toolclient/spawn.go b/cmd/olliesrv/internal/toolclient/spawn.go index a7754ce..cdf382c 100644 --- a/cmd/olliesrv/internal/toolclient/spawn.go +++ b/cmd/olliesrv/internal/toolclient/spawn.go @@ -85,6 +85,7 @@ func Spawn(ctx context.Context, cwd string, opts ...Option) (*Process, error) { // Note: secret is NOT passed via env var. It's established on first // connection via 9P Tauth (socket permissions + SSH handle security). cmd.Stderr = os.Stderr // Let server errors go to stderr for debugging + cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGTERM} if err := cmd.Start(); err != nil { cancel() @@ -333,6 +334,10 @@ func (pk *ProcessKeeper) Dial() (*toolsrvclient.Conn, error) { } if pk.respawn != nil { + // Kill the old process to prevent orphans. + if pk.proc != nil { + pk.proc.Kill() + } proc, err := pk.respawn(pk.ctx) if err != nil { return nil, fmt.Errorf("respawn: %w", err) diff --git a/cmd/toolsrv/auth9p.go b/cmd/toolsrv/auth9p.go deleted file mode 100644 index 971a793..0000000 --- a/cmd/toolsrv/auth9p.go +++ /dev/null @@ -1,17 +0,0 @@ -// auth9p.go - Authentication helpers for 9P toolsrv -package main - -import ( - "crypto/rand" - "encoding/hex" -) - -// GenerateSecret generates a random shared secret for toolsrv auth. -// The secret is transmitted over the socket via 9P Tauth. -func GenerateSecret() (string, error) { - secret := make([]byte, 32) - if _, err := rand.Read(secret); err != nil { - return "", err - } - return hex.EncodeToString(secret), nil -} diff --git a/cmd/toolsrv/exec9p.go b/cmd/toolsrv/internal/exec/exec.go similarity index 94% rename from cmd/toolsrv/exec9p.go rename to cmd/toolsrv/internal/exec/exec.go index 6e3f2d8..3c7462c 100644 --- a/cmd/toolsrv/exec9p.go +++ b/cmd/toolsrv/internal/exec/exec.go @@ -1,5 +1,5 @@ -// exec9p.go - Decoupled tool execution for 9P toolsrv -package main +// Package exec handles tool execution in sandboxed environments. +package exec import ( "bytes" @@ -21,9 +21,8 @@ import ( "ollie/toolsrv" ) -// ExecConfig contains all context needed for tool execution. -// This decouples execution from Server state. -type ExecConfig struct { +// Config contains all context needed for tool execution. +type Config struct { CWD string Env map[string]string Yolo bool @@ -46,8 +45,7 @@ func WithStreamFunc(ctx context.Context, fn func(string)) context.Context { } // ExecuteTool runs a tool script with the given args and returns the result. -// This is the core execution function, decoupled from Server. -func ExecuteTool(ctx context.Context, info toolsrv.ToolInfo, args json.RawMessage, cfg ExecConfig) (json.RawMessage, error) { +func ExecuteTool(ctx context.Context, info toolsrv.ToolInfo, args json.RawMessage, cfg Config) (json.RawMessage, error) { // Resolve script path toolPath, err := toolsrv.ResolveTool(info.Name) if err != nil { @@ -169,7 +167,7 @@ func executeSandboxed(ctx context.Context, toolPath, stdinData, cwd string, envE } // Compute NAMESPACE if not set if _, ok := envMap["NAMESPACE"]; !ok { - if ns := plan9Namespace(envMap); ns != "" { + if ns := Plan9Namespace(envMap); ns != "" { envMap["NAMESPACE"] = ns } } @@ -333,8 +331,8 @@ func readBypassFrames(ctx context.Context, conn net.Conn, w *bytes.Buffer, strea } } -// plan9Namespace computes the Plan 9 namespace directory. -func plan9Namespace(env map[string]string) string { +// Plan9Namespace computes the Plan 9 namespace directory. +func Plan9Namespace(env map[string]string) string { if ns := env["NAMESPACE"]; ns != "" { return ns } diff --git a/cmd/toolsrv/internal/fs/parse.go b/cmd/toolsrv/internal/fs/parse.go new file mode 100644 index 0000000..8d4946a --- /dev/null +++ b/cmd/toolsrv/internal/fs/parse.go @@ -0,0 +1,46 @@ +// parse.go - Key=value payload parsing for toolsrv. +package fs + +import "strings" + +// ParsePayload parses key=value lines into a map. +// Values are unescaped: \n → newline, \\ → backslash. +func ParsePayload(payload string) map[string]string { + args := make(map[string]string) + for _, line := range strings.Split(payload, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + idx := strings.Index(line, "=") + if idx < 0 { + continue + } + key := line[:idx] + value := line[idx+1:] + value = UnescapeValue(value) + args[key] = value + } + return args +} + +// UnescapeValue reverses escape sequences: \n → newline, \\ → backslash. +func UnescapeValue(s string) string { + result := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + if s[i] == '\\' && i+1 < len(s) { + switch s[i+1] { + case 'n': + result = append(result, '\n') + i++ + continue + case '\\': + result = append(result, '\\') + i++ + continue + } + } + result = append(result, s[i]) + } + return string(result) +} diff --git a/cmd/toolsrv/fs9p.go b/cmd/toolsrv/internal/fs/proc.go similarity index 53% rename from cmd/toolsrv/fs9p.go rename to cmd/toolsrv/internal/fs/proc.go index 353655f..32ac269 100644 --- a/cmd/toolsrv/fs9p.go +++ b/cmd/toolsrv/internal/fs/proc.go @@ -1,27 +1,5 @@ -// Package toolsrv provides a 9P filesystem interface to the tool execution server. -// -// Namespace: -// -// /ctl write: load , unload -// /tools read: list loaded tools (name\tdesc per line) -// write: tool name to load -// /proc/ dir: running/completed processes -// /proc/new rdwr: write tool+args, blocks, read result (auto-cleanup) -// /proc/new.bg write: write tool+args, returns pid immediately (detached) -// /proc// dir: process directory -// /proc//out read: output stream -// /proc//wait read: blocks until exit, returns exit code -// /proc//stat read: status (running, exited N, runtime) -// /proc//ctl write: signal , dismiss -// /info read: host info (JSON) -// -// Tool call payload format: key=value lines, newline delimited. -// Example: -// -// tool=shell -// cmd=ls -la -// timeout=30 -package main +// proc.go - Process management and tool operations for toolsrv. +package fs import ( "bytes" @@ -37,23 +15,24 @@ import ( "syscall" "time" + "ollie/cmd/toolsrv/internal/exec" + "ollie/cmd/toolsrv/internal/registry" "ollie/toolsrv" ) -// FS9P implements a 9P-style tool server. -// This is the core logic; the actual 9P serving is handled separately. -type FS9P struct { +// State implements the filesystem state for toolsrv. +type State struct { mu sync.RWMutex cwd string env map[string]string - registry *Registry + registry *registry.Registry sessID string yolo bool // Process management procMu sync.Mutex - procs map[int]*Proc9P + procs map[int]*Proc nextPID int procLimit int @@ -61,8 +40,8 @@ type FS9P struct { OnToolsChanged func() } -// Proc9P represents a running or completed process. -type Proc9P struct { +// Proc represents a running or completed process. +type Proc struct { PID int Tool string Args map[string]string @@ -78,71 +57,71 @@ type Proc9P struct { proc *os.Process // for signaling } -// NewFS9P creates a new 9P tool server. -func NewFS9P(cwd string) *FS9P { - return &FS9P{ +// NewState creates a new filesystem state. +func NewState(cwd string) *State { + return &State{ cwd: cwd, env: make(map[string]string), - procs: make(map[int]*Proc9P), + procs: make(map[int]*Proc), nextPID: 1, procLimit: 32, } } // SetRegistry configures the tool registry and session ID. -func (fs *FS9P) SetRegistry(r *Registry, sessID string) { - fs.mu.Lock() - fs.registry = r - fs.sessID = sessID - fs.mu.Unlock() +func (st *State) SetRegistry(r *registry.Registry, sessID string) { + st.mu.Lock() + st.registry = r + st.sessID = sessID + st.mu.Unlock() } // SetYolo enables/disables sandbox bypass. -func (fs *FS9P) SetYolo(yolo bool) { - fs.mu.Lock() - fs.yolo = yolo - fs.mu.Unlock() +func (st *State) SetYolo(yolo bool) { + st.mu.Lock() + st.yolo = yolo + st.mu.Unlock() } // CWD returns the current working directory. -func (fs *FS9P) CWD() string { - fs.mu.RLock() - defer fs.mu.RUnlock() - return fs.cwd +func (st *State) CWD() string { + st.mu.RLock() + defer st.mu.RUnlock() + return st.cwd } // SetCWD sets the current working directory. -func (fs *FS9P) SetCWD(dir string) { - fs.mu.Lock() - fs.cwd = dir - fs.mu.Unlock() +func (st *State) SetCWD(dir string) { + st.mu.Lock() + st.cwd = dir + st.mu.Unlock() } // SetEnv sets an environment variable. -func (fs *FS9P) SetEnv(key, value string) { - fs.mu.Lock() - if fs.env == nil { - fs.env = make(map[string]string) +func (st *State) SetEnv(key, value string) { + st.mu.Lock() + if st.env == nil { + st.env = make(map[string]string) } - fs.env[key] = value - fs.mu.Unlock() + st.env[key] = value + st.mu.Unlock() } // GetEnv returns an environment variable value. -func (fs *FS9P) GetEnv(key string) string { - fs.mu.RLock() - defer fs.mu.RUnlock() - return fs.env[key] +func (st *State) GetEnv(key string) string { + st.mu.RLock() + defer st.mu.RUnlock() + return st.env[key] } // --- Tool Registry --- // ListTools returns loaded tools for the current session. -func (fs *FS9P) ListTools() []toolsrv.ToolInfo { - fs.mu.RLock() - reg := fs.registry - sid := fs.sessID - fs.mu.RUnlock() +func (st *State) ListTools() []toolsrv.ToolInfo { + st.mu.RLock() + reg := st.registry + sid := st.sessID + st.mu.RUnlock() if reg == nil || sid == "" { return nil @@ -151,11 +130,11 @@ func (fs *FS9P) ListTools() []toolsrv.ToolInfo { } // LoadTool loads a tool by name into the session registry. -func (fs *FS9P) LoadTool(name string) error { - fs.mu.RLock() - reg := fs.registry - sid := fs.sessID - fs.mu.RUnlock() +func (st *State) LoadTool(name string) error { + st.mu.RLock() + reg := st.registry + sid := st.sessID + st.mu.RUnlock() if reg == nil { return fmt.Errorf("no tool registry configured") @@ -167,18 +146,18 @@ func (fs *FS9P) LoadTool(name string) error { if err := reg.Load(sid, name); err != nil { return err } - if fs.OnToolsChanged != nil { - fs.OnToolsChanged() + if st.OnToolsChanged != nil { + st.OnToolsChanged() } return nil } // UnloadTool removes a tool from the session registry. -func (fs *FS9P) UnloadTool(name string) error { - fs.mu.RLock() - reg := fs.registry - sid := fs.sessID - fs.mu.RUnlock() +func (st *State) UnloadTool(name string) error { + st.mu.RLock() + reg := st.registry + sid := st.sessID + st.mu.RUnlock() if reg == nil { return fmt.Errorf("no tool registry configured") @@ -190,8 +169,8 @@ func (fs *FS9P) UnloadTool(name string) error { if err := reg.Unload(sid, name); err != nil { return err } - if fs.OnToolsChanged != nil { - fs.OnToolsChanged() + if st.OnToolsChanged != nil { + st.OnToolsChanged() } return nil } @@ -199,44 +178,44 @@ func (fs *FS9P) UnloadTool(name string) error { // --- Process Management --- // allocPID allocates a new process ID. -func (fs *FS9P) allocPID() int { - fs.procMu.Lock() - defer fs.procMu.Unlock() - pid := fs.nextPID - fs.nextPID++ +func (st *State) allocPID() int { + st.procMu.Lock() + defer st.procMu.Unlock() + pid := st.nextPID + st.nextPID++ return pid } // NewProc creates a new process and starts execution. // If background is false, blocks until completion and returns result. // If background is true, returns immediately with pid. -func (fs *FS9P) NewProc(ctx context.Context, payload string, background bool) (result string, pid int, err error) { +func (st *State) NewProc(ctx context.Context, payload string, background bool) (result string, pid int, err error) { // Parse payload - args := parsePayload(payload) + args := ParsePayload(payload) toolName := args["tool"] if toolName == "" { return "", 0, fmt.Errorf("missing 'tool' in payload") } // Check proc limit - fs.procMu.Lock() - if len(fs.procs) >= fs.procLimit { - fs.procMu.Unlock() - return "", 0, fmt.Errorf("process limit reached (%d)", fs.procLimit) + st.procMu.Lock() + if len(st.procs) >= st.procLimit { + st.procMu.Unlock() + return "", 0, fmt.Errorf("process limit reached (%d)", st.procLimit) } - fs.procMu.Unlock() + st.procMu.Unlock() // Look up tool - fs.mu.RLock() - reg := fs.registry - sid := fs.sessID - cwd := fs.cwd - yolo := fs.yolo + st.mu.RLock() + reg := st.registry + sid := st.sessID + cwd := st.cwd + yolo := st.yolo envCopy := make(map[string]string) - for k, v := range fs.env { + for k, v := range st.env { envCopy[k] = v } - fs.mu.RUnlock() + st.mu.RUnlock() if reg == nil || sid == "" { return "", 0, fmt.Errorf("tool registry not configured") @@ -249,8 +228,8 @@ func (fs *FS9P) NewProc(ctx context.Context, payload string, background bool) (r // Create proc procCtx, cancel := context.WithCancel(ctx) - proc := &Proc9P{ - PID: fs.allocPID(), + proc := &Proc{ + PID: st.allocPID(), Tool: toolName, Args: args, StartTime: time.Now(), @@ -258,16 +237,16 @@ func (fs *FS9P) NewProc(ctx context.Context, payload string, background bool) (r cancel: cancel, } - fs.procMu.Lock() - fs.procs[proc.PID] = proc - fs.procMu.Unlock() + st.procMu.Lock() + st.procs[proc.PID] = proc + st.procMu.Unlock() // Execute in goroutine go func() { defer close(proc.done) defer cancel() - out, exitCode := fs.executeTool(procCtx, info, args, cwd, envCopy, yolo) + out, exitCode := st.executeTool(procCtx, info, args, cwd, envCopy, yolo) proc.mu.Lock() proc.output.WriteString(out) @@ -290,9 +269,9 @@ func (fs *FS9P) NewProc(ctx context.Context, payload string, background bool) (r proc.mu.Unlock() // Auto-cleanup for foreground procs - fs.procMu.Lock() - delete(fs.procs, proc.PID) - fs.procMu.Unlock() + st.procMu.Lock() + delete(st.procs, proc.PID) + st.procMu.Unlock() if exitCode != 0 { return result, proc.PID, fmt.Errorf("exit %d", exitCode) @@ -301,17 +280,17 @@ func (fs *FS9P) NewProc(ctx context.Context, payload string, background bool) (r } // executeTool runs a tool and returns output + exit code. -func (fs *FS9P) executeTool(ctx context.Context, info toolsrv.ToolInfo, args map[string]string, cwd string, envExtra map[string]string, yolo bool) (string, int) { +func (st *State) executeTool(ctx context.Context, info toolsrv.ToolInfo, args map[string]string, cwd string, envExtra map[string]string, yolo bool) (string, int) { // Convert args map to JSON for the execution path jsonArgs := argsToJSON(args) - cfg := ExecConfig{ + cfg := exec.Config{ CWD: cwd, Env: envExtra, Yolo: yolo, } - result, err := ExecuteTool(ctx, info, jsonArgs, cfg) + result, err := exec.ExecuteTool(ctx, info, jsonArgs, cfg) if err != nil { return fmt.Sprintf("error: %v", err), 1 } @@ -319,37 +298,37 @@ func (fs *FS9P) executeTool(ctx context.Context, info toolsrv.ToolInfo, args map } // GetProc returns a process by PID. -func (fs *FS9P) GetProc(pid int) *Proc9P { - fs.procMu.Lock() - defer fs.procMu.Unlock() - return fs.procs[pid] +func (st *State) GetProc(pid int) *Proc { + st.procMu.Lock() + defer st.procMu.Unlock() + return st.procs[pid] } // ListProcs returns all process PIDs. -func (fs *FS9P) ListProcs() []int { - fs.procMu.Lock() - defer fs.procMu.Unlock() - pids := make([]int, 0, len(fs.procs)) - for pid := range fs.procs { +func (st *State) ListProcs() []int { + st.procMu.Lock() + defer st.procMu.Unlock() + pids := make([]int, 0, len(st.procs)) + for pid := range st.procs { pids = append(pids, pid) } return pids } // DismissProc removes a process from the list. -func (fs *FS9P) DismissProc(pid int) bool { - fs.procMu.Lock() - defer fs.procMu.Unlock() - if _, ok := fs.procs[pid]; ok { - delete(fs.procs, pid) +func (st *State) DismissProc(pid int) bool { + st.procMu.Lock() + defer st.procMu.Unlock() + if _, ok := st.procs[pid]; ok { + delete(st.procs, pid) return true } return false } // SignalProc sends a signal to a process. -func (fs *FS9P) SignalProc(pid int, sig syscall.Signal) error { - proc := fs.GetProc(pid) +func (st *State) SignalProc(pid int, sig syscall.Signal) error { + proc := st.GetProc(pid) if proc == nil { return fmt.Errorf("process not found: %d", pid) } @@ -372,68 +351,147 @@ func (fs *FS9P) SignalProc(pid int, sig syscall.Signal) error { // --- Host Info --- -// HostInfo9P returns host information. -func (fs *FS9P) HostInfo9P() string { - fs.mu.RLock() - cwd := fs.cwd - fs.mu.RUnlock() +// HostInfo returns host information. +func (st *State) HostInfo() string { + st.mu.RLock() + cwd := st.cwd + st.mu.RUnlock() return fmt.Sprintf("platform=%s\narch=%s\ncwd=%s\ngit=%v\n", - runtime.GOOS, runtime.GOARCH, cwd, isGitRepo9P(cwd)) + runtime.GOOS, runtime.GOARCH, cwd, isGitRepo(cwd)) } -func isGitRepo9P(dir string) bool { +func isGitRepo(dir string) bool { info, err := os.Stat(dir + "/.git") return err == nil && info.IsDir() } -// --- Payload Parsing --- +// --- Request Handlers --- -// parsePayload parses key=value lines into a map. -func parsePayload(payload string) map[string]string { - args := make(map[string]string) - for _, line := range strings.Split(payload, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue +// HandleCtl processes ctl commands. +func (st *State) HandleCtl(input string) error { + parts := strings.Fields(input) + if len(parts) == 0 { + return nil + } + + switch parts[0] { + case "load": + if len(parts) < 2 { + return fmt.Errorf("load requires tool name") } - idx := strings.Index(line, "=") + return st.LoadTool(parts[1]) + case "unload": + if len(parts) < 2 { + return fmt.Errorf("unload requires tool name") + } + return st.UnloadTool(parts[1]) + case "env": + if len(parts) < 2 { + return fmt.Errorf("env requires KEY=VALUE") + } + // Rejoin in case value contains spaces + kv := strings.Join(parts[1:], " ") + idx := strings.Index(kv, "=") if idx < 0 { - continue + return fmt.Errorf("env requires KEY=VALUE format") } - key := line[:idx] - value := line[idx+1:] - // Unescape \n and \\ in one pass to handle \\n correctly - value = unescapeValue(value) - args[key] = value + st.SetEnv(kv[:idx], kv[idx+1:]) + return nil + case "cwd": + if len(parts) < 2 { + return fmt.Errorf("cwd requires path") + } + st.SetCWD(parts[1]) + return nil + default: + return fmt.Errorf("unknown command: %s", parts[0]) } - return args } -// unescapeValue reverses escapeValue: \\n -> newline, \\\\ -> backslash -func unescapeValue(s string) string { - result := make([]byte, 0, len(s)) - for i := 0; i < len(s); i++ { - if s[i] == '\\' && i+1 < len(s) { - switch s[i+1] { - case 'n': - result = append(result, '\n') - i++ - continue - case '\\': - result = append(result, '\\') - i++ - continue - } - } - result = append(result, s[i]) - } - return string(result) +// HandleToolsRead returns the tool list as JSON. +func (st *State) HandleToolsRead() string { + tools := st.ListTools() + data, _ := json.Marshal(tools) + return string(data) } +// HandleToolsWrite loads a tool by name. +func (st *State) HandleToolsWrite(name string) error { + return st.LoadTool(strings.TrimSpace(name)) +} + +// HandleProcNew is the rdwr handler for /proc/new (blocking). +func (st *State) HandleProcNew(ctx context.Context, payload string) (string, error) { + result, _, err := st.NewProc(ctx, payload, false) + return result, err +} + +// HandleProcNewBg is the write handler for /proc/new.bg (background). +func (st *State) HandleProcNewBg(ctx context.Context, payload string) (string, error) { + result, _, err := st.NewProc(ctx, payload, true) + return result, err +} + +// HandleProcCtl processes writes to /proc//ctl. +func (st *State) HandleProcCtl(pid int, input string) error { + parts := strings.Fields(input) + if len(parts) == 0 { + return nil + } + + switch parts[0] { + case "signal": + if len(parts) < 2 { + return fmt.Errorf("signal requires signal number") + } + sig, err := strconv.Atoi(parts[1]) + if err != nil { + return fmt.Errorf("invalid signal: %s", parts[1]) + } + return st.SignalProc(pid, syscall.Signal(sig)) + case "dismiss": + st.DismissProc(pid) + return nil + default: + return fmt.Errorf("unknown command: %s", parts[0]) + } +} + +// --- Proc Methods --- + +// Output returns the current output buffer. +func (p *Proc) Output() string { + p.mu.Lock() + defer p.mu.Unlock() + return p.output.String() +} + +// Wait blocks until the process exits, returns exit code. +func (p *Proc) Wait() int { + <-p.done + p.mu.Lock() + defer p.mu.Unlock() + return p.ExitCode +} + +// Stat returns a status string. +func (p *Proc) Stat() string { + p.mu.Lock() + defer p.mu.Unlock() + + rt := time.Since(p.StartTime) + if p.Exited { + rt = p.EndTime.Sub(p.StartTime) + return fmt.Sprintf("exited %d\nruntime=%v\ntool=%s\n", p.ExitCode, rt, p.Tool) + } + return fmt.Sprintf("running\nruntime=%v\ntool=%s\n", rt, p.Tool) +} + +// --- Helpers --- + // argsToJSON converts the args map to JSON for the existing tool path. func argsToJSON(args map[string]string) []byte { - // Build JSON manually to avoid import cycle var buf bytes.Buffer buf.WriteByte('{') first := true @@ -465,128 +523,6 @@ func escapeJSON(s string) string { return s } -// --- Proc9P Methods --- - -// Output returns the current output buffer. -func (p *Proc9P) Output() string { - p.mu.Lock() - defer p.mu.Unlock() - return p.output.String() -} - -// Wait blocks until the process exits, returns exit code. -func (p *Proc9P) Wait() int { - <-p.done - p.mu.Lock() - defer p.mu.Unlock() - return p.ExitCode -} - -// Stat returns a status string. -func (p *Proc9P) Stat() string { - p.mu.Lock() - defer p.mu.Unlock() - - runtime := time.Since(p.StartTime) - if p.Exited { - runtime = p.EndTime.Sub(p.StartTime) - return fmt.Sprintf("exited %d\nruntime=%v\ntool=%s\n", p.ExitCode, runtime, p.Tool) - } - return fmt.Sprintf("running\nruntime=%v\ntool=%s\n", runtime, p.Tool) -} - -// --- Request Handlers (for integration with 9P server) --- - -// HandleCtl processes ctl commands. -func (fs *FS9P) HandleCtl(input string) error { - parts := strings.Fields(input) - if len(parts) == 0 { - return nil - } - - switch parts[0] { - case "load": - if len(parts) < 2 { - return fmt.Errorf("load requires tool name") - } - return fs.LoadTool(parts[1]) - case "unload": - if len(parts) < 2 { - return fmt.Errorf("unload requires tool name") - } - return fs.UnloadTool(parts[1]) - case "env": - if len(parts) < 2 { - return fmt.Errorf("env requires KEY=VALUE") - } - // Rejoin in case value contains spaces - kv := strings.Join(parts[1:], " ") - idx := strings.Index(kv, "=") - if idx < 0 { - return fmt.Errorf("env requires KEY=VALUE format") - } - fs.SetEnv(kv[:idx], kv[idx+1:]) - return nil - case "cwd": - if len(parts) < 2 { - return fmt.Errorf("cwd requires path") - } - fs.SetCWD(parts[1]) - return nil - default: - return fmt.Errorf("unknown command: %s", parts[0]) - } -} - -// HandleTools processes reads/writes to /tools. -func (fs *FS9P) HandleToolsRead() string { - tools := fs.ListTools() - data, _ := json.Marshal(tools) - return string(data) -} - -// HandleToolsWrite loads a tool by name. -func (fs *FS9P) HandleToolsWrite(name string) error { - return fs.LoadTool(strings.TrimSpace(name)) -} - -// HandleProcNew is the rdwr handler for /proc/new (blocking). -func (fs *FS9P) HandleProcNew(ctx context.Context, payload string) (string, error) { - result, _, err := fs.NewProc(ctx, payload, false) - return result, err -} - -// HandleProcNewBg is the write handler for /proc/new.bg (background). -func (fs *FS9P) HandleProcNewBg(ctx context.Context, payload string) (string, error) { - result, _, err := fs.NewProc(ctx, payload, true) - return result, err -} - -// HandleProcCtl processes writes to /proc//ctl. -func (fs *FS9P) HandleProcCtl(pid int, input string) error { - parts := strings.Fields(input) - if len(parts) == 0 { - return nil - } - - switch parts[0] { - case "signal": - if len(parts) < 2 { - return fmt.Errorf("signal requires signal number") - } - sig, err := strconv.Atoi(parts[1]) - if err != nil { - return fmt.Errorf("invalid signal: %s", parts[1]) - } - return fs.SignalProc(pid, syscall.Signal(sig)) - case "dismiss": - fs.DismissProc(pid) - return nil - default: - return fmt.Errorf("unknown command: %s", parts[0]) - } -} - // --- Atomic counters for unique IDs --- var globalProcCounter atomic.Int64 diff --git a/cmd/toolsrv/fs9p_test.go b/cmd/toolsrv/internal/fs/proc_test.go similarity index 57% rename from cmd/toolsrv/fs9p_test.go rename to cmd/toolsrv/internal/fs/proc_test.go index 04974cb..7ec6d96 100644 --- a/cmd/toolsrv/fs9p_test.go +++ b/cmd/toolsrv/internal/fs/proc_test.go @@ -1,4 +1,4 @@ -package main +package fs import ( "context" @@ -7,181 +7,125 @@ import ( "time" ) -func TestFS9P_Basic(t *testing.T) { - fs := NewFS9P("/tmp") +func TestState_Basic(t *testing.T) { + st := NewState("/tmp") // Test CWD - if fs.CWD() != "/tmp" { - t.Errorf("CWD() = %q, want /tmp", fs.CWD()) + if st.CWD() != "/tmp" { + t.Errorf("CWD() = %q, want /tmp", st.CWD()) } - fs.SetCWD("/home") - if fs.CWD() != "/home" { - t.Errorf("CWD() = %q, want /home", fs.CWD()) + st.SetCWD("/home") + if st.CWD() != "/home" { + t.Errorf("CWD() = %q, want /home", st.CWD()) } // Test Env - fs.SetEnv("FOO", "bar") - if fs.GetEnv("FOO") != "bar" { - t.Errorf("GetEnv(FOO) = %q, want bar", fs.GetEnv("FOO")) + st.SetEnv("FOO", "bar") + if st.GetEnv("FOO") != "bar" { + t.Errorf("GetEnv(FOO) = %q, want bar", st.GetEnv("FOO")) } // Test HostInfo - info := fs.HostInfo9P() + info := st.HostInfo() if !strings.Contains(info, "platform=") { - t.Errorf("HostInfo9P() missing platform") + t.Errorf("HostInfo() missing platform") } } -func TestFS9P_ToolsWithoutRegistry(t *testing.T) { - fs := NewFS9P("/tmp") +func TestState_ToolsWithoutRegistry(t *testing.T) { + st := NewState("/tmp") // Without registry, ListTools should return nil - tools := fs.ListTools() + tools := st.ListTools() if tools != nil { t.Errorf("ListTools() without registry = %v, want nil", tools) } // Load should fail without registry - err := fs.LoadTool("shell") + err := st.LoadTool("shell") if err == nil { t.Error("LoadTool without registry should fail") } } -func TestFS9P_HandleCtl(t *testing.T) { - fs := NewFS9P("/tmp") +func TestState_HandleCtl(t *testing.T) { + st := NewState("/tmp") // Unknown command - err := fs.HandleCtl("unknown") + err := st.HandleCtl("unknown") if err == nil { t.Error("HandleCtl(unknown) should fail") } // load without name - err = fs.HandleCtl("load") + err = st.HandleCtl("load") if err == nil { t.Error("HandleCtl(load) without name should fail") } // unload without name - err = fs.HandleCtl("unload") + err = st.HandleCtl("unload") if err == nil { t.Error("HandleCtl(unload) without name should fail") } } -func TestFS9P_ProcLimit(t *testing.T) { - fs := NewFS9P("/tmp") - fs.procLimit = 2 - - // Create a fake registry and session ID - reg, err := NewRegistry() - if err != nil { - t.Skip("no tools directory available") - } - fs.SetRegistry(reg, "test-session") - - // Load a simple tool if available - if err := reg.Load("test-session", "shell"); err != nil { - t.Skip("shell tool not available") - } - - // Proc limit test would require actual tool execution - // For now just verify the limit is set - if fs.procLimit != 2 { - t.Errorf("procLimit = %d, want 2", fs.procLimit) - } -} - -func TestFS9P_ParsePayload(t *testing.T) { - tests := []struct { - input string - want map[string]string - }{ - { - input: "tool=shell\ncmd=ls -la\n", - want: map[string]string{"tool": "shell", "cmd": "ls -la"}, - }, - { - input: "key=value with spaces\n", - want: map[string]string{"key": "value with spaces"}, - }, - { - input: "multi=line\\none\\ntwo\n", - want: map[string]string{"multi": "line\none\ntwo"}, - }, - { - input: "escaped=back\\\\slash\n", - want: map[string]string{"escaped": "back\\slash"}, - }, - } - - for _, tt := range tests { - got := parsePayload(tt.input) - for k, v := range tt.want { - if got[k] != v { - t.Errorf("parsePayload(%q)[%q] = %q, want %q", tt.input, k, got[k], v) - } - } - } -} - -func TestFS9P_ProcLifecycle(t *testing.T) { - fs := NewFS9P("/tmp") +func TestState_ProcLifecycle(t *testing.T) { + st := NewState("/tmp") // No procs initially - pids := fs.ListProcs() + pids := st.ListProcs() if len(pids) != 0 { t.Errorf("ListProcs() = %v, want empty", pids) } // GetProc for non-existent - if fs.GetProc(999) != nil { + if st.GetProc(999) != nil { t.Error("GetProc(999) should return nil") } // DismissProc for non-existent - if fs.DismissProc(999) { + if st.DismissProc(999) { t.Error("DismissProc(999) should return false") } // SignalProc for non-existent - if err := fs.SignalProc(999, 15); err == nil { + if err := st.SignalProc(999, 15); err == nil { t.Error("SignalProc(999) should fail") } } -func TestFS9P_HandleProcCtl(t *testing.T) { - fs := NewFS9P("/tmp") +func TestState_HandleProcCtl(t *testing.T) { + st := NewState("/tmp") // Unknown command - err := fs.HandleProcCtl(1, "unknown") + err := st.HandleProcCtl(1, "unknown") if err == nil { t.Error("HandleProcCtl(unknown) should fail") } // signal without number - err = fs.HandleProcCtl(1, "signal") + err = st.HandleProcCtl(1, "signal") if err == nil { t.Error("HandleProcCtl(signal) without number should fail") } // signal with invalid number - err = fs.HandleProcCtl(1, "signal abc") + err = st.HandleProcCtl(1, "signal abc") if err == nil { t.Error("HandleProcCtl(signal abc) should fail") } // dismiss on non-existent (should succeed silently) - err = fs.HandleProcCtl(999, "dismiss") + err = st.HandleProcCtl(999, "dismiss") if err != nil { t.Errorf("HandleProcCtl(dismiss) on non-existent = %v, want nil", err) } } -func TestProc9P_Stat(t *testing.T) { - proc := &Proc9P{ +func TestProc_Stat(t *testing.T) { + proc := &Proc{ PID: 1, Tool: "shell", StartTime: time.Now(), @@ -205,12 +149,45 @@ func TestProc9P_Stat(t *testing.T) { } } -func TestFS9P_NewProcWithoutRegistry(t *testing.T) { - fs := NewFS9P("/tmp") +func TestState_NewProcWithoutRegistry(t *testing.T) { + st := NewState("/tmp") ctx := context.Background() - _, _, err := fs.NewProc(ctx, "tool=shell\ncmd=ls\n", false) + _, _, err := st.NewProc(ctx, "tool=shell\ncmd=ls\n", false) if err == nil { t.Error("NewProc without registry should fail") } } + +func TestParsePayload(t *testing.T) { + tests := []struct { + input string + want map[string]string + }{ + { + input: "tool=shell\ncmd=ls -la\n", + want: map[string]string{"tool": "shell", "cmd": "ls -la"}, + }, + { + input: "key=value with spaces\n", + want: map[string]string{"key": "value with spaces"}, + }, + { + input: "multi=line\\none\\ntwo\n", + want: map[string]string{"multi": "line\none\ntwo"}, + }, + { + input: "escaped=back\\\\slash\n", + want: map[string]string{"escaped": "back\\slash"}, + }, + } + + for _, tt := range tests { + got := ParsePayload(tt.input) + for k, v := range tt.want { + if got[k] != v { + t.Errorf("ParsePayload(%q)[%q] = %q, want %q", tt.input, k, got[k], v) + } + } + } +} diff --git a/cmd/toolsrv/internal/fs/spec.go b/cmd/toolsrv/internal/fs/spec.go new file mode 100644 index 0000000..fc327fb --- /dev/null +++ b/cmd/toolsrv/internal/fs/spec.go @@ -0,0 +1,173 @@ +// spec.go - fsedsl-based namespace specification for toolsrv. +package fs + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + + "ollie/fsedsl" +) + +// Ctx is the context passed through the fsedsl tree. +type Ctx struct { + Server *Server + Proc *Proc // set when traversing into /proc/{pid} +} + +// Type aliases from fsedsl, specialized to Ctx. +type ( + FsNodeDecl = fsedsl.FsNodeDecl[Ctx] + Binding = fsedsl.Binding[Ctx] +) + +// Specialized fsedsl constructors for Ctx. +var ( + DirNode = fsedsl.DirNode[Ctx] + FileNode = fsedsl.FileNode[Ctx] + Each = fsedsl.Each[Ctx] + Doc = fsedsl.Doc[Ctx] + Read = fsedsl.Read[Ctx] + Write = fsedsl.Write[Ctx] + BlockOnce = fsedsl.BlockOnce[Ctx] + Request = fsedsl.Request[Ctx] +) + +// Spec returns the fsedsl specification for the toolsrv namespace. +func Spec() FsNodeDecl { + return DirNode("/", + FileNode("ctl", 0222, + Doc("Control: write 'load ' or 'unload '"), + Write(handleCtlWrite), + ), + FileNode("tools", 0666, + Doc("Tools: read to list, write tool name to load"), + Read(handleToolsRead), + Write(handleToolsWrite), + ), + FileNode("info", 0444, + Doc("Host info: platform, arch"), + Read(handleInfoRead), + ), + DirNode("proc", + FileNode("new", 0666, + Doc("Execute tool: write token + tool + args, read result (blocking)"), + Request(handleProcNew), + ), + FileNode("new.bg", 0222, + Doc("Execute tool in background: write token + tool + args, returns pid"), + Write(handleProcNewBg), + ), + Each("{pid}", procBindings, + FileNode("out", 0444, + Doc("Process output"), + Read(handleProcOutRead), + ), + FileNode("wait", 0444, + Doc("Block until process exits, returns exit code"), + BlockOnce(handleProcWait), + ), + FileNode("stat", 0444, + Doc("Process status: running/exited, runtime, tool"), + Read(handleProcStatRead), + ), + FileNode("ctl", 0222, + Doc("Process control: write 'signal ' or 'dismiss'"), + Write(handleProcCtlWrite), + ), + ), + ), + ) +} + +// --- Handlers --- + +func handleCtlWrite(ctx Ctx, data []byte) error { + return ctx.Server.Fs.HandleCtl(strings.TrimSpace(string(data))) +} + +func handleToolsRead(ctx Ctx) ([]byte, error) { + return []byte(ctx.Server.Fs.HandleToolsRead()), nil +} + +func handleToolsWrite(ctx Ctx, data []byte) error { + return ctx.Server.Fs.HandleToolsWrite(strings.TrimSpace(string(data))) +} + +func handleInfoRead(ctx Ctx) ([]byte, error) { + return []byte(ctx.Server.HostInfo()), nil +} + +func handleProcNew(ctx Ctx, data []byte) ([]byte, error) { + result, err := ctx.Server.Fs.HandleProcNew(context.Background(), string(data)) + if err != nil { + return nil, err + } + return []byte(result), nil +} + +func handleProcNewBg(ctx Ctx, data []byte) error { + _, err := ctx.Server.Fs.HandleProcNewBg(context.Background(), string(data)) + return err +} + +// procBindings returns bindings for each running process. +func procBindings(ctx Ctx) ([]Binding, error) { + pids := ctx.Server.Fs.ListProcs() + var bindings []Binding + + for _, pid := range pids { + proc := ctx.Server.Fs.GetProc(pid) + if proc == nil { + continue + } + p := proc // capture for closure + bindings = append(bindings, Binding{ + Name: strconv.Itoa(pid), + Applier: func(c Ctx) Ctx { + c.Proc = p + return c + }, + }) + } + + return bindings, nil +} + +func handleProcOutRead(ctx Ctx) ([]byte, error) { + if ctx.Proc == nil { + return nil, fmt.Errorf("no process context") + } + return []byte(ctx.Proc.Output()), nil +} + +func handleProcWait(ctx Ctx, cctx context.Context, base string) ([]byte, string, error) { + if ctx.Proc == nil { + return nil, "", fmt.Errorf("no process context") + } + exitCode := ctx.Proc.Wait() + return []byte(fmt.Sprintf("%d\n", exitCode)), "", nil +} + +func handleProcStatRead(ctx Ctx) ([]byte, error) { + if ctx.Proc == nil { + return nil, fmt.Errorf("no process context") + } + return []byte(ctx.Proc.Stat()), nil +} + +func handleProcCtlWrite(ctx Ctx, data []byte) error { + if ctx.Proc == nil { + return fmt.Errorf("no process context") + } + return ctx.Server.Fs.HandleProcCtl(ctx.Proc.PID, strings.TrimSpace(string(data))) +} + +// Mode constants for convenience. +const ( + ModeReadOnly os.FileMode = 0444 + ModeWriteOnly os.FileMode = 0222 + ModeReadWrite os.FileMode = 0666 +) diff --git a/cmd/toolsrv/internal/fs/state.go b/cmd/toolsrv/internal/fs/state.go new file mode 100644 index 0000000..8ed6a2b --- /dev/null +++ b/cmd/toolsrv/internal/fs/state.go @@ -0,0 +1,106 @@ +// state.go - Server state management for toolsrv. +package fs + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "runtime" + "sync" + + "ollie/cmd/toolsrv/internal/registry" + "ollie/fsedsl" +) + +// Server holds the state for the toolsrv 9P server. +type Server struct { + mu sync.RWMutex + + secret string // set on first auth, verified on subsequent + token string // session token returned after auth + registry *registry.Registry // tool registry + sessID string // session ID for registry scoping + yolo bool // skip sandbox + + // Process management + Fs *State +} + +// NewServer creates a new toolsrv server. +func NewServer() *Server { + return &Server{ + Fs: NewState(""), // cwd set per-agent + } +} + +// SetRegistry configures the tool registry and session ID. +func (s *Server) SetRegistry(r *registry.Registry, sessID string) { + s.mu.Lock() + s.registry = r + s.sessID = sessID + s.Fs.SetRegistry(r, sessID) + s.mu.Unlock() +} + +// SetYolo enables/disables sandbox bypass. +func (s *Server) SetYolo(yolo bool) { + s.mu.Lock() + s.yolo = yolo + s.Fs.SetYolo(yolo) + s.mu.Unlock() +} + +// Authenticate handles secret verification. +// First call sets the secret; subsequent calls must match it. +// Returns (token, nil) on success, ("", error) on failure. +func (s *Server) Authenticate(clientSecret string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.secret == "" { + // First auth - set the secret + s.secret = clientSecret + s.token = randomToken() + return s.token, nil + } + + // Subsequent auth - verify secret + if clientSecret != s.secret { + return "", fmt.Errorf("authentication failed") + } + + return s.token, nil +} + +// Token returns the current session token (empty if not authenticated). +func (s *Server) Token() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.token +} + +// HostInfo returns platform info as key=value lines. +func (s *Server) HostInfo() string { + return fmt.Sprintf("platform=%s\narch=%s\n", runtime.GOOS, runtime.GOARCH) +} + +// BuildTree creates the fsedsl tree for this server. +func (s *Server) BuildTree() *fsedsl.Tree { + ctx := Ctx{Server: s} + return fsedsl.BuildTree(Spec(), ctx) +} + +// GenerateSecret generates a random shared secret for toolsrv auth. +func GenerateSecret() (string, error) { + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return "", err + } + return hex.EncodeToString(secret), nil +} + +func randomToken() string { + b := make([]byte, 16) + rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/cmd/toolsrv/server9p_test.go b/cmd/toolsrv/internal/fs/state_test.go similarity index 71% rename from cmd/toolsrv/server9p_test.go rename to cmd/toolsrv/internal/fs/state_test.go index 10f541e..758a66b 100644 --- a/cmd/toolsrv/server9p_test.go +++ b/cmd/toolsrv/internal/fs/state_test.go @@ -1,11 +1,12 @@ -package main +package fs import ( + "strings" "testing" ) -func TestServer9P_AuthenticateFirst(t *testing.T) { - srv := NewServer9P() +func TestServer_AuthenticateFirst(t *testing.T) { + srv := NewServer() secret := "test-secret-0123456789abcdef" @@ -24,8 +25,8 @@ func TestServer9P_AuthenticateFirst(t *testing.T) { } } -func TestServer9P_AuthenticateSameSecret(t *testing.T) { - srv := NewServer9P() +func TestServer_AuthenticateSameSecret(t *testing.T) { + srv := NewServer() secret := "test-secret-0123456789abcdef" @@ -46,8 +47,8 @@ func TestServer9P_AuthenticateSameSecret(t *testing.T) { } } -func TestServer9P_AuthenticateWrongSecret(t *testing.T) { - srv := NewServer9P() +func TestServer_AuthenticateWrongSecret(t *testing.T) { + srv := NewServer() secret := "test-secret-0123456789abcdef" wrongSecret := "wrong-secret-fedcba9876543210" @@ -65,8 +66,8 @@ func TestServer9P_AuthenticateWrongSecret(t *testing.T) { } } -func TestServer9P_TokenBeforeAuth(t *testing.T) { - srv := NewServer9P() +func TestServer_TokenBeforeAuth(t *testing.T) { + srv := NewServer() // Token should be empty before authentication if srv.Token() != "" { @@ -74,8 +75,8 @@ func TestServer9P_TokenBeforeAuth(t *testing.T) { } } -func TestServer9P_BuildTree(t *testing.T) { - srv := NewServer9P() +func TestServer_BuildTree(t *testing.T) { + srv := NewServer() tree := srv.BuildTree() if tree == nil { @@ -83,19 +84,18 @@ func TestServer9P_BuildTree(t *testing.T) { } } -func TestServer9P_HostInfo(t *testing.T) { - srv := NewServer9P() +func TestServer_HostInfo(t *testing.T) { + srv := NewServer() info := srv.HostInfo() if info == "" { t.Fatal("HostInfo returned empty string") } - // Should contain platform info - if !contains(info, "platform=") { + if !strings.Contains(info, "platform=") { t.Error("HostInfo missing platform=") } - if !contains(info, "arch=") { + if !strings.Contains(info, "arch=") { t.Error("HostInfo missing arch=") } } @@ -119,16 +119,3 @@ func TestGenerateSecret(t *testing.T) { t.Error("GenerateSecret returned same secret twice") } } - -func contains(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsAt(s, substr)) -} - -func containsAt(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} diff --git a/cmd/toolsrv/registry.go b/cmd/toolsrv/internal/registry/registry.go similarity index 93% rename from cmd/toolsrv/registry.go rename to cmd/toolsrv/internal/registry/registry.go index 4538fdb..b380188 100644 --- a/cmd/toolsrv/registry.go +++ b/cmd/toolsrv/internal/registry/registry.go @@ -1,5 +1,5 @@ -// registry.go - Session-scoped tool registry for toolsrv. -package main +// Package registry provides session-scoped tool registry for toolsrv. +package registry import ( "fmt" @@ -16,8 +16,8 @@ type Registry struct { revisions map[string]uint64 } -// NewRegistry creates a new tool registry. -func NewRegistry() (*Registry, error) { +// New creates a new tool registry. +func New() (*Registry, error) { r := &Registry{ sessions: make(map[string]map[string]toolsrv.ToolInfo), revisions: make(map[string]uint64), diff --git a/cmd/toolsrv/main.go b/cmd/toolsrv/main.go index 6feaaa7..293790b 100644 --- a/cmd/toolsrv/main.go +++ b/cmd/toolsrv/main.go @@ -16,12 +16,17 @@ import ( "context" "flag" "fmt" + "log/slog" "net" "os" "os/signal" "path/filepath" + "sync/atomic" "syscall" + "time" + "ollie/cmd/toolsrv/internal/fs" + "ollie/cmd/toolsrv/internal/registry" "ollie/env" "ollie/fsedsl" ) @@ -32,8 +37,18 @@ var ( sessionID = flag.String("session-id", "", "session ID for tool registry") yolo = flag.Bool("yolo", false, "skip sandbox enforcement") noAuth = flag.Bool("no-auth", false, "disable Tauth authentication (for debugging)") + idleTimeout = flag.Duration("idle-timeout", 30*time.Second, "exit after this duration with no connections (0 to disable)") ) +// logger is the toolsrv structured logger. +var logger *slog.Logger + +// activeConns tracks the number of active 9P connections. +var activeConns atomic.Int64 + +// hadConnection is set to true once the first client connects. +var hadConnection atomic.Bool + func main() { if len(os.Args) < 2 || os.Args[1] != "serve" { fmt.Fprintln(os.Stderr, "usage: toolsrv serve --cwd --listen [--yolo]") @@ -46,6 +61,11 @@ func main() { os.Exit(1) } + // Set up structured logger + logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })).With("svc", "toolsrv") + env.EnsureDefaults() // Prepend our bin dir to PATH so landrun is found (deployed alongside us). @@ -54,24 +74,24 @@ func main() { os.Setenv("PATH", binDir+":"+os.Getenv("PATH")) // Create tool registry - toolReg, _ := NewRegistry() + toolReg, _ := registry.New() sid := *sessionID if sid == "" { sid = os.Getenv("OLLIE_SESSION_ID") // fallback to env var } - // Create 9P server (secret is established via Tauth, not env var) - srv := NewServer9P() + // Create server state + srv := fs.NewServer() srv.SetYolo(*yolo) if toolReg != nil && sid != "" { srv.SetRegistry(toolReg, sid) } // Build the filesystem tree - ctx := ToolsrvCtx{Server: srv} - tree := fsedsl.BuildTree(ToolsrvSpec(), ctx) + ctx := fs.Ctx{Server: srv} + tree := fsedsl.BuildTree(fs.Spec(), ctx) if tree == nil { - fmt.Fprintln(os.Stderr, "error: failed to build filesystem tree") + logger.Error("failed to build filesystem tree") os.Exit(1) } @@ -85,19 +105,24 @@ func main() { cancel() }() + // Start idle timeout monitor + if *idleTimeout > 0 { + go idleMonitor(runCtx, cancel, *idleTimeout) + } + // Remove stale socket if it exists os.Remove(*listenPath) // Listen on Unix socket ln, err := net.Listen("unix", *listenPath) if err != nil { - fmt.Fprintf(os.Stderr, "error: listen: %v\n", err) + logger.Error("listen failed", "err", err) os.Exit(1) } defer ln.Close() defer os.Remove(*listenPath) - fmt.Fprintf(os.Stderr, "toolsrv: listening on %s\n", *listenPath) + logger.Info("listening on " + *listenPath) // Accept connections go func() { @@ -112,10 +137,65 @@ func main() { case <-runCtx.Done(): return default: - fmt.Fprintf(os.Stderr, "accept error: %v\n", err) + logger.Warn("accept error", "err", err) continue } } - go serve9P(runCtx, conn, tree, srv, *noAuth) + activeConns.Add(1) + hadConnection.Store(true) + go func() { + defer activeConns.Add(-1) + serve9P(runCtx, conn, tree, srv, *noAuth) + }() + } +} + +// idleMonitor exits the process if there are no active connections for the +// given duration. It waits for the first connection before monitoring (with +// a startup grace period of 60s). +func idleMonitor(ctx context.Context, cancel context.CancelFunc, timeout time.Duration) { + const startupGrace = 60 * time.Second + + // Wait for first connection (or startup grace period) + deadline := time.After(startupGrace) + for { + select { + case <-ctx.Done(): + return + case <-deadline: + if !hadConnection.Load() { + logger.Info("no connections within startup grace period, exiting") + cancel() + return + } + case <-time.After(1 * time.Second): + if hadConnection.Load() { + goto monitor + } + } + } + +monitor: + // Monitor for idle (zero connections after having had at least one) + idleSince := time.Time{} + for { + select { + case <-ctx.Done(): + return + case <-time.After(5 * time.Second): + if activeConns.Load() > 0 { + idleSince = time.Time{} + continue + } + // No active connections + if idleSince.IsZero() { + idleSince = time.Now() + logger.Info("all connections closed, idle timer started", "timeout", timeout) + } else if time.Since(idleSince) >= timeout { + logger.Info("idle timeout reached, exiting") + cancel() + return + } + } } } diff --git a/cmd/toolsrv/server.go b/cmd/toolsrv/server.go index 8bb97ec..f685776 100644 --- a/cmd/toolsrv/server.go +++ b/cmd/toolsrv/server.go @@ -4,11 +4,12 @@ package main import ( "context" "fmt" + "hash/fnv" "io" "net" - "os" "sync" + "ollie/cmd/toolsrv/internal/fs" "ollie/fsedsl" "9fans.net/go/plan9" @@ -41,12 +42,12 @@ type connState struct { authFids map[uint32]*authFid ctx context.Context uname string - srv *Server9P + srv *fs.Server noAuth bool // skip authentication (for debugging) } // serve9P handles a single 9P connection using the fsedsl tree. -func serve9P(ctx context.Context, conn net.Conn, tree *fsedsl.Tree, srv *Server9P, noAuth bool) { +func serve9P(ctx context.Context, conn net.Conn, tree *fsedsl.Tree, srv *fs.Server, noAuth bool) { defer conn.Close() cs := &connState{ @@ -67,14 +68,14 @@ func serve9P(ctx context.Context, conn net.Conn, tree *fsedsl.Tree, srv *Server9 fc, err := plan9.ReadFcall(conn) if err != nil { if err != io.EOF { - fmt.Fprintf(os.Stderr, "toolsrv: read error: %v\n", err) + logger.Warn("read error", "err", err) } return } resp := handleFcall(cs, fc, tree) if err := plan9.WriteFcall(conn, resp); err != nil { - fmt.Fprintf(os.Stderr, "toolsrv: write error: %v\n", err) + logger.Warn("write error", "err", err) return } } @@ -509,9 +510,7 @@ func handleStat(cs *connState, fc *plan9.Fcall, tree *fsedsl.Tree) *plan9.Fcall } func pathHash(p string) uint64 { - var h uint64 = 5381 - for _, c := range p { - h = h*33 + uint64(c) - } - return h + h := fnv.New64a() + h.Write([]byte(p)) + return h.Sum64() } diff --git a/cmd/toolsrv/server9p.go b/cmd/toolsrv/server9p.go deleted file mode 100644 index 354addd..0000000 --- a/cmd/toolsrv/server9p.go +++ /dev/null @@ -1,101 +0,0 @@ -// server9p.go - State management for 9P toolsrv -package main - -import ( - "crypto/rand" - "encoding/hex" - "fmt" - "runtime" - "sync" - - "ollie/fsedsl" -) - -// Server9P holds the state for the 9P tool server. -type Server9P struct { - mu sync.RWMutex - - secret string // set on first auth, verified on subsequent - token string // session token returned after auth - registry *Registry // tool registry - sessID string // session ID for registry scoping - yolo bool // skip sandbox - - // Process management - fs *FS9P -} - -// NewServer9P creates a new 9P tool server. -func NewServer9P() *Server9P { - return &Server9P{ - fs: NewFS9P(""), // cwd set per-agent - } -} - -// SetRegistry configures the tool registry and session ID. -func (s *Server9P) SetRegistry(r *Registry, sessID string) { - s.mu.Lock() - s.registry = r - s.sessID = sessID - s.fs.SetRegistry(r, sessID) - s.mu.Unlock() -} - -// SetYolo enables/disables sandbox bypass. -func (s *Server9P) SetYolo(yolo bool) { - s.mu.Lock() - s.yolo = yolo - s.fs.SetYolo(yolo) - s.mu.Unlock() -} - -// Authenticate handles secret verification. -// First call sets the secret; subsequent calls must match it. -// Returns (token, nil) on success, ("", error) on failure. -func (s *Server9P) Authenticate(clientSecret string) (string, error) { - s.mu.Lock() - defer s.mu.Unlock() - - if s.secret == "" { - // First auth - set the secret - s.secret = clientSecret - s.token = randomToken() - return s.token, nil - } - - // Subsequent auth - verify secret - if clientSecret != s.secret { - return "", fmt.Errorf("authentication failed") - } - - return s.token, nil -} - -// Token returns the current session token (empty if not authenticated). -func (s *Server9P) Token() string { - s.mu.RLock() - defer s.mu.RUnlock() - return s.token -} - -// HostInfo returns platform info as key=value lines. -func (s *Server9P) HostInfo() string { - return fmt.Sprintf("platform=%s\narch=%s\n", runtime.GOOS, runtime.GOARCH) -} - -// FS returns the underlying filesystem state. -func (s *Server9P) FS() *FS9P { - return s.fs -} - -// BuildTree creates the fsedsl tree for this server. -func (s *Server9P) BuildTree() *fsedsl.Tree { - ctx := ToolsrvCtx{Server: s} - return fsedsl.BuildTree(ToolsrvSpec(), ctx) -} - -func randomToken() string { - b := make([]byte, 16) - rand.Read(b) - return hex.EncodeToString(b) -} diff --git a/cmd/toolsrv/spec9p.go b/cmd/toolsrv/spec9p.go deleted file mode 100644 index b308754..0000000 --- a/cmd/toolsrv/spec9p.go +++ /dev/null @@ -1,220 +0,0 @@ -// spec9p.go - fsedsl-based namespace specification for toolsrv -package main - -import ( - "context" - "fmt" - "os" - "strconv" - "strings" - - "ollie/fsedsl" -) - -// ToolsrvCtx is the context passed through the fsedsl tree. -type ToolsrvCtx struct { - Server *Server9P - Proc *Proc9P // set when traversing into /proc/{pid} -} - -// Type aliases from fsedsl, specialized to ToolsrvCtx. -type ( - FsNodeDecl = fsedsl.FsNodeDecl[ToolsrvCtx] - Binding = fsedsl.Binding[ToolsrvCtx] -) - -// Specialized fsedsl constructors for ToolsrvCtx. -var ( - DirNode = fsedsl.DirNode[ToolsrvCtx] - FileNode = fsedsl.FileNode[ToolsrvCtx] - Each = fsedsl.Each[ToolsrvCtx] - Doc = fsedsl.Doc[ToolsrvCtx] - Read = fsedsl.Read[ToolsrvCtx] - Write = fsedsl.Write[ToolsrvCtx] - BlockOnce = fsedsl.BlockOnce[ToolsrvCtx] - Request = fsedsl.Request[ToolsrvCtx] -) - -// ToolsrvSpec returns the fsedsl specification for the toolsrv namespace. -// Authentication is handled via 9P Tauth, not a /register file. -func ToolsrvSpec() FsNodeDecl { - return DirNode("/", - FileNode("ctl", 0222, - Doc("Control: write 'load ' or 'unload '"), - Write(handleCtlWrite), - ), - FileNode("tools", 0666, - Doc("Tools: read to list, write tool name to load"), - Read(handleToolsRead), - Write(handleToolsWrite), - ), - FileNode("info", 0444, - Doc("Host info: platform, arch"), - Read(handleInfoRead), - ), - DirNode("proc", - FileNode("new", 0666, - Doc("Execute tool: write token + tool + args, read result (blocking)"), - Request(handleProcNew), - ), - FileNode("new.bg", 0222, - Doc("Execute tool in background: write token + tool + args, returns pid"), - Write(handleProcNewBg), - ), - Each("{pid}", procBindings, - FileNode("out", 0444, - Doc("Process output"), - Read(handleProcOutRead), - ), - FileNode("wait", 0444, - Doc("Block until process exits, returns exit code"), - BlockOnce(handleProcWait), - ), - FileNode("stat", 0444, - Doc("Process status: running/exited, runtime, tool"), - Read(handleProcStatRead), - ), - FileNode("ctl", 0222, - Doc("Process control: write 'signal ' or 'dismiss'"), - Write(handleProcCtlWrite), - ), - ), - ), - ) -} - -// --- Handlers --- - -func handleCtlWrite(ctx ToolsrvCtx, data []byte) error { - return ctx.Server.fs.HandleCtl(strings.TrimSpace(string(data))) -} - -func handleToolsRead(ctx ToolsrvCtx) ([]byte, error) { - return []byte(ctx.Server.fs.HandleToolsRead()), nil -} - -func handleToolsWrite(ctx ToolsrvCtx, data []byte) error { - return ctx.Server.fs.HandleToolsWrite(strings.TrimSpace(string(data))) -} - -func handleInfoRead(ctx ToolsrvCtx) ([]byte, error) { - return []byte(ctx.Server.HostInfo()), nil -} - -func handleProcNew(ctx ToolsrvCtx, data []byte) ([]byte, error) { - // Pass raw payload through - fs9p.go's parsePayload will handle escaping. - // Token verification is done at connection level via Tauth. - result, err := ctx.Server.fs.HandleProcNew(context.Background(), string(data)) - if err != nil { - return nil, err - } - return []byte(result), nil -} - -func handleProcNewBg(ctx ToolsrvCtx, data []byte) error { - // Pass raw payload through - fs9p.go's parsePayload will handle escaping. - // Token verification is done at connection level via Tauth. - _, err := ctx.Server.fs.HandleProcNewBg(context.Background(), string(data)) - return err -} - -// procBindings returns bindings for each running process. -func procBindings(ctx ToolsrvCtx) ([]Binding, error) { - pids := ctx.Server.fs.ListProcs() - var bindings []Binding - - for _, pid := range pids { - proc := ctx.Server.fs.GetProc(pid) - if proc == nil { - continue - } - p := proc // capture for closure - bindings = append(bindings, Binding{ - Name: strconv.Itoa(pid), - Applier: func(c ToolsrvCtx) ToolsrvCtx { - c.Proc = p - return c - }, - }) - } - - return bindings, nil -} - -func handleProcOutRead(ctx ToolsrvCtx) ([]byte, error) { - if ctx.Proc == nil { - return nil, fmt.Errorf("no process context") - } - return []byte(ctx.Proc.Output()), nil -} - -func handleProcWait(ctx ToolsrvCtx, cctx context.Context, base string) ([]byte, string, error) { - if ctx.Proc == nil { - return nil, "", fmt.Errorf("no process context") - } - exitCode := ctx.Proc.Wait() - return []byte(fmt.Sprintf("%d\n", exitCode)), "", nil -} - -func handleProcStatRead(ctx ToolsrvCtx) ([]byte, error) { - if ctx.Proc == nil { - return nil, fmt.Errorf("no process context") - } - return []byte(ctx.Proc.Stat()), nil -} - -func handleProcCtlWrite(ctx ToolsrvCtx, data []byte) error { - if ctx.Proc == nil { - return fmt.Errorf("no process context") - } - return ctx.Server.fs.HandleProcCtl(ctx.Proc.PID, strings.TrimSpace(string(data))) -} - -// Mode constants for convenience -const ( - ModeReadOnly os.FileMode = 0444 - ModeWriteOnly os.FileMode = 0222 - ModeReadWrite os.FileMode = 0666 -) - -// parseKV parses key=value lines into a map. -func parseKV(input string) map[string]string { - m := make(map[string]string) - for _, line := range strings.Split(input, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - idx := strings.Index(line, "=") - if idx < 0 { - continue - } - k := line[:idx] - v := line[idx+1:] - // Unescape \n and \\ in one pass - v = unescapeKV(v) - m[k] = v - } - return m -} - -// unescapeKV reverses escape sequences: \n -> newline, \\ -> backslash -func unescapeKV(s string) string { - result := make([]byte, 0, len(s)) - for i := 0; i < len(s); i++ { - if s[i] == '\\' && i+1 < len(s) { - switch s[i+1] { - case 'n': - result = append(result, '\n') - i++ - continue - case '\\': - result = append(result, '\\') - i++ - continue - } - } - result = append(result, s[i]) - } - return string(result) -}