Graham King

Solvitas perambulum

systemd socket activation in Go

Summary
To start a server on ports below 1024, you need root permissions, usually achieved by binding the socket as root and then dropping privileges, but this can introduce security risks. A more efficient method is to use systemd socket activation. You can run a Go program that listens on port 8080 if started manually or on port 80 via systemd. Set up systemd by creating a service file for the program and a socket file to listen on port 80, ensuring to handle TCP_NODELAY and O_NONBLOCK settings. After reloading systemd configuration, your service will operate securely. For TLS and HTTP/2 support, modify the socket file to listen on port 443 and wrap the listener with `tls.NewListener`, configuring it with your certificates.

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.