mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -10:00
* feat(daemon): TLS remote bridge — authenticated network access to the daemon Add internal/daemon/remote: an OPT-IN, TLS-only network bridge on top of the local daemon. A remote client drives the SAME SessionManager/Pool over the network behind bearer-token auth, reusing the daemon's framing and dispatch unchanged. The local Unix-socket daemon stays the default and is unchanged. - Bridge: per accepted TLS connection, authenticate THEN hand to the daemon's control dispatch via the new Server.ServeConn seam — a remote session runs under the same sandbox/risk model as a local one (never bypasses it). - Authenticator + TokenAuthenticator: constant-time bearer-token check, token from $ZERO_DAEMON_REMOTE_TOKEN or a file; optional attestation hook (no-op default). Auth happens before any control frame; failure closes the connection after a small backoff; tokens are never logged. - RemoteClient (DialRemote): verified TLS dial (never InsecureSkipVerify) + auth handshake, then the existing daemon.Client via the new NewClientConn seam, so remote and local share one protocol. - Fail-closed: TLS mandatory (refuses without cert/key), a configurable min-protocol-version floor, the 1 MiB frame cap reused, and a bounded concurrent-connection cap that refuses overflow. - CLI: `zero daemon serve-remote --addr --tls-cert --tls-key`; `run`/`attach` gain `--remote <addr> [--token --ca-cert --server-name]`. Daemon seams added (additive, behavior unchanged): Server.ServeConn, NewClientConn. SessionLink (event POST) and git-bundle repo transfer are documented follow-ups. Tests cover auth accept/reject (env + file), TLS-required, cert verification, version floor, run/status/attach round-trip, re-attach resumption, connection cap, and ListenAndServeTLS/Close. Gates: gofmt / vet / build (host+linux+ windows) / test -race / staticcheck (no new) / govulncheck (0) / deadcode (no new) all pass. * swarm: de-flake TestMailboxConcurrentSends on Windows CI The 200-way concurrent-send stress test inherited from the swarm package timed out on Windows CI (103/200 sends hit the 10s lock timeout): the lock's 20ms retry sleep starves waiters when Windows file ops are slow under heavy contention. Shorten the retry to 2ms (a freed lock is re-acquired promptly) and raise the test's lock timeout to 60s so a legitimately-slow CI never times a send out. Passes repeatedly under -race. (CI de-flake of a test that surfaced on #210's Windows smoke; functionally unrelated to the OAuth changes.) * remote: address CodeRabbit re-review (TLS dial + Close idempotency) - client: replace the deprecated, non-cancellation-aware tls.DialWithDialer with a context-aware tls.Dialer.DialContext (resolves the golangci-lint noctx CI blocker; the context bounds the dial + TLS handshake). - bridge: Close now clears b.listener under the lock, so a repeat Close is a no-op instead of returning a closed-listener error (matches its doc). Test asserts a double Close succeeds. Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new)/ govulncheck(0)/deadcode(no new) all pass.
302 lines
8.2 KiB
Go
302 lines
8.2 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"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
|
|
|
|
listener net.Listener
|
|
lock *fileLock
|
|
|
|
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
|
|
}
|
|
|
|
// 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{}),
|
|
}, 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
|
|
}
|
|
if err := secureSocketParent(s.opts.Paths.Socket); err != nil {
|
|
return err
|
|
}
|
|
lock, err := acquireLock(s.opts.Paths.Lock, s.opts.isAlive)
|
|
if err != nil {
|
|
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.
|
|
_ = 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.listener = listener
|
|
if err := hardenSocketFile(s.opts.Paths.Socket); 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.wg.Add(1)
|
|
go func() {
|
|
defer s.wg.Done()
|
|
s.handleConn(conn)
|
|
}()
|
|
}
|
|
}
|
|
|
|
// 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
|
|
if s.listener != nil {
|
|
_ = s.listener.Close()
|
|
}
|
|
s.opts.Pool.Drain()
|
|
})
|
|
}
|
|
|
|
func (s *Server) cleanup() {
|
|
if s.listener != nil {
|
|
_ = s.listener.Close()
|
|
}
|
|
_ = os.Remove(s.opts.Paths.Socket)
|
|
_ = os.Remove(s.opts.Paths.Status)
|
|
if s.lock != nil {
|
|
_ = s.lock.release()
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
if err := os.WriteFile(s.opts.Paths.Status, data, 0o600); err != 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) { s.handleConn(conn) }
|
|
|
|
// handleConn performs the handshake then dispatches a single control command.
|
|
func (s *Server) handleConn(conn net.Conn) {
|
|
defer conn.Close()
|
|
|
|
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
|
|
}
|
|
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 {
|
|
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})
|
|
}
|