April 9, 2016

systemd socket activation in Go

Posted in Software at 20:01 by graham

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!

Leave a Comment

Note: Your comment will only appear on the site once I approve it manually. This can take a day or two. Thanks for taking the time to comment.