Files
zero/internal/cli/daemon.go
KRATOS f857c4afb4 feat(daemon): TLS remote bridge — authenticated network access to the daemon (#211)
* 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.
2026-06-15 18:48:07 +05:30

588 lines
18 KiB
Go

package cli
import (
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/Gitlawb/zero/internal/background"
"github.com/Gitlawb/zero/internal/daemon"
"github.com/Gitlawb/zero/internal/daemon/remote"
)
// runDaemon dispatches the `zero daemon ...` subcommands. The daemon supervises a
// pool of headless `zero exec` workers and routes sessions to them over an
// owner-only local control socket. It is an ADDITIVE surface — interactive and
// one-shot exec are unchanged.
func runDaemon(args []string, stdout io.Writer, stderr io.Writer, _ appDeps) int {
if len(args) == 0 {
return writeDaemonUsage(stderr, exitUsage)
}
sub, rest := args[0], args[1:]
switch sub {
case "start":
return runDaemonStart(rest, stdout, stderr)
case "stop":
return runDaemonStop(rest, stdout, stderr)
case "status":
return runDaemonStatus(rest, stdout, stderr)
case "run":
return runDaemonRun(rest, stdout, stderr)
case "attach":
return runDaemonAttach(rest, stdout, stderr)
case "serve-remote":
return runDaemonServeRemote(rest, stdout, stderr)
case "-h", "--help", "help":
return writeDaemonUsage(stdout, exitSuccess)
default:
fmt.Fprintf(stderr, "unknown daemon subcommand %q\n", sub)
return writeDaemonUsage(stderr, exitUsage)
}
}
func writeDaemonUsage(w io.Writer, code int) int {
fmt.Fprint(w, `Usage: zero daemon <command>
Commands:
start [--foreground] Start the daemon (background by default).
stop Gracefully stop the running daemon.
status Show daemon / pool / session status.
run --session <id> [--cwd <dir>] [--prompt <text>] [exec flags...]
Create/route a session and stream its output.
attach <session> Attach to a running session's stream.
serve-remote --addr <host:port> --tls-cert <f> --tls-key <f>
Serve an opt-in, TLS-only network bridge to this
daemon. Requires a bearer token in $ZERO_DAEMON_REMOTE_TOKEN
(or $ZERO_DAEMON_REMOTE_TOKEN_FILE).
run and attach accept --remote <host:port> [--token <t>] [--ca-cert <f>]
[--server-name <name>] to drive a remote daemon over the bridge instead of the
local socket.
`)
return code
}
// runDaemonStart starts the daemon. Without --foreground it spawns a detached
// background process running the foreground daemon and returns once it is up.
func runDaemonStart(args []string, stdout io.Writer, stderr io.Writer) int {
foreground := false
for _, a := range args {
switch a {
case "--foreground", "-f":
foreground = true
case "-h", "--help":
return writeDaemonUsage(stdout, exitSuccess)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown flag %q for daemon start", a))
}
}
paths, err := daemon.DefaultPaths()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if foreground {
return runDaemonForeground(paths, stdout, stderr)
}
return runDaemonStartDetached(paths, stdout, stderr)
}
// runDaemonForeground runs the daemon in this process until SIGINT/SIGTERM. Each
// worker is a headless `zero exec` child with the sandbox re-entrancy markers
// scrubbed (NewExecLauncher) so it establishes its own sandbox.
func runDaemonForeground(paths daemon.Paths, stdout io.Writer, stderr io.Writer) int {
launcher, err := daemon.NewExecLauncher(daemon.ExecLauncherConfig{})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
logf := func(line string) { fmt.Fprintln(stderr, "[daemon] "+line) }
pool, err := daemon.NewPool(daemon.PoolOptions{Launcher: launcher, Log: logf})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
mgr, err := daemon.NewSessionManager(daemon.SessionManagerOptions{Pool: pool})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
srv, err := daemon.NewServer(daemon.ServerOptions{Paths: paths, Manager: mgr, Pool: pool, Log: logf})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
go func() {
<-sigCh
srv.Shutdown()
}()
if err := srv.Serve(); err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
return exitSuccess
}
// runDaemonStartDetached spawns the foreground daemon as a detached background
// process (its own process group, output to a log file) and waits for it to bind.
func runDaemonStartDetached(paths daemon.Paths, stdout io.Writer, stderr io.Writer) int {
if daemonReachable(paths) {
fmt.Fprintln(stdout, "zero daemon is already running")
return exitSuccess
}
exe, err := os.Executable()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if err := os.MkdirAll(filepath.Dir(paths.Socket), 0o700); err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
logPath := filepath.Join(filepath.Dir(paths.Socket), "daemon.log")
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
defer logFile.Close()
cmd := exec.Command(exe, "daemon", "start", "--foreground")
cmd.Stdout = logFile
cmd.Stderr = logFile
background.ConfigureChildProcessGroup(cmd) // own process group: outlives this shell
if err := cmd.Start(); err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
_ = cmd.Process.Release()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if daemonReachable(paths) {
fmt.Fprintf(stdout, "zero daemon started (socket %s)\n", paths.Socket)
return exitSuccess
}
time.Sleep(25 * time.Millisecond)
}
return writeAppError(stderr, "daemon did not come up within timeout; see "+logPath, exitCrash)
}
func runDaemonStop(args []string, stdout io.Writer, stderr io.Writer) int {
if helpRequested(args) {
return writeDaemonUsage(stdout, exitSuccess)
}
if len(args) > 0 {
return writeExecUsageError(stderr, "daemon stop takes no arguments")
}
paths, err := daemon.DefaultPaths()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
client, err := daemon.Dial(paths.Socket)
if err != nil {
fmt.Fprintln(stdout, "zero daemon is not running")
return exitSuccess
}
defer client.Close()
if err := client.Shutdown(); err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
fmt.Fprintln(stdout, "zero daemon stopped")
return exitSuccess
}
func runDaemonStatus(args []string, stdout io.Writer, stderr io.Writer) int {
if helpRequested(args) {
return writeDaemonUsage(stdout, exitSuccess)
}
if len(args) > 0 {
return writeExecUsageError(stderr, "daemon status takes no arguments")
}
paths, err := daemon.DefaultPaths()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
client, err := daemon.Dial(paths.Socket)
if err != nil {
fmt.Fprintln(stdout, "zero daemon is not running")
return exitSuccess
}
defer client.Close()
report, err := client.Status()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
fmt.Fprintf(stdout, "daemon pid=%d version=%d socket=%s\n", report.PID, report.Version, report.Socket)
fmt.Fprintf(stdout, "pool size=%d busy=%d queue=%d\n", report.PoolSize, len(report.Workers), report.QueueDepth)
for _, s := range report.Sessions {
fmt.Fprintf(stdout, " session %s state=%s lines=%d\n", s.ID, s.State, s.Lines)
}
return exitSuccess
}
func runDaemonRun(args []string, stdout io.Writer, stderr io.Writer) int {
session, cwd, prompt := "", "", ""
var remoteFlags remoteDialFlags
var forward []string
for i := 0; i < len(args); i++ {
a := args[i]
value := func() (string, bool) {
if i+1 >= len(args) {
return "", false
}
i++
return args[i], true
}
switch {
case a == "-h" || a == "--help":
return writeDaemonUsage(stdout, exitSuccess)
case a == "--remote":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--remote requires a value")
}
remoteFlags.Addr = v
case strings.HasPrefix(a, "--remote="):
remoteFlags.Addr = strings.TrimPrefix(a, "--remote=")
case a == "--token":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--token requires a value")
}
remoteFlags.Token = v
case strings.HasPrefix(a, "--token="):
remoteFlags.Token = strings.TrimPrefix(a, "--token=")
case a == "--ca-cert":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--ca-cert requires a value")
}
remoteFlags.CACert = v
case strings.HasPrefix(a, "--ca-cert="):
remoteFlags.CACert = strings.TrimPrefix(a, "--ca-cert=")
case a == "--server-name":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--server-name requires a value")
}
remoteFlags.ServerName = v
case strings.HasPrefix(a, "--server-name="):
remoteFlags.ServerName = strings.TrimPrefix(a, "--server-name=")
case a == "--session":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--session requires a value")
}
session = v
case strings.HasPrefix(a, "--session="):
session = strings.TrimPrefix(a, "--session=")
case a == "--cwd":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--cwd requires a value")
}
cwd = v
case strings.HasPrefix(a, "--cwd="):
cwd = strings.TrimPrefix(a, "--cwd=")
case a == "--prompt" || a == "-p":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--prompt requires a value")
}
prompt = v
case strings.HasPrefix(a, "--prompt="):
prompt = strings.TrimPrefix(a, "--prompt=")
default:
// Forwarded verbatim to the worker `zero exec` (reuses exec's own flag
// parsing for per-session run options).
forward = append(forward, a)
}
}
if strings.TrimSpace(session) == "" {
return writeExecUsageError(stderr, "daemon run requires --session <id>")
}
if prompt == "" && len(forward) == 0 {
return writeExecUsageError(stderr, "daemon run requires --prompt <text> or exec args")
}
client, err := dialForCLI(remoteFlags)
if err != nil {
return writeAppError(stderr, daemonDialError(remoteFlags, err), exitCrash)
}
defer client.Close()
if err := client.Run(session, cwd, prompt, forward, func(line string) { fmt.Fprintln(stdout, line) }); err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
return exitSuccess
}
func runDaemonAttach(args []string, stdout io.Writer, stderr io.Writer) int {
session := ""
var remoteFlags remoteDialFlags
extra := 0
for i := 0; i < len(args); i++ {
a := args[i]
value := func() (string, bool) {
if i+1 >= len(args) {
return "", false
}
i++
return args[i], true
}
switch {
case a == "-h" || a == "--help":
return writeDaemonUsage(stdout, exitSuccess)
case a == "--remote":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--remote requires a value")
}
remoteFlags.Addr = v
case strings.HasPrefix(a, "--remote="):
remoteFlags.Addr = strings.TrimPrefix(a, "--remote=")
case a == "--token":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--token requires a value")
}
remoteFlags.Token = v
case strings.HasPrefix(a, "--token="):
remoteFlags.Token = strings.TrimPrefix(a, "--token=")
case a == "--ca-cert":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--ca-cert requires a value")
}
remoteFlags.CACert = v
case strings.HasPrefix(a, "--ca-cert="):
remoteFlags.CACert = strings.TrimPrefix(a, "--ca-cert=")
case a == "--server-name":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--server-name requires a value")
}
remoteFlags.ServerName = v
case strings.HasPrefix(a, "--server-name="):
remoteFlags.ServerName = strings.TrimPrefix(a, "--server-name=")
case session == "":
session = a
default:
extra++
}
}
if strings.TrimSpace(session) == "" {
return writeExecUsageError(stderr, "daemon attach requires a <session> id")
}
if extra > 0 {
return writeExecUsageError(stderr, "daemon attach accepts exactly one <session> id")
}
client, err := dialForCLI(remoteFlags)
if err != nil {
return writeAppError(stderr, daemonDialError(remoteFlags, err), exitCrash)
}
defer client.Close()
if err := client.Attach(session, func(line string) { fmt.Fprintln(stdout, line) }); err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
return exitSuccess
}
// daemonDialError tailors the connect-failure message for local vs remote.
func daemonDialError(flags remoteDialFlags, err error) string {
if strings.TrimSpace(flags.Addr) != "" {
return "failed to reach remote daemon at " + flags.Addr + ": " + err.Error()
}
return "zero daemon is not running (start it with `zero daemon start`)"
}
// runDaemonServeRemote starts the local daemon plus an opt-in, TLS-only network
// bridge. TLS and a bearer token are mandatory (fail closed): it refuses to
// start without a cert/key pair and a token from the environment.
func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int {
addr, certFile, keyFile := "", "", ""
minVersion, maxConns := 0, 0
for i := 0; i < len(args); i++ {
a := args[i]
value := func() (string, bool) {
if i+1 >= len(args) {
return "", false
}
i++
return args[i], true
}
switch {
case a == "-h" || a == "--help":
return writeDaemonUsage(stdout, exitSuccess)
case a == "--addr":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--addr requires a value")
}
addr = v
case strings.HasPrefix(a, "--addr="):
addr = strings.TrimPrefix(a, "--addr=")
case a == "--tls-cert":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--tls-cert requires a value")
}
certFile = v
case strings.HasPrefix(a, "--tls-cert="):
certFile = strings.TrimPrefix(a, "--tls-cert=")
case a == "--tls-key":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--tls-key requires a value")
}
keyFile = v
case strings.HasPrefix(a, "--tls-key="):
keyFile = strings.TrimPrefix(a, "--tls-key=")
case a == "--min-version":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--min-version requires a value")
}
minVersion = atoiOrZero(v)
case a == "--max-conns":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--max-conns requires a value")
}
maxConns = atoiOrZero(v)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown flag %q for daemon serve-remote", a))
}
}
if strings.TrimSpace(addr) == "" {
return writeExecUsageError(stderr, "daemon serve-remote requires --addr <host:port>")
}
// Fail closed: TLS cert/key + a bearer token are mandatory.
tlsConfig, err := remote.ServerTLSConfig(certFile, keyFile)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
token, err := remote.TokenFromEnv()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
auth, err := remote.NewTokenAuthenticator(token)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
paths, err := daemon.DefaultPaths()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
launcher, err := daemon.NewExecLauncher(daemon.ExecLauncherConfig{})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
logf := func(line string) { fmt.Fprintln(stderr, "[daemon] "+line) }
pool, err := daemon.NewPool(daemon.PoolOptions{Launcher: launcher, Log: logf})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
mgr, err := daemon.NewSessionManager(daemon.SessionManagerOptions{Pool: pool})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
srv, err := daemon.NewServer(daemon.ServerOptions{Paths: paths, Manager: mgr, Pool: pool, Log: logf})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
bridge, err := remote.NewBridge(remote.BridgeOptions{
Server: srv, Authenticator: auth, MinVersion: minVersion, MaxConnections: maxConns, Log: logf,
})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
// Serve the local control socket too, so local clients keep working.
go func() {
if serveErr := srv.Serve(); serveErr != nil {
logf("local serve error: " + serveErr.Error())
}
}()
serveErr := make(chan error, 1)
go func() { serveErr <- bridge.ListenAndServeTLS(addr, tlsConfig) }()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
fmt.Fprintf(stdout, "zero daemon remote bridge listening on %s (TLS)\n", addr)
select {
case <-sigCh:
srv.Shutdown()
_ = bridge.Close()
<-serveErr // wait for the accept loop to unwind
return exitSuccess
case err := <-serveErr:
// Bind/serve failed before any signal (e.g. address in use).
srv.Shutdown()
return writeAppError(stderr, err.Error(), exitCrash)
}
}
// remoteDialFlags holds the optional flags that redirect run/attach to a remote
// daemon. When Addr is empty the local control socket is used.
type remoteDialFlags struct {
Addr string
Token string
CACert string
ServerName string
}
// dialForCLI returns a daemon.Client for either the local socket (Addr empty) or
// a remote bridge (Addr set). For remote, the token falls back to the env.
func dialForCLI(flags remoteDialFlags) (*daemon.Client, error) {
if strings.TrimSpace(flags.Addr) == "" {
paths, err := daemon.DefaultPaths()
if err != nil {
return nil, err
}
return daemon.Dial(paths.Socket)
}
token := strings.TrimSpace(flags.Token)
if token == "" {
token, _ = remote.TokenFromEnv() // best effort; DialRemote rejects an empty token
}
return remote.DialRemote(remote.RemoteConfig{
Address: flags.Addr,
Token: token,
CACertFile: flags.CACert,
ServerName: flags.ServerName,
})
}
func atoiOrZero(s string) int {
n := 0
for _, r := range strings.TrimSpace(s) {
if r < '0' || r > '9' {
return 0
}
n = n*10 + int(r-'0')
}
return n
}
// daemonReachable reports whether a daemon is accepting connections (a successful
// handshake). Used for the single-instance check and start-up wait.
func daemonReachable(paths daemon.Paths) bool {
client, err := daemon.Dial(paths.Socket)
if err != nil {
return false
}
_ = client.Close()
return true
}
func helpRequested(args []string) bool {
for _, a := range args {
if a == "-h" || a == "--help" {
return true
}
}
return false
}