mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
* fix(daemon): publish status files atomically * fix(daemon): bind status publication to directory handle * fix(daemon): validate status directory access * fix(daemon): accept current token directory owner * fix(daemon): migrate owned runtime directories safely * fix(daemon): open Windows security handle relatively * fix(daemon): use current NT directory object * fix(observability): harden existing crash directories * fix(security): bind crash report creation to private root * fix(observability): atomically publish crash reports * fix(observability): preserve committed crash reports * fix(observability): revalidate committed crash path * fix(daemon): secure runtime lifecycle boundaries * fix(daemon): bind status ownership through cleanup * fix(daemon): preserve Windows status security * fix(daemon): retain trusted runtime root for lifecycle * fix(daemon): roll back redirected socket binds * fix(daemon): address status publication review
529 lines
16 KiB
Go
529 lines
16 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Server is the daemon control plane. Mirrors reference-daemon-code-agent-js/
|
|
// supervisor.js (single-instance lock, status file, lifecycle) + the accept loop
|
|
// that routes framed control requests to the SessionManager/Pool. It listens on
|
|
// an owner-only local Unix socket and NEVER binds a TCP port.
|
|
type Server struct {
|
|
opts ServerOptions
|
|
startedAt time.Time
|
|
|
|
mu sync.Mutex // guards listener + conns
|
|
listener net.Listener
|
|
conns map[net.Conn]struct{} // open connections, closed on Shutdown so blocked reads return
|
|
lock *fileLock
|
|
// runtimeRoot is retained for the full default-daemon lifecycle. Every file
|
|
// child is addressed relative to this capability; runtimeDir is used only to
|
|
// verify the unavoidable AF_UNIX pathname bind still names the same object.
|
|
runtimeRoot *os.Root
|
|
runtimeDir string
|
|
// socketRoot captures the directory that actually received the pathname
|
|
// bind. It can differ from runtimeRoot only when the configured directory is
|
|
// exchanged in the final pre-bind window; retaining it makes rollback target
|
|
// the socket that was really created rather than re-resolving the pathname.
|
|
socketRoot *os.Root
|
|
socketName string
|
|
// statusRoot binds status publication and shutdown cleanup to the same
|
|
// directory object. statusCommitted is set only after this server publishes
|
|
// its document, so a failed startup never removes a previous daemon's status.
|
|
statusRoot *os.Root
|
|
statusName string
|
|
statusCommitted bool
|
|
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
|
|
wg sync.WaitGroup
|
|
shutdownOnce sync.Once
|
|
done chan struct{}
|
|
}
|
|
|
|
// ServerOptions configures a Server.
|
|
type ServerOptions struct {
|
|
Paths Paths
|
|
Manager *SessionManager
|
|
Pool *Pool
|
|
Version int
|
|
Now func() time.Time
|
|
Log func(string)
|
|
isAlive func(int) bool // test hook for the single-instance lock
|
|
// beforeStatusReplace, replaceStatusFile, and syncStatusParent are test hooks
|
|
// for the status-file commit boundary. nil selects production behavior.
|
|
beforeStatusReplace func()
|
|
replaceStatusFile func(root *os.Root, src, dst string) error
|
|
syncStatusParent func(root *os.Root) error
|
|
afterRuntimeRootOpen func() // test hook at the default-root trust boundary
|
|
beforeSocketBind func() // test hook before the final root verification
|
|
afterSocketPreflight func() // test hook in the unavoidable pathname-bind window
|
|
}
|
|
|
|
// NewServer validates options and builds a Server.
|
|
func NewServer(opts ServerOptions) (*Server, error) {
|
|
if opts.Manager == nil || opts.Pool == nil {
|
|
return nil, errors.New("daemon: server requires a Pool and SessionManager")
|
|
}
|
|
if opts.Paths.Socket == "" || opts.Paths.Lock == "" || opts.Paths.Status == "" {
|
|
return nil, errors.New("daemon: server requires socket, lock, and status paths")
|
|
}
|
|
if opts.Version <= 0 {
|
|
opts.Version = ProtoVersion
|
|
}
|
|
if opts.Now == nil {
|
|
opts.Now = time.Now
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
return &Server{
|
|
opts: opts,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
done: make(chan struct{}),
|
|
conns: map[net.Conn]struct{}{},
|
|
}, nil
|
|
}
|
|
|
|
func (s *Server) logf(format string, args ...any) {
|
|
if s.opts.Log != nil {
|
|
s.opts.Log(fmt.Sprintf(format, args...))
|
|
}
|
|
}
|
|
|
|
// Serve acquires the single-instance lock, binds the owner-only control socket,
|
|
// writes the status file, and serves connections until Shutdown. It blocks. On
|
|
// return it has released the lock and removed the socket/status files.
|
|
func (s *Server) Serve() error {
|
|
if err := checkSocketPathLength(s.opts.Paths.Socket); err != nil {
|
|
return err
|
|
}
|
|
defaultRoot, isDefault, err := openDefaultRuntimeRoot(s.opts.Paths)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if isDefault {
|
|
s.runtimeRoot = defaultRoot
|
|
s.runtimeDir = filepath.Dir(s.opts.Paths.Socket)
|
|
s.statusRoot = defaultRoot
|
|
s.statusName = filepath.Base(s.opts.Paths.Status)
|
|
if s.opts.afterRuntimeRootOpen != nil {
|
|
s.opts.afterRuntimeRootOpen()
|
|
}
|
|
if err := runtimeRootStillNamesPath(defaultRoot, s.runtimeDir); err != nil {
|
|
s.closeRuntimeRoots()
|
|
return err
|
|
}
|
|
} else {
|
|
if err := secureCustomRuntimeParents(s.opts.Paths); err != nil {
|
|
return err
|
|
}
|
|
statusRoot, err := openStatusRoot(s.opts.Paths.Status)
|
|
if err != nil {
|
|
return fmt.Errorf("daemon: open status directory: %w", err)
|
|
}
|
|
s.statusRoot = statusRoot
|
|
s.statusName = filepath.Base(s.opts.Paths.Status)
|
|
}
|
|
var lock *fileLock
|
|
if s.runtimeRoot != nil {
|
|
lock, err = acquireLockRoot(s.runtimeRoot, filepath.Base(s.opts.Paths.Lock), s.opts.Paths.Lock, s.opts.isAlive)
|
|
} else {
|
|
lock, err = acquireLock(s.opts.Paths.Lock, s.opts.isAlive)
|
|
}
|
|
if err != nil {
|
|
s.closeRuntimeRoots()
|
|
return err
|
|
}
|
|
s.lock = lock
|
|
defer s.cleanup()
|
|
|
|
// A leftover socket file from an unclean exit would make Listen fail with
|
|
// "address already in use"; we hold the lock, so any socket here is stale.
|
|
if s.runtimeRoot != nil {
|
|
if err := s.runtimeRoot.Remove(filepath.Base(s.opts.Paths.Socket)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("daemon: remove stale control socket: %w", err)
|
|
}
|
|
if s.opts.beforeSocketBind != nil {
|
|
s.opts.beforeSocketBind()
|
|
}
|
|
if err := runtimeRootStillNamesPath(s.runtimeRoot, s.runtimeDir); err != nil {
|
|
return err
|
|
}
|
|
if s.opts.afterSocketPreflight != nil {
|
|
s.opts.afterSocketPreflight()
|
|
}
|
|
} else {
|
|
_ = os.Remove(s.opts.Paths.Socket)
|
|
}
|
|
|
|
listener, err := net.Listen("unix", s.opts.Paths.Socket)
|
|
if err != nil {
|
|
return fmt.Errorf("daemon: bind control socket: %w", err)
|
|
}
|
|
s.mu.Lock()
|
|
s.listener = listener
|
|
s.mu.Unlock()
|
|
if s.runtimeRoot != nil {
|
|
boundRoot, boundName, err := captureBoundSocketRoot(s.opts.Paths.Socket)
|
|
if err != nil {
|
|
_ = listener.Close()
|
|
return fmt.Errorf("daemon: capture bound control socket: %w", err)
|
|
}
|
|
s.socketRoot = boundRoot
|
|
s.socketName = boundName
|
|
if err := runtimeRootStillNamesPath(s.runtimeRoot, s.runtimeDir); err != nil {
|
|
return err
|
|
}
|
|
info, err := s.runtimeRoot.Lstat(filepath.Base(s.opts.Paths.Socket))
|
|
if err != nil || info.Mode()&os.ModeSocket == 0 {
|
|
return fmt.Errorf("daemon: bound control socket is outside the secured runtime directory")
|
|
}
|
|
}
|
|
// If Shutdown already fired during the bind window, close now and bail so a
|
|
// shutdown requested at startup is never lost (the accept loop would otherwise
|
|
// block forever waiting for a connection that never comes) (D4).
|
|
select {
|
|
case <-s.done:
|
|
_ = listener.Close()
|
|
s.wg.Wait()
|
|
return nil
|
|
default:
|
|
}
|
|
if err := s.hardenSocket(); err != nil {
|
|
return fmt.Errorf("daemon: harden control socket: %w", err)
|
|
}
|
|
s.startedAt = s.opts.Now()
|
|
if err := s.writeStatusFile(); err != nil {
|
|
return err
|
|
}
|
|
s.logf("daemon listening on %s (pid %d)", s.opts.Paths.Socket, os.Getpid())
|
|
|
|
for {
|
|
conn, err := listener.Accept()
|
|
if err != nil {
|
|
select {
|
|
case <-s.done:
|
|
s.wg.Wait()
|
|
return nil // clean shutdown
|
|
default:
|
|
// Transient accept error during normal operation.
|
|
s.logf("accept error: %v", err)
|
|
return fmt.Errorf("daemon: accept: %w", err)
|
|
}
|
|
}
|
|
s.trackConn(conn)
|
|
s.wg.Add(1)
|
|
go func() {
|
|
defer s.wg.Done()
|
|
defer s.untrackConn(conn)
|
|
s.handleConn(conn)
|
|
}()
|
|
}
|
|
}
|
|
|
|
// trackConn registers an accepted connection so Shutdown can close it. If a
|
|
// shutdown is already in progress it closes the connection immediately rather
|
|
// than serving it.
|
|
func (s *Server) trackConn(c net.Conn) {
|
|
s.mu.Lock()
|
|
select {
|
|
case <-s.done:
|
|
s.mu.Unlock()
|
|
_ = c.Close()
|
|
return
|
|
default:
|
|
}
|
|
s.conns[c] = struct{}{}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *Server) untrackConn(c net.Conn) {
|
|
s.mu.Lock()
|
|
delete(s.conns, c)
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// Shutdown stops accepting connections, cancels in-flight runs, drains the pool,
|
|
// and removes the socket/lock/status files. Safe to call multiple times.
|
|
func (s *Server) Shutdown() {
|
|
s.shutdownOnce.Do(func() {
|
|
close(s.done)
|
|
s.cancel() // stop in-flight pool runs
|
|
s.mu.Lock()
|
|
if s.listener != nil {
|
|
_ = s.listener.Close()
|
|
}
|
|
// Close every open connection so a handler blocked on an idle/hostile
|
|
// client's read returns at once and wg.Wait() can finish — otherwise a single
|
|
// stalled connection wedges shutdown (and SIGTERM cleanup) forever (D3).
|
|
for c := range s.conns {
|
|
_ = c.Close()
|
|
}
|
|
s.mu.Unlock()
|
|
s.opts.Pool.Drain()
|
|
})
|
|
}
|
|
|
|
func (s *Server) cleanup() {
|
|
if s.listener != nil {
|
|
_ = s.listener.Close()
|
|
}
|
|
if s.socketRoot != nil {
|
|
if err := s.socketRoot.Remove(s.socketName); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
s.logf("daemon: remove control socket: %v", err)
|
|
}
|
|
} else if s.runtimeRoot != nil {
|
|
if err := s.runtimeRoot.Remove(filepath.Base(s.opts.Paths.Socket)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
s.logf("daemon: remove control socket: %v", err)
|
|
}
|
|
} else {
|
|
_ = os.Remove(s.opts.Paths.Socket)
|
|
}
|
|
if s.statusRoot != nil {
|
|
if s.statusCommitted {
|
|
if err := s.statusRoot.Remove(s.statusName); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
s.logf("daemon: remove status file: %v", err)
|
|
}
|
|
}
|
|
}
|
|
if s.lock != nil {
|
|
_ = s.lock.release()
|
|
}
|
|
s.closeRuntimeRoots()
|
|
}
|
|
|
|
func (s *Server) hardenSocket() error {
|
|
if s.runtimeRoot != nil {
|
|
return hardenSocketFileRoot(s.runtimeRoot, filepath.Base(s.opts.Paths.Socket))
|
|
}
|
|
return hardenSocketFile(s.opts.Paths.Socket)
|
|
}
|
|
|
|
func (s *Server) closeRuntimeRoots() {
|
|
if s.socketRoot != nil {
|
|
if err := s.socketRoot.Close(); err != nil {
|
|
s.logf("daemon: close bound socket directory: %v", err)
|
|
}
|
|
}
|
|
s.socketRoot = nil
|
|
if s.statusRoot != nil && s.statusRoot != s.runtimeRoot {
|
|
if err := s.statusRoot.Close(); err != nil {
|
|
s.logf("daemon: close status directory: %v", err)
|
|
}
|
|
}
|
|
s.statusRoot = nil
|
|
if s.runtimeRoot != nil {
|
|
if err := s.runtimeRoot.Close(); err != nil {
|
|
s.logf("daemon: close runtime directory: %v", err)
|
|
}
|
|
}
|
|
s.runtimeRoot = nil
|
|
}
|
|
|
|
func (s *Server) writeStatusFile() error {
|
|
status := StatusFile{
|
|
PID: os.Getpid(),
|
|
Socket: s.opts.Paths.Socket,
|
|
Version: s.opts.Version,
|
|
StartedAt: s.startedAt,
|
|
}
|
|
data, err := json.MarshalIndent(status, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
root := s.statusRoot
|
|
ownedRoot := false
|
|
if root == nil {
|
|
var err error
|
|
root, err = openStatusRoot(s.opts.Paths.Status)
|
|
if err != nil {
|
|
return fmt.Errorf("daemon: write status file: %w", err)
|
|
}
|
|
ownedRoot = true
|
|
}
|
|
committed, err := writeStatusFileAtomicallyRoot(root, filepath.Base(s.opts.Paths.Status), data, 0o600, s.opts.beforeStatusReplace, s.opts.replaceStatusFile, s.opts.syncStatusParent)
|
|
if ownedRoot {
|
|
if closeErr := root.Close(); closeErr != nil {
|
|
err = errors.Join(err, fmt.Errorf("close status directory: %w", closeErr))
|
|
}
|
|
}
|
|
if committed {
|
|
s.statusCommitted = true
|
|
}
|
|
if err != nil {
|
|
if committed {
|
|
s.logf("daemon: status file publication committed with warning: %v", err)
|
|
return nil
|
|
}
|
|
return fmt.Errorf("daemon: write status file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ServeConn runs the control protocol (handshake + one command) on an
|
|
// already-established connection, reusing the exact local dispatch path. The
|
|
// remote bridge calls it AFTER authenticating a TLS connection, so a remote
|
|
// session is handled identically to a local one (same SessionManager/Pool, same
|
|
// sandbox/risk model) — remote never bypasses the local controls. It closes conn.
|
|
func (s *Server) ServeConn(conn net.Conn) {
|
|
// Track the conn so Shutdown can close it: the remote bridge enters here (not via
|
|
// the local accept loop), so without this a remote connection stalled in the
|
|
// pre-stream handshake read would survive Shutdown's conns-close sweep. (AUDIT-M8)
|
|
s.trackConn(conn)
|
|
defer s.untrackConn(conn)
|
|
s.handleConn(conn)
|
|
}
|
|
|
|
// handleConn performs the handshake then dispatches a single control command.
|
|
func (s *Server) handleConn(conn net.Conn) {
|
|
defer conn.Close()
|
|
|
|
// Bound the handshake (hello + first command). The remote bridge clears the conn
|
|
// deadline before handing off and the local socket sets none, so an idle/hostile
|
|
// peer that connects but never completes the exchange would otherwise pin a
|
|
// handler goroutine (and, on the bridge, a connection slot) forever. Cleared once
|
|
// the command is read, before the (long-lived) streaming phase. (AUDIT-M7, AUDIT-I1)
|
|
_ = conn.SetDeadline(time.Now().Add(handshakeTimeout))
|
|
|
|
hello, err := ReadControl(conn)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if hello.Type != CtrlHello {
|
|
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: "expected hello"})
|
|
return
|
|
}
|
|
version, ok := NegotiateVersion(hello.Version)
|
|
if !ok {
|
|
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: "unsupported protocol version"})
|
|
return
|
|
}
|
|
if err := WriteControl(conn, Ctrl{Type: CtrlHelloOK, Version: version}); err != nil {
|
|
return
|
|
}
|
|
|
|
cmd, err := ReadControl(conn)
|
|
if err != nil {
|
|
return
|
|
}
|
|
// Handshake complete — the dispatched command may stream indefinitely, so drop
|
|
// the deadline before handing off.
|
|
_ = conn.SetDeadline(time.Time{})
|
|
switch cmd.Type {
|
|
case CtrlRun:
|
|
s.handleRun(conn, cmd)
|
|
case CtrlAttach:
|
|
s.handleAttach(conn, cmd)
|
|
case CtrlStatus:
|
|
s.handleStatus(conn)
|
|
case CtrlShutdown:
|
|
_ = WriteControl(conn, Ctrl{Type: CtrlAck, Message: "shutting down"})
|
|
s.Shutdown()
|
|
default:
|
|
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: fmt.Sprintf("unknown command %q", cmd.Type)})
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleRun(conn net.Conn, cmd Ctrl) {
|
|
if cmd.Session == "" {
|
|
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: "run requires a session id"})
|
|
return
|
|
}
|
|
args := cmd.Args
|
|
if cmd.Prompt != "" {
|
|
args = append(args, "--prompt", cmd.Prompt)
|
|
}
|
|
sess, err := s.opts.Manager.Start(s.ctx, WorkerSpec{Session: cmd.Session, Cwd: cmd.Cwd, Args: args})
|
|
if err != nil {
|
|
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: err.Error()})
|
|
return
|
|
}
|
|
_ = WriteControl(conn, Ctrl{Type: CtrlAck, Session: sess.ID()})
|
|
buffered, live, cancel := sess.Subscribe()
|
|
defer cancel()
|
|
s.streamToClient(conn, sess, buffered, live)
|
|
}
|
|
|
|
func (s *Server) handleAttach(conn net.Conn, cmd Ctrl) {
|
|
if cmd.Session == "" {
|
|
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: "attach requires a session id"})
|
|
return
|
|
}
|
|
buffered, live, cancel, err := s.opts.Manager.Attach(cmd.Session)
|
|
if err != nil {
|
|
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: err.Error()})
|
|
return
|
|
}
|
|
defer cancel()
|
|
sess, _ := s.opts.Manager.Get(cmd.Session)
|
|
_ = WriteControl(conn, Ctrl{Type: CtrlAck, Session: cmd.Session})
|
|
s.streamToClient(conn, sess, buffered, live)
|
|
}
|
|
|
|
// streamToClient writes the buffered history then live lines as CtrlData frames,
|
|
// finishing with CtrlEnd (or CtrlError if the session failed). A write error
|
|
// (client disconnected) ends the stream without affecting the session.
|
|
func (s *Server) streamToClient(conn net.Conn, sess *Session, buffered []string, live <-chan string) {
|
|
for _, line := range buffered {
|
|
// Honor shutdown during history replay too: a large buffer would otherwise
|
|
// keep writing after Shutdown, delaying drain (D5).
|
|
select {
|
|
case <-s.done:
|
|
_ = WriteControl(conn, Ctrl{Type: CtrlEnd, Message: "daemon shutting down"})
|
|
return
|
|
default:
|
|
}
|
|
if err := WriteControl(conn, Ctrl{Type: CtrlData, Line: line}); err != nil {
|
|
return
|
|
}
|
|
}
|
|
for {
|
|
select {
|
|
case line, ok := <-live:
|
|
if !ok {
|
|
s.finishStream(conn, sess)
|
|
return
|
|
}
|
|
if err := WriteControl(conn, Ctrl{Type: CtrlData, Line: line}); err != nil {
|
|
return
|
|
}
|
|
case <-s.done:
|
|
_ = WriteControl(conn, Ctrl{Type: CtrlEnd, Message: "daemon shutting down"})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) finishStream(conn net.Conn, sess *Session) {
|
|
if sess != nil {
|
|
if err := sess.Err(); err != nil {
|
|
_ = WriteControl(conn, Ctrl{Type: CtrlError, Session: sess.ID(), Message: err.Error()})
|
|
return
|
|
}
|
|
}
|
|
_ = WriteControl(conn, Ctrl{Type: CtrlEnd})
|
|
}
|
|
|
|
func (s *Server) handleStatus(conn net.Conn) {
|
|
report := StatusReport{
|
|
PID: os.Getpid(),
|
|
Version: s.opts.Version,
|
|
Socket: s.opts.Paths.Socket,
|
|
StartedAt: s.startedAt,
|
|
PoolSize: s.opts.Pool.Size(),
|
|
Workers: s.opts.Pool.WorkerStats(),
|
|
Sessions: s.opts.Manager.Statuses(),
|
|
QueueDepth: s.opts.Pool.QueueDepth(),
|
|
}
|
|
_ = WriteControl(conn, Ctrl{Type: CtrlStatusResult, Status: &report})
|
|
}
|