open tls keys/certificate as root, pass fd's to the unprivileged child process

makes it easier to use tls keys/certs managed by other tools, with or without
acme. the root process has access to open such files. the child process reads
the key from the file descriptor, then closes the file.

for issue #30 by inigoserna, thanks!
This commit is contained in:
Mechiel Lukkien 2023-05-31 14:09:53 +02:00
parent dd0cede4f9
commit 70d07c5459
No known key found for this signature in database
6 changed files with 119 additions and 36 deletions

View file

@ -313,7 +313,7 @@ type KeyCert struct {
type TLS struct { type TLS struct {
ACME string `sconf:"optional" sconf-doc:"Name of provider from top-level configuration to use for ACME, e.g. letsencrypt."` 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."` 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. Config *tls.Config `sconf:"-" json:"-"` // TLS config for non-ACME-verification connections, i.e. SMTP and IMAP, and not port 443.

View file

@ -125,6 +125,8 @@ describe-static" and "mox config describe-domains":
# (optional) # (optional)
ACME: 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) # (optional)
KeyCerts: KeyCerts:
- -

View file

@ -115,7 +115,7 @@ during those commands instead of during "data".
// Tell queue it shouldn't be queuing/delivering. // Tell queue it shouldn't be queuing/delivering.
queue.Localserve = true queue.Localserve = true
mox.ListenImmediate = true mox.FilesImmediate = true
const mtastsdbRefresher = false const mtastsdbRefresher = false
const skipForkExec = true const skipForkExec = true
if err := start(mtastsdbRefresher, skipForkExec); err != nil { if err := start(mtastsdbRefresher, skipForkExec); err != nil {

View file

@ -10,6 +10,7 @@ import (
"encoding/pem" "encoding/pem"
"errors" "errors"
"fmt" "fmt"
"io"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
@ -1188,7 +1189,7 @@ func loadTLSKeyCerts(configFile, kind string, ctls *config.TLS) error {
for _, kp := range ctls.KeyCerts { for _, kp := range ctls.KeyCerts {
certPath := configDirPath(configFile, kp.CertFile) certPath := configDirPath(configFile, kp.CertFile)
keyPath := configDirPath(configFile, kp.KeyFile) keyPath := configDirPath(configFile, kp.KeyFile)
cert, err := tls.LoadX509KeyPair(certPath, keyPath) cert, err := loadX509KeyPairPrivileged(certPath, keyPath)
if err != nil { if err != nil {
return fmt.Errorf("tls config for %q: parsing x509 key pair: %v", kind, err) 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 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)
}

View file

@ -19,14 +19,17 @@ import (
"github.com/mjl-/mox/mlog" "github.com/mjl-/mox/mlog"
) )
// We start up as root, bind to sockets, and fork and exec as unprivileged user. // We start up as root, bind to sockets, open private key/cert files and fork and
// During startup as root, we gather the fd's for the listen addresses in listens, // exec as unprivileged user. During startup as root, we gather the fd's for the
// and pass their addresses in an environment variable to the new process. // listen addresses in passedListeners and files in passedFiles, and pass their
var listens = map[string]*os.File{} // 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 // RestorePassedFiles reads addresses from $MOX_SOCKETS and paths from $MOX_FILES
// for each file descriptor, which are used by later calls of Listen. // and prepares an os.File for each file descriptor, which are used by later calls
func RestorePassedSockets() { // of Listen or opening files.
func RestorePassedFiles() {
s := os.Getenv("MOX_SOCKETS") s := os.Getenv("MOX_SOCKETS")
if s == "" { if s == "" {
var linuxhint string 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) 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 first passed fd (first listeners, then files).
// 0,1,2 are stdin,stdout,stderr, 3 is the network/address fd. var o uintptr = 3
f := os.NewFile(3+uintptr(i), addr) for _, addr := range strings.Split(s, ",") {
listens[addr] = f 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} files := []*os.File{os.Stdin, os.Stdout, os.Stderr}
var addrs []string var addrs []string
for addr, f := range listens { for addr, f := range passedListeners {
files = append(files, f) files = append(files, f)
addrs = append(addrs, addr) 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 := 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{ p, err := os.StartProcess(prog, os.Args, &os.ProcAttr{
Env: env, Env: env,
@ -76,10 +96,7 @@ func ForkExecUnprivileged() {
if err != nil { if err != nil {
xlog.Fatalx("fork and exec", err) xlog.Fatalx("fork and exec", err)
} }
for _, f := range listens { CleanupPassedFiles()
err := f.Close()
xlog.Check(err, "closing socket after passing to unprivileged child")
}
// If we get a interrupt/terminate signal, pass it on to the child. For interrupt, // If we get a interrupt/terminate signal, pass it on to the child. For interrupt,
// the child probably already got it. // the child probably already got it.
@ -101,26 +118,34 @@ func ForkExecUnprivileged() {
os.Exit(code) os.Exit(code)
} }
// CleanupPassedSockets closes the listening socket file descriptors passed in by // CleanupPassedFiles closes the listening socket file descriptors and files passed
// the parent process. To be called after listeners have been recreated (they dup // in by the parent process. To be called by the unprivileged child after listeners
// the file descriptor). // have been recreated (they dup the file descriptor), and by the privileged
func CleanupPassedSockets() { // process after starting its child.
for _, f := range listens { func CleanupPassedFiles() {
for _, f := range passedListeners {
err := f.Close() err := f.Close()
xlog.Check(err, "closing listener socket file descriptor") 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 // For privileged file descriptor operations (listen and opening privileged files),
// case ForkExecUnprivileged is not used. // perform them immediately, regardless of running as root or other user, in case
var ListenImmediate bool // ForkExecUnprivileged is not used.
var FilesImmediate bool
// Listen returns a newly created network listener when starting as root, and // 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 // otherwise (not root) returns a network listener from a file descriptor that was
// passed by the parent root process. // passed by the parent root process.
func Listen(network, addr string) (net.Listener, error) { func Listen(network, addr string) (net.Listener, error) {
if os.Getuid() != 0 && !ListenImmediate { if os.Getuid() != 0 && !FilesImmediate {
f, ok := listens[addr] f, ok := passedListeners[addr]
if !ok { if !ok {
return nil, fmt.Errorf("no file descriptor for listener %s", addr) 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 return ln, nil
} }
if _, ok := listens[addr]; ok { if _, ok := passedListeners[addr]; ok {
return nil, fmt.Errorf("duplicate listener: %s", addr) return nil, fmt.Errorf("duplicate listener: %s", addr)
} }
@ -147,10 +172,36 @@ func Listen(network, addr string) (net.Listener, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("dup listener: %v", err) return nil, fmt.Errorf("dup listener: %v", err)
} }
listens[addr] = f passedListeners[addr] = f
return ln, err 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 // 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 // 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 // canaceled, the operation should not be started, and new connections/commands should

View file

@ -147,11 +147,12 @@ requested, other TLS certificates are requested on demand.
mlog.SetConfig(mox.Conf.Log) mlog.SetConfig(mox.Conf.Log)
checkACMEHosts := os.Getuid() != 0 checkACMEHosts := os.Getuid() != 0
mox.MustLoadConfig(checkACMEHosts)
log := mlog.New("serve") log := mlog.New("serve")
if os.Getuid() == 0 { if os.Getuid() == 0 {
mox.MustLoadConfig(checkACMEHosts)
// No need to potentially start and keep multiple processes. As root, we just need // No need to potentially start and keep multiple processes. As root, we just need
// to start the child process. // to start the child process.
runtime.GOMAXPROCS(1) runtime.GOMAXPROCS(1)
@ -160,6 +161,9 @@ requested, other TLS certificates are requested on demand.
if os.Getenv("MOX_SOCKETS") != "" { if os.Getenv("MOX_SOCKETS") != "" {
log.Fatal("refusing to start as root with $MOX_SOCKETS set") 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 { if !mox.Conf.Static.NoFixPermissions {
// Fix permissions now that we have privilege to do so. Useful for update of v0.0.1 // 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 { } 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())) 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) syscall.Umask(syscall.Umask(007) | 007)
@ -584,7 +589,7 @@ func start(mtastsdbRefresher, skipForkExec bool) error {
mox.ForkExecUnprivileged() mox.ForkExecUnprivileged()
panic("cannot happen") panic("cannot happen")
} else { } else {
mox.CleanupPassedSockets() mox.CleanupPassedFiles()
} }
} }