diff --git a/config/config.go b/config/config.go index 547e2ab..596149a 100644 --- a/config/config.go +++ b/config/config.go @@ -313,7 +313,7 @@ type KeyCert struct { type TLS struct { ACME string `sconf:"optional" sconf-doc:"Name of provider from top-level configuration to use for ACME, e.g. letsencrypt."` - KeyCerts []KeyCert `sconf:"optional"` + KeyCerts []KeyCert `sconf:"optional" sconf-doc:"Key and certificate files are opened by the privileged root process and passed to the unprivileged mox process, so no special permissions are required."` MinVersion string `sconf:"optional" sconf-doc:"Minimum TLS version. Default: TLSv1.2."` Config *tls.Config `sconf:"-" json:"-"` // TLS config for non-ACME-verification connections, i.e. SMTP and IMAP, and not port 443. diff --git a/config/doc.go b/config/doc.go index 91be2a6..02df771 100644 --- a/config/doc.go +++ b/config/doc.go @@ -125,6 +125,8 @@ describe-static" and "mox config describe-domains": # (optional) ACME: + # Key and certificate files are opened by the privileged root process and passed + # to the unprivileged mox process, so no special permissions are required. # (optional) KeyCerts: - diff --git a/localserve.go b/localserve.go index 33078d9..23892d1 100644 --- a/localserve.go +++ b/localserve.go @@ -115,7 +115,7 @@ during those commands instead of during "data". // Tell queue it shouldn't be queuing/delivering. queue.Localserve = true - mox.ListenImmediate = true + mox.FilesImmediate = true const mtastsdbRefresher = false const skipForkExec = true if err := start(mtastsdbRefresher, skipForkExec); err != nil { diff --git a/mox-/config.go b/mox-/config.go index c1230ef..57a7c82 100644 --- a/mox-/config.go +++ b/mox-/config.go @@ -10,6 +10,7 @@ import ( "encoding/pem" "errors" "fmt" + "io" "net" "net/http" "net/url" @@ -1188,7 +1189,7 @@ func loadTLSKeyCerts(configFile, kind string, ctls *config.TLS) error { for _, kp := range ctls.KeyCerts { certPath := configDirPath(configFile, kp.CertFile) keyPath := configDirPath(configFile, kp.KeyFile) - cert, err := tls.LoadX509KeyPair(certPath, keyPath) + cert, err := loadX509KeyPairPrivileged(certPath, keyPath) if err != nil { return fmt.Errorf("tls config for %q: parsing x509 key pair: %v", kind, err) } @@ -1199,3 +1200,27 @@ func loadTLSKeyCerts(configFile, kind string, ctls *config.TLS) error { } return nil } + +// load x509 key/cert files from file descriptor possibly passed in by privileged +// process. +func loadX509KeyPairPrivileged(certPath, keyPath string) (tls.Certificate, error) { + certBuf, err := readFilePrivileged(certPath) + if err != nil { + return tls.Certificate{}, fmt.Errorf("reading tls certificate: %v", err) + } + keyBuf, err := readFilePrivileged(keyPath) + if err != nil { + return tls.Certificate{}, fmt.Errorf("reading tls key: %v", err) + } + return tls.X509KeyPair(certBuf, keyBuf) +} + +// like os.ReadFile, but open privileged file possibly passed in by root process. +func readFilePrivileged(path string) ([]byte, error) { + f, err := OpenPrivileged(path) + if err != nil { + return nil, err + } + defer f.Close() + return io.ReadAll(f) +} diff --git a/mox-/lifecycle.go b/mox-/lifecycle.go index 9ae443a..2a6a61e 100644 --- a/mox-/lifecycle.go +++ b/mox-/lifecycle.go @@ -19,14 +19,17 @@ import ( "github.com/mjl-/mox/mlog" ) -// We start up as root, bind to sockets, and fork and exec as unprivileged user. -// During startup as root, we gather the fd's for the listen addresses in listens, -// and pass their addresses in an environment variable to the new process. -var listens = map[string]*os.File{} +// We start up as root, bind to sockets, open private key/cert files and fork and +// exec as unprivileged user. During startup as root, we gather the fd's for the +// listen addresses in passedListeners and files in passedFiles, and pass their +// addresses and paths in environment variables to the new process. +var passedListeners = map[string]*os.File{} // Listen address to file descriptor. +var passedFiles = map[string][]*os.File{} // Path to file descriptors. -// RestorePassedSockets reads addresses from $MOX_SOCKETS and prepares an os.File -// for each file descriptor, which are used by later calls of Listen. -func RestorePassedSockets() { +// RestorePassedFiles reads addresses from $MOX_SOCKETS and paths from $MOX_FILES +// and prepares an os.File for each file descriptor, which are used by later calls +// of Listen or opening files. +func RestorePassedFiles() { s := os.Getenv("MOX_SOCKETS") if s == "" { var linuxhint string @@ -35,11 +38,21 @@ func RestorePassedSockets() { } xlog.Fatal("mox must be started as root, and will drop privileges after binding required sockets (missing environment variable MOX_SOCKETS)." + linuxhint) } - addrs := strings.Split(s, ",") - for i, addr := range addrs { - // 0,1,2 are stdin,stdout,stderr, 3 is the network/address fd. - f := os.NewFile(3+uintptr(i), addr) - listens[addr] = f + + // 0,1,2 are stdin,stdout,stderr, 3 is the first passed fd (first listeners, then files). + var o uintptr = 3 + for _, addr := range strings.Split(s, ",") { + passedListeners[addr] = os.NewFile(o, addr) + o++ + } + + files := os.Getenv("MOX_FILES") + if files == "" { + return + } + for _, path := range strings.Split(files, ",") { + passedFiles[path] = append(passedFiles[path], os.NewFile(o, path)) + o++ } } @@ -56,12 +69,19 @@ func ForkExecUnprivileged() { files := []*os.File{os.Stdin, os.Stdout, os.Stderr} var addrs []string - for addr, f := range listens { + for addr, f := range passedListeners { files = append(files, f) addrs = append(addrs, addr) } + var paths []string + for path, fl := range passedFiles { + for _, f := range fl { + files = append(files, f) + paths = append(paths, path) + } + } env := os.Environ() - env = append(env, "MOX_SOCKETS="+strings.Join(addrs, ",")) + env = append(env, "MOX_SOCKETS="+strings.Join(addrs, ","), "MOX_FILES="+strings.Join(paths, ",")) p, err := os.StartProcess(prog, os.Args, &os.ProcAttr{ Env: env, @@ -76,10 +96,7 @@ func ForkExecUnprivileged() { if err != nil { xlog.Fatalx("fork and exec", err) } - for _, f := range listens { - err := f.Close() - xlog.Check(err, "closing socket after passing to unprivileged child") - } + CleanupPassedFiles() // If we get a interrupt/terminate signal, pass it on to the child. For interrupt, // the child probably already got it. @@ -101,26 +118,34 @@ func ForkExecUnprivileged() { os.Exit(code) } -// CleanupPassedSockets closes the listening socket file descriptors passed in by -// the parent process. To be called after listeners have been recreated (they dup -// the file descriptor). -func CleanupPassedSockets() { - for _, f := range listens { +// CleanupPassedFiles closes the listening socket file descriptors and files passed +// in by the parent process. To be called by the unprivileged child after listeners +// have been recreated (they dup the file descriptor), and by the privileged +// process after starting its child. +func CleanupPassedFiles() { + for _, f := range passedListeners { err := f.Close() xlog.Check(err, "closing listener socket file descriptor") } + for _, fl := range passedFiles { + for _, f := range fl { + err := f.Close() + xlog.Check(err, "closing path file descriptor") + } + } } -// Make Listen listen immediately, regardless of running as root or other user, in -// case ForkExecUnprivileged is not used. -var ListenImmediate bool +// For privileged file descriptor operations (listen and opening privileged files), +// perform them immediately, regardless of running as root or other user, in case +// ForkExecUnprivileged is not used. +var FilesImmediate bool // Listen returns a newly created network listener when starting as root, and // otherwise (not root) returns a network listener from a file descriptor that was // passed by the parent root process. func Listen(network, addr string) (net.Listener, error) { - if os.Getuid() != 0 && !ListenImmediate { - f, ok := listens[addr] + if os.Getuid() != 0 && !FilesImmediate { + f, ok := passedListeners[addr] if !ok { return nil, fmt.Errorf("no file descriptor for listener %s", addr) } @@ -131,7 +156,7 @@ func Listen(network, addr string) (net.Listener, error) { return ln, nil } - if _, ok := listens[addr]; ok { + if _, ok := passedListeners[addr]; ok { return nil, fmt.Errorf("duplicate listener: %s", addr) } @@ -147,10 +172,36 @@ func Listen(network, addr string) (net.Listener, error) { if err != nil { return nil, fmt.Errorf("dup listener: %v", err) } - listens[addr] = f + passedListeners[addr] = f return ln, err } +// Open a privileged file, such as a TLS private key. When running as root +// (during startup), the file is opened and the file descriptor is stored. +// These file descriptors are passed to the unprivileged process. When in the +// unprivileged processed, we lookup a passed file descriptor. +// The same calls should be made in the privileged and unprivileged process. +func OpenPrivileged(path string) (*os.File, error) { + if os.Getuid() != 0 && !FilesImmediate { + fl := passedFiles[path] + if len(fl) == 0 { + return nil, fmt.Errorf("no file descriptor for file %s", path) + } + f := fl[0] + passedFiles[path] = fl[1:] + return f, nil + } + + f, err := os.Open(path) + if err != nil { + return nil, err + } + passedFiles[path] = append(passedFiles[path], f) + + // Open again, the caller will be closing this file. + return os.Open(path) +} + // Shutdown is canceled when a graceful shutdown is initiated. SMTP, IMAP, periodic // processes should check this before starting a new operation. If this context is // canaceled, the operation should not be started, and new connections/commands should diff --git a/serve.go b/serve.go index 00a657a..e1db32a 100644 --- a/serve.go +++ b/serve.go @@ -147,11 +147,12 @@ requested, other TLS certificates are requested on demand. mlog.SetConfig(mox.Conf.Log) checkACMEHosts := os.Getuid() != 0 - mox.MustLoadConfig(checkACMEHosts) log := mlog.New("serve") if os.Getuid() == 0 { + mox.MustLoadConfig(checkACMEHosts) + // No need to potentially start and keep multiple processes. As root, we just need // to start the child process. runtime.GOMAXPROCS(1) @@ -160,6 +161,9 @@ requested, other TLS certificates are requested on demand. if os.Getenv("MOX_SOCKETS") != "" { log.Fatal("refusing to start as root with $MOX_SOCKETS set") } + if os.Getenv("MOX_FILES") != "" { + log.Fatal("refusing to start as root with $MOX_FILES set") + } if !mox.Conf.Static.NoFixPermissions { // Fix permissions now that we have privilege to do so. Useful for update of v0.0.1 @@ -178,7 +182,8 @@ requested, other TLS certificates are requested on demand. } } else { log.Print("starting as unprivileged user", mlog.Field("user", mox.Conf.Static.User), mlog.Field("uid", mox.Conf.Static.UID), mlog.Field("gid", mox.Conf.Static.GID), mlog.Field("pid", os.Getpid())) - mox.RestorePassedSockets() + mox.RestorePassedFiles() + mox.MustLoadConfig(checkACMEHosts) } syscall.Umask(syscall.Umask(007) | 007) @@ -584,7 +589,7 @@ func start(mtastsdbRefresher, skipForkExec bool) error { mox.ForkExecUnprivileged() panic("cannot happen") } else { - mox.CleanupPassedSockets() + mox.CleanupPassedFiles() } }