systemd socket activation in Go
Summary
To start a server on a port below 1024 (i.e. 80, 443), you need root permissions or capability CAP_NET_BIND_SERVICE
, but you also want most of your server to run unprivileged, reducing your attack surface. The traditional way to achieve this was to start as root, bind the socket, then drop privileges. It’s a hassle, and if you get it wrong it’s a big security hole, so often we just run on an unprivileged port and use something like nginx
to proxy. But no longer. There’s a much better way: systemd socket activation.
Here is a Go program that will listen on port 8080 if started manually, or port 80 if run via systemd.
package main
import (
"log"
"net"
"net/http"
"os"
"strconv"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello World!"))
})
if os.Getenv("LISTEN_PID") == strconv.Itoa(os.Getpid()) {
// systemd run
f := os.NewFile(3, "from systemd")
l, err := net.FileListener(f)
if err != nil {
log.Fatal(err)
}
http.Serve(l, nil)
} else {
// manual run
log.Fatal(http.ListenAndServe(":8080", nil))
}
}
Build it (go build hello.go
) and put the hello
binary on your path (~/bin/
in my case).
Create /etc/systemd/system/hello.service
:
[Unit]
Requires=hello.socket
[Service]
ExecStart=/home/graham/bin/hello
NonBlocking=true
Create /etc/systemd/system/hello.socket
:
[Unit]
Description=Hello socket
[Socket]
ListenStream=80
NoDelay=true
We use NoDelay
(TCP_NODELAY) and NonBlocking
(O_NONBLOCK) because Go sets these by default on sockets it opens, and we want similar behavior.
Reload the config in systemd (sudo systemctl daemon-reload
), and you’re done. If you start it manually it will listen on 8080, if you start it via sudo systemctl start hello
it will listen on port 80.
To stop the service you also need to stop the socket. If you don’t stop the socket, incoming connections will auto-start the service (inetd style). systemd will warn you about this.
sudo systemctl stop hello.socket hello
How it works
When systemd starts a process that uses socket-based activation it sets the following environment variables. To check whether we are being started via socket activation we just need to check if one of those environment variables is set.
LISTEN_PID
: The process id of the process who gets the sockets. This prevents a child forked from your main process from thinking it is being given some sockets.LISTEN_FDS
: The number of file descriptors (sockets) your process is being given. In our case this will be 1.
Every process gets three standard file descriptors: STDIN=0, STDOUT=1, and STDERR=2. Descriptors given to us by systemd hence start at number 3.
Here are all the .socket file options. You should also set protections in your .service
file, see The joy of systemd.
TLS and HTTP2
Edit /etc/systemd/system/hello.socket
to use port 443:
ListenStream=443
Wrap the listener with tls.NewListener
, and set tls.Config.NextProtos
to be []string{"h2", "http/1.1"}
to maintain http2 support. Here’s the full // systemd run
section:
config := &tls.Config{
Certificates: make([]tls.Certificate, 1),
NextProtos: []string{"h2", "http/1.1"},
PreferServerCipherSuites: true,
}
var err error
config.Certificates[0], err = tls.LoadX509KeyPair(
"my_cert.pem",
"my_cert.key",
)
if err != nil {
log.Fatal(err)
}
f := os.NewFile(3, "from systemd")
l, err := net.FileListener(f)
if err != nil {
log.Fatal(err)
}
tlsListener := tls.NewListener(l, config)
http.Serve(tlsListener, nil)
That’s it!
UPDATE 2018: Vincent Bernat has an excellent blog-post on zero-downtime upgrades in Go with systemd.