From: Dan Fuhry Date: Fri, 13 Sep 2024 00:38:32 +0000 (-0400) Subject: utils/daemon: add new package X-Git-Url: https://go.fuhry.dev/?a=commitdiff_plain;h=fbe694d5b69a37232aff664206bf5aa3583de894;p=runtime.git utils/daemon: add new package Add new "daemon" package to handle detaching on Linux and Unix systems. Supports writing pid files, socket pair based startup messaging, and systemd notification on Linux. --- diff --git a/utils/daemon/constants_linux.go b/utils/daemon/constants_linux.go new file mode 100644 index 0000000..07bacc4 --- /dev/null +++ b/utils/daemon/constants_linux.go @@ -0,0 +1,16 @@ +//go:build linux && !arm64 + +package daemon + +import "syscall" + +const DefaultPidFileDirectory = "/run" +const DefaultLogDirectory = "/var/log" + +func fork() (uintptr, uintptr, error) { + return syscall.Syscall(syscall.SYS_FORK, 0, 0, 0) +} + +func dup2(oldfd, newfd int) error { + return syscall.Dup2(oldfd, newfd) +} diff --git a/utils/daemon/constants_linux_arm64.go b/utils/daemon/constants_linux_arm64.go new file mode 100644 index 0000000..1f4fbd2 --- /dev/null +++ b/utils/daemon/constants_linux_arm64.go @@ -0,0 +1,26 @@ +//go:build linux && arm64 + +package daemon + +import ( + "fmt" + "syscall" +) + +const DefaultPidFileDirectory = "/run" +const DefaultLogDirectory = "/var/log" + +const SYS_FORK = 2 +const SYS_DUP2 = 63 + +func fork() (uintptr, uintptr, error) { + return syscall.Syscall(SYS_FORK, 0, 0, 0) +} + +func dup2(oldfd int, newfd int) (err error) { + _, _, e1 := syscall.Syscall(SYS_DUP2, uintptr(oldfd), uintptr(newfd), 0) + if e1 != 0 { + err = fmt.Errorf("dup2: errno %d", e1) + } + return +} diff --git a/utils/daemon/constants_unix.go b/utils/daemon/constants_unix.go new file mode 100644 index 0000000..229b4c2 --- /dev/null +++ b/utils/daemon/constants_unix.go @@ -0,0 +1,16 @@ +//go:build !linux && !win32 + +package daemon + +import "syscall" + +const DefaultPidFileDirectory = "/var/run" +const DefaultLogDirectory = "/var/log" + +func fork() (uintptr, uintptr, error) { + return syscall.Syscall(syscall.SYS_FORK, 0, 0, 0) +} + +func dup2(oldfd, newfd int) error { + return syscall.Dup2(oldfd, newfd) +} diff --git a/utils/daemon/daemonize.go b/utils/daemon/daemonize.go new file mode 100644 index 0000000..35fcde0 --- /dev/null +++ b/utils/daemon/daemonize.go @@ -0,0 +1,203 @@ +package daemon + +import ( + "errors" + "flag" + "fmt" + "os" + "path" + "strconv" + "strings" + "syscall" + + "go.fuhry.dev/runtime/constants" + "go.fuhry.dev/runtime/utils/hashset" + "go.fuhry.dev/runtime/utils/log" +) + +const logDirDefaultMode os.FileMode = 0700 +const logFileDefaultMode os.FileMode = 0600 + +var logger *log.Logger + +var stdoutReopenPath, stderrReopenPath string +var detach bool + +func init() { + logger = log.WithPrefix("daemon") + + execName, err := os.Executable() + if err != nil { + execName = "main" + } + + defaultLogDirectory := path.Join(DefaultLogDirectory, constants.OrgSlug) + if strings.HasPrefix(execName, "/home/") { + defaultLogDirectory = path.Dir(execName) + } + defaultStdoutFile := path.Join(defaultLogDirectory, path.Base(execName)+".log") + defaultStderrFile := path.Join(defaultLogDirectory, path.Base(execName)+".err") + + flag.BoolVar(&detach, "detach", true, "Set to false to run in foreground (don't fork and detach)") + flag.StringVar(&stdoutReopenPath, "log.out", defaultStdoutFile, "File to reopen as standard output after daemonizing") + flag.StringVar(&stderrReopenPath, "log.err", defaultStderrFile, "File to reopen as standard error after daemonizing") +} + +func createLogDirs() { + // create log directories, if they don't exist + logDirs := hashset.NewHashSet[string]() + logDirs.Add(path.Dir(stdoutReopenPath)) + logDirs.Add(path.Dir(stderrReopenPath)) + for _, dir := range logDirs.AsSlice() { + stat, err := os.Stat(dir) + if err == nil { + // log directory exists, is it a directory? + if !stat.IsDir() { + logger.Warnf("cannot open log: %q exists but is not a directory", dir) + } + } else if errors.Is(err, os.ErrNotExist) { + // log directory doesn't exist + err = os.MkdirAll(dir, logDirDefaultMode) + if err != nil { + logger.Warnf("failed to create log directory %q: %v", dir, err) + } + } else { + logger.Warnf("failed to stat log directory %q: %v", dir, err) + } + } +} + +// ReopenStdio redirects the standard output and standard error file descriptors to the +// log files set on the command line. +func ReopenStdio() { + createLogDirs() + + newStdout, err := os.OpenFile(stdoutReopenPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, logFileDefaultMode) + if err != nil { + logger.Warningf("failed to open output logfile: %v", err) + goto skipStdout + } + + err = dup2(int(newStdout.Fd()), int(os.Stdout.Fd())) + if err != nil { + logger.Warningf("failed to dup2() for stdout: %v", err) + } +skipStdout: + + newStderr, err := os.OpenFile(stderrReopenPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, logFileDefaultMode) + if err != nil { + logger.Warningf("failed to open error logfile: %v", err) + goto skipStderr + } + + err = dup2(int(newStderr.Fd()), int(os.Stderr.Fd())) + if err != nil { + logger.Warningf("failed to dup2() for stderr: %v", err) + } +skipStderr: + + return +} + +// Detach re-executes the program that is currently running with standard output and standard error +// redirected to log files. +// +// We don't directly use fork, because fork only copies the current thread and that messes up the +// golang runtime quite a bit. Instead, we create a new process and use a socket pair for IPC to +// have the child process notify us that it's alive. +// +// This is probably *quite* unix-specific; it was tested on Linux and OpenBSD, and nowhere else. +func Detach() error { + if !detach { + return nil + } + + if socketfdStr := os.Getenv("GODAEMON_SOCKETFD"); socketfdStr != "" { + socketfd, err := strconv.Atoi(socketfdStr) + if err != nil { + return err + } + + parentConn, err := fdToFileConn(socketfd) + if err != nil { + return err + } + + parentConn.Write([]byte(strconv.Itoa(os.Getpid()) + "\n")) + parentConn.Close() + + return nil + } + + logger.V(1).Infof("stdout will be redirected to: %s", stdoutReopenPath) + logger.V(1).Infof("stderr will be redirected to: %s", stderrReopenPath) + + createLogDirs() + stdout, err := os.OpenFile(stdoutReopenPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, logFileDefaultMode) + if err != nil { + return err + } + defer stdout.Close() + + stderr, err := os.OpenFile(stderrReopenPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, logFileDefaultMode) + if err != nil { + return err + } + defer stderr.Close() + + executable, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get calling executable: %v", err) + } + + left, right, err := socketpair() + if err != nil { + return fmt.Errorf("failed to get socket pair: %v", err) + } + + leftConn, err := fdToFileConn(left) + if err != nil { + return err + } + defer leftConn.Close() + defer syscall.Close(right) + + env := append(os.Environ(), "GODAEMON_SOCKETFD=3") + + attrs := &syscall.ProcAttr{ + Dir: "/", + Env: env, + Files: []uintptr{ + uintptr(syscall.Stdin), + uintptr(stdout.Fd()), + uintptr(stderr.Fd()), + uintptr(right), + }, + Sys: &syscall.SysProcAttr{ + Setsid: true, + }, + } + + pid, err := syscall.ForkExec(executable, os.Args, attrs) + if err != nil { + return err + } + + buf := make([]byte, 32) + n, err := leftConn.Read(buf) + if err != nil { + return err + } + buf = buf[0 : n-1] + + if checkPid, err := strconv.Atoi(string(buf)); err == nil { + if checkPid == pid { + logger.Infof("successfully daemonized with pid %d", pid) + os.Exit(0) + } + + return fmt.Errorf("got unexpected child pid: %d != %d", checkPid, pid) + } + + return fmt.Errorf("got unexpected message through daemon socket: %s", string(buf)) +} diff --git a/utils/daemon/pidfile.go b/utils/daemon/pidfile.go new file mode 100644 index 0000000..7b5b557 --- /dev/null +++ b/utils/daemon/pidfile.go @@ -0,0 +1,104 @@ +package daemon + +import ( + "flag" + "fmt" + "io" + "os" + "path" + "strconv" + "strings" + "syscall" +) + +var pidFilePath string +var pidFileMode os.FileMode = 0644 + +var enforceSingleInstance bool +var replaceInstance bool + +func WritePidFile() error { + pid, err := ReadPidFile() + if err == nil && processIsAlive(pid) { + return fmt.Errorf("refusing to overwrite existing pid file with alive process %d", pid) + } + + pid = os.Getpid() + pidBytes := []byte(strconv.Itoa(pid)) + + return os.WriteFile(pidFilePath, pidBytes, pidFileMode) +} + +func PidFilePath() string { + return pidFilePath +} + +func ReadPidFile() (int, error) { + fp, err := os.OpenFile(pidFilePath, os.O_RDONLY|os.O_EXCL, pidFileMode) + if err != nil { + return -1, err + } + defer fp.Close() + + pidBytes, err := io.ReadAll(fp) + if err != nil && err != io.EOF { + return -1, err + } + + pid, err := strconv.Atoi(string(pidBytes)) + if err != nil { + return -1, err + } + + return pid, nil +} + +func CleanupPidFile() error { + pid, err := ReadPidFile() + if err != nil { + return err + } + + if pid != os.Getpid() { + return fmt.Errorf( + "refusing to remove pid file %q: PID mismatch: %d (our PID) != %d (recorded PID)", + pidFilePath, os.Getpid(), pid) + } + + return os.Remove(pidFilePath) +} + +func Singleton() { + pid, err := ReadPidFile() + if err != nil || !processIsAlive(pid) { + return + } + +} + +func processIsAlive(pid int) bool { + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + defer proc.Release() + + return proc.Signal(syscall.Signal(0)) == nil +} + +func init() { + execName, err := os.Executable() + if err != nil { + execName = "main" + } + pidFileDirectory := DefaultPidFileDirectory + if strings.HasPrefix(execName, "/home/") { + pidFileDirectory = path.Dir(execName) + } + defaultPidFilePath := path.Join(pidFileDirectory, path.Base(execName)+".pid") + flag.StringVar(&pidFilePath, "pid.file", defaultPidFilePath, "Path to PID file to write after startup") + flag.BoolVar(&enforceSingleInstance, "pid.singleton", true, + "if true, enforce that only a single instance of this program runs (based on pid file)") + flag.BoolVar(&replaceInstance, "pid.replace", false, + "if true, when another running instance is found, kill it instead of exiting with an error") +} diff --git a/utils/daemon/process_linux.go b/utils/daemon/process_linux.go new file mode 100644 index 0000000..f3dc182 --- /dev/null +++ b/utils/daemon/process_linux.go @@ -0,0 +1,31 @@ +//go:build linux + +package daemon + +import ( + "os" + "path" + "path/filepath" + "strconv" +) + +func processIsSameExecutable(pid int) bool { + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + defer proc.Release() + + execPath, err := os.Executable() + if err != nil { + return false + } + + // linux: use /proc//exe + if target, err := os.Readlink(path.Join("/proc", strconv.Itoa(pid), "exe")); err == nil { + abspath, err := filepath.Abs(target) + return err == nil && abspath == execPath + } + + return false +} diff --git a/utils/daemon/process_unix.go b/utils/daemon/process_unix.go new file mode 100644 index 0000000..d4555e9 --- /dev/null +++ b/utils/daemon/process_unix.go @@ -0,0 +1,31 @@ +//go:build opsnbsd || freebsd || netbsd + +package daemon + +import ( + "os" + "path/filepath" + + "github.com/mitchellh/go-ps" +) + +func processIsSameExecutable(pid int) bool { + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + defer proc.Release() + + execPath, err := os.Executable() + if err != nil { + return false + } + + // other platforms: use mitchellh/go-ps + if proc, err := ps.FindProcess(pid); err == nil { + abspath, err := filepath.Abs(proc.Executable()) + return err == nil && abspath == execPath + } + + return false +} diff --git a/utils/daemon/socket_pair.go b/utils/daemon/socket_pair.go new file mode 100644 index 0000000..e6a414b --- /dev/null +++ b/utils/daemon/socket_pair.go @@ -0,0 +1,22 @@ +package daemon + +import ( + "net" + "os" + "syscall" +) + +func socketpair() (int, int, error) { + fds, err := syscall.Socketpair(syscall.AF_LOCAL, syscall.SOCK_STREAM, 0) + if err != nil { + return -1, -1, err + } + + return fds[0], fds[1], nil +} + +func fdToFileConn(fd int) (net.Conn, error) { + f := os.NewFile(uintptr(fd), "") + defer f.Close() + return net.FileConn(f) +}