Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions internal/cli/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
Expand Down Expand Up @@ -147,11 +146,7 @@ func runDaemonStartDetached(paths daemon.Paths, stdout io.Writer, stderr io.Writ
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)
logFile, logPath, err := daemon.OpenRuntimeLog(paths)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
Expand Down
33 changes: 31 additions & 2 deletions internal/daemon/lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,35 @@ var processAlive = osProcessAlive
// only to enrich a contention error with the current holder's PID; the kernel
// lock, not PID metadata, is authoritative.
func acquireLock(path string, isAlive func(pid int) bool) (*fileLock, error) {
return acquireLockWith(isAlive, func() (*lockutil.FileLock, error) {
return lockutil.TryAcquireFileLock(path)
}, func() (int, error) {
return readPidFile(path)
})
}

func acquireLockRoot(root *os.Root, name, displayPath string, isAlive func(pid int) bool) (*fileLock, error) {
return acquireLockWith(isAlive, func() (*lockutil.FileLock, error) {
return lockutil.TryAcquireFileLockRoot(root, name, displayPath)
}, func() (int, error) {
return readPidFileRoot(root, name)
})
}

func acquireLockWith(
isAlive func(pid int) bool,
acquire func() (*lockutil.FileLock, error),
readPID func() (int, error),
) (*fileLock, error) {
if isAlive == nil {
isAlive = processAlive
}
lock, err := lockutil.TryAcquireFileLock(path)
lock, err := acquire()
if err != nil {
if !errors.Is(err, lockutil.ErrLockHeld) {
return nil, err
}
pid, perr := readPidFile(path)
pid, perr := readPID()
if perr == nil && pid > 0 && isAlive(pid) {
return nil, fmt.Errorf("%w (pid %d)", ErrAlreadyRunning, pid)
}
Expand All @@ -62,6 +82,15 @@ func (l *fileLock) release() error {
// readPidFile reads and parses the PID recorded in a lock file.
func readPidFile(path string) (int, error) {
data, err := os.ReadFile(path)
return parsePidFile(data, err)
}

func readPidFileRoot(root *os.Root, name string) (int, error) {
data, err := root.ReadFile(name)
return parsePidFile(data, err)
}

func parsePidFile(data []byte, err error) (int, error) {
if err != nil {
return 0, err
}
Expand Down
142 changes: 135 additions & 7 deletions internal/daemon/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"net"
"os"
"path/filepath"
"sync"
"time"
)
Expand All @@ -23,6 +24,17 @@ type Server struct {
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
// 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
Expand All @@ -41,6 +53,13 @@ type ServerOptions struct {
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 after rooted lock acquisition
}

// NewServer validates options and builds a Server.
Expand Down Expand Up @@ -80,19 +99,61 @@ 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 {
defaultRoot, isDefault, err := openDefaultRuntimeRoot(s.opts.Paths)
if err != nil {
return err
}
lock, err := acquireLock(s.opts.Paths.Lock, s.opts.isAlive)
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.
_ = os.Remove(s.opts.Paths.Socket)
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
}
} else {
_ = os.Remove(s.opts.Paths.Socket)
}

listener, err := net.Listen("unix", s.opts.Paths.Socket)
if err != nil {
Expand All @@ -101,6 +162,15 @@ func (s *Server) Serve() error {
s.mu.Lock()
s.listener = listener
s.mu.Unlock()
if s.runtimeRoot != nil {
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).
Expand All @@ -111,7 +181,7 @@ func (s *Server) Serve() error {
return nil
default:
}
if err := hardenSocketFile(s.opts.Paths.Socket); err != nil {
if err := s.hardenSocket(); err != nil {
return fmt.Errorf("daemon: harden control socket: %w", err)
}
s.startedAt = s.opts.Now()
Expand Down Expand Up @@ -190,11 +260,46 @@ 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.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.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 {
Expand All @@ -208,7 +313,30 @@ func (s *Server) writeStatusFile() error {
if err != nil {
return err
}
if err := os.WriteFile(s.opts.Paths.Status, data, 0o600); err != nil {
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
Expand Down
Loading
Loading