]> go.fuhry.dev Git - runtime.git/commitdiff
utils/daemon: add new package
authorDan Fuhry <dan@fuhry.com>
Fri, 13 Sep 2024 00:38:32 +0000 (20:38 -0400)
committerDan Fuhry <dan@fuhry.com>
Fri, 13 Sep 2024 00:38:32 +0000 (20:38 -0400)
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.

utils/daemon/constants_linux.go [new file with mode: 0644]
utils/daemon/constants_linux_arm64.go [new file with mode: 0644]
utils/daemon/constants_unix.go [new file with mode: 0644]
utils/daemon/daemonize.go [new file with mode: 0644]
utils/daemon/pidfile.go [new file with mode: 0644]
utils/daemon/process_linux.go [new file with mode: 0644]
utils/daemon/process_unix.go [new file with mode: 0644]
utils/daemon/socket_pair.go [new file with mode: 0644]

diff --git a/utils/daemon/constants_linux.go b/utils/daemon/constants_linux.go
new file mode 100644 (file)
index 0000000..07bacc4
--- /dev/null
@@ -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 (file)
index 0000000..1f4fbd2
--- /dev/null
@@ -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 (file)
index 0000000..229b4c2
--- /dev/null
@@ -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 (file)
index 0000000..35fcde0
--- /dev/null
@@ -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 (file)
index 0000000..7b5b557
--- /dev/null
@@ -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 (file)
index 0000000..f3dc182
--- /dev/null
@@ -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/<pid>/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 (file)
index 0000000..d4555e9
--- /dev/null
@@ -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 (file)
index 0000000..e6a414b
--- /dev/null
@@ -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)
+}