hardened systemd service

like containers, but less convenient

SEAN K.H. LIAO

hardened systemd service

like containers, but less convenient

systemd

Why do you need containers? "just run it as a systemd service with super-long-list of options to lock it down", they said.

Anyway, I run Arch Linux, the unofficial testing ground of systemd, so we have all the latest bugs as well. As always, RTFM: system.unit systemd.service systemd.exec systemd.resource-control

app

So, what are we containing? A very generic web app exercising the network, filesystem, and secrets.

important: build as a static binary (CGO_ENABLED=0)

main.go:

 1package main
 2
 3import (
 4        "flag"
 5        "io"
 6        "log"
 7        "net/http"
 8        "os"
 9        "path/filepath"
10)
11
12func main() {
13        var configPath, dataPath, certFile, keyFile string
14        flag.StringVar(&configPath, "config", "", "path to config file")
15        flag.StringVar(&dataPath, "data", "", "path to data dir")
16        flag.StringVar(&certFile, "cert", "", "path to cert file")
17        flag.StringVar(&keyFile, "key", "", "path to key file")
18        flag.Parse()
19
20        log.Printf("starting with config=%q data=%q", configPath, dataPath)
21        b, err := os.ReadFile(configPath)
22        if err != nil {
23                log.Fatal("read config", err)
24        }
25        log.Println("config:\n", string(b))
26
27        statefile := filepath.Join(dataPath, "statefile")
28
29        http.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
30                if r.Method == http.MethodGet {
31                        b, err := os.ReadFile(statefile)
32                        if err != nil {
33                                rw.WriteHeader(http.StatusInternalServerError)
34                                rw.Write([]byte(err.Error()))
35                                return
36                        }
37                        rw.Write(b)
38                } else if r.Method == http.MethodPost {
39                        defer r.Body.Close()
40                        b, err := io.ReadAll(r.Body)
41                        if err != nil {
42                                rw.WriteHeader(http.StatusInternalServerError)
43                                rw.Write([]byte(err.Error()))
44                        }
45                        err = os.WriteFile(statefile, b, 0o644)
46                        if err != nil {
47                                rw.WriteHeader(http.StatusInternalServerError)
48                                rw.Write([]byte(err.Error()))
49                        }
50                        rw.Write([]byte("ok"))
51                }
52        })
53
54        log.Println(http.ListenAndServeTLS(":8080", certFile, keyFile, nil))
55}

service

So what do you ship to your target system? A binary and a systemd service file of course. No need for extra user, directory, etc.. setup, but you'll want to provide your config files, and secrets.

foobar.service:

 1[Unit]
 2Description=A foo bar app
 3Documentation=https://seankhliao.com/blog/12021-04-07-hardened-systemd-service/
 4Requires=network-online.target
 5After=network-online.target
 6
 7[Service]
 8Type=simple
 9# path within the filesystem namespace
10ExecStart=/bin/foobar -config /etc/foobar/conf.yaml -data /var/lib/foobar -cert ${CREDENTIALS_DIRECTORY}/cert -key ${CREDENTIALS_DIRECTORY}/key
11RestartSec=60s
12Restart=always
13
14WorkingDirectory=/
15# path on host system to be root of filesystem namespace,
16# created by RuntimeDirectory=
17# There's nothing in this namespace, including no libc,
18# which is why statically linked binaries are important
19# otherwise the error is a very opaque 203/EXEC File not found
20RootDirectory=/run/foobar
21ProtectProc=noaccess
22ProcSubset=pid
23# mount our executable from host into namespace
24BindReadOnlyPaths=/usr/local/bin/foobar:/bin/foobar
25
26DynamicUser=true
27
28CapabilityBoundingSet=
29AmbientCapabilities=
30
31NoNewPrivileges=true
32
33UMask=0022
34
35ProtectSystem=strict
36ProtectHome=true
37RuntimeDirectory=foobar
38StateDirectory=foobar
39# CacheDirectory=
40# LogDirectory=
41ConfigurationDirectory=foobar
42PrivateTmp=true
43PrivateDevices=true
44PrivateIPC=true
45PrivateUsers=true
46ProtectHostname=true
47ProtectClock=true
48ProtectKernelTunables=true
49ProtectKernelModules=true
50ProtectKernelLogs=true
51ProtectControlGroups=true
52RestrictAddressFamilies=AF_INET AF_INET6
53RestrictNamespaces=true
54LockPersonality=true
55MemoryDenyWriteExecute=true
56RestrictRealtime=true
57RestrictSUIDSGID=true
58RemoveIPC=true
59PrivateMounts=true
60
61# I think this is everything most Go processes would need
62# maybe you can trim it down more?
63# SystemCallFilter=@basic-io @file-system @io-event @network-io @sync
64SystemCallErrorNumber=EPERM
65SystemCallArchitectures=native
66
67# unset all env
68Environment=
69
70# Fancy credential passing
71LoadCredential=cert:/etc/certs/example.com.pem
72LoadCredential=key:/etc/certs/example.com-key.pem
73
74IPAddressAllow=any
75IPAddressDeny=
76DevicePolicy=closed
77DeviceAllow=
78
79
80[Install]
81WantedBy=mult-user.target
update

So if you make outgoing network calls, you'll want DNS and ca-certs. Also might want tzdata? but Go can embed that these days.

1BindReadOnlyPaths=/usr/bin/feed-agg:/bin/feed-agg \
2    /etc/feed-agg:/etc/feed-agg:rbind \
3    /etc/resolv.conf:/etc/resolv.conf \
4    /etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt