@marcel91200 / caddy Mirror · read-only / commits / fc2236fe
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 01:00 UTC

core: resolve systemd socket descriptors by name (#8123)

* core: resolve systemd socket descriptors by name * core: reject overflowing systemd descriptor indices * internal: isolate systemd resolution * chore: move integration * core: simplify systemd descriptor lookup

Zen Dodd committed Nov 3, 2026 at 18:16 UTC fc2236fe43da415e98a51109aca894b2fec38310
5 files changed +467
caddyconfig/httpcaddyfile/addresses_test.go
+15
@@ -1,9 +1,24 @@
1 package httpcaddyfile
2
3 import (
4 + "strings"
5 "testing"
6 )
7
8 +func TestSystemdListenPlaceholderSurvivesAdaptation(t *testing.T) {
9 + adapted := adaptCaddyfile(t, `{
10 + auto_https off
11 + }
12 +
13 + http://localhost {
14 + bind fd/{systemd.listen.web}
15 + respond ok
16 + }`)
17 + if !strings.Contains(adapted, `"listen":["fd/{systemd.listen.web}"]`) {
18 + t.Fatalf("adapted listener lost systemd placeholder: %s", adapted)
19 + }
20 +}
21 +
22 func TestParseAddress(t *testing.T) {
23 for i, test := range []struct {
24 input string
caddytest/integration/systemd_socket_activation_linux_test.go new
+236
@@ -0,0 +1,236 @@
1 +// Copyright 2015 Matthew Holt and The Caddy Authors
2 +//
3 +// Licensed under the Apache License, Version 2.0 (the "License");
4 +// you may not use this file except in compliance with the License.
5 +// You may obtain a copy of the License at
6 +//
7 +// http://www.apache.org/licenses/LICENSE-2.0
8 +//
9 +// Unless required by applicable law or agreed to in writing, software
10 +// distributed under the License is distributed on an "AS IS" BASIS,
11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +// See the License for the specific language governing permissions and
13 +// limitations under the License.
14 +
15 +//go:build linux
16 +
17 +package integration
18 +
19 +import (
20 + "context"
21 + "crypto/tls"
22 + "fmt"
23 + "io"
24 + "net"
25 + "os"
26 + "os/exec"
27 + "strconv"
28 + "strings"
29 + "testing"
30 + "time"
31 +
32 + "github.com/caddyserver/caddy/v2"
33 +)
34 +
35 +const systemdListenHelper = "CADDY_SYSTEMD_LISTEN_HELPER"
36 +
37 +func TestSystemdListenFDIntegration(t *testing.T) {
38 + if os.Getenv(systemdListenHelper) == "1" {
39 + runSystemdListenFDHelper(t)
40 + return
41 + }
42 +
43 + tcpListener, err := net.Listen("tcp4", "127.0.0.1:0")
44 + if err != nil {
45 + t.Fatal(err)
46 + }
47 + tcpFile, err := tcpListener.(*net.TCPListener).File()
48 + if err != nil {
49 + t.Fatal(err)
50 + }
51 + _ = tcpListener.Close()
52 + defer tcpFile.Close()
53 +
54 + udpConn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
55 + if err != nil {
56 + t.Fatal(err)
57 + }
58 + udpFile, err := udpConn.File()
59 + if err != nil {
60 + t.Fatal(err)
61 + }
62 + _ = udpConn.Close()
63 + defer udpFile.Close()
64 +
65 + h3Conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
66 + if err != nil {
67 + t.Fatal(err)
68 + }
69 + h3File, err := h3Conn.File()
70 + if err != nil {
71 + t.Fatal(err)
72 + }
73 + _ = h3Conn.Close()
74 + defer h3File.Close()
75 +
76 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
77 + defer cancel()
78 + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestSystemdListenFDIntegration$", "-test.v")
79 + cmd.ExtraFiles = []*os.File{tcpFile, udpFile, h3File}
80 + cmd.Env = append(withoutSystemdListenEnv(os.Environ()),
81 + systemdListenHelper+"=1",
82 + "LISTEN_FDS=3",
83 + "LISTEN_FDNAMES=web:dns:h3",
84 + )
85 + output, err := cmd.CombinedOutput()
86 + if err != nil {
87 + t.Fatalf("helper failed: %v\n%s", err, output)
88 + }
89 +}
90 +
91 +func runSystemdListenFDHelper(t *testing.T) {
92 + t.Setenv("LISTEN_PID", strconv.Itoa(os.Getpid()))
93 + repl := caddy.NewReplacer()
94 +
95 + stream := resolveSystemdListenAddress(t, repl, "fd/{systemd.listen.web}", "fd/3")
96 + lnAny, err := stream.Listen(context.Background(), 0, net.ListenConfig{})
97 + if err != nil {
98 + t.Fatal(err)
99 + }
100 + ln, ok := lnAny.(net.Listener)
101 + if !ok {
102 + t.Fatalf("stream listener has type %T", lnAny)
103 + }
104 + defer ln.Close()
105 + checkInheritedStream(t, ln)
106 + if _, err := repl.ReplaceOrErr("fd/{systemd.listen.missing}", true, true); err == nil {
107 + t.Fatal("unknown descriptor name did not fail replacement")
108 + }
109 +
110 + // The inherited descriptor mapping belongs to the process. Keep using the
111 + // first valid snapshot even if later code changes the environment.
112 + t.Setenv("LISTEN_FDNAMES", "changed")
113 +
114 + datagram := resolveSystemdListenAddress(t, repl, "fdgram/{systemd.listen.dns}", "fdgram/4")
115 + pcAny, err := datagram.Listen(context.Background(), 0, net.ListenConfig{})
116 + if err != nil {
117 + t.Fatal(err)
118 + }
119 + pc, ok := pcAny.(net.PacketConn)
120 + if !ok {
121 + t.Fatalf("datagram listener has type %T", pcAny)
122 + }
123 + defer pc.Close()
124 + checkInheritedDatagram(t, pc)
125 +
126 + h3 := resolveSystemdListenAddress(t, repl, "fdgram/{systemd.listen.h3}", "fdgram/5")
127 + var tlsConfig *tls.Config
128 + tlsConfig = &tls.Config{
129 + GetConfigForClient: func(*tls.ClientHelloInfo) (*tls.Config, error) {
130 + return tlsConfig, nil
131 + },
132 + }
133 + quicListener, err := h3.ListenQUIC(context.Background(), 0, net.ListenConfig{}, caddy.ListenQUICOptions{
134 + TLSConfig: tlsConfig,
135 + })
136 + if err != nil {
137 + t.Fatal(err)
138 + }
139 + if err := quicListener.Close(); err != nil {
140 + t.Fatal(err)
141 + }
142 + if err := quicListener.Close(); err != nil {
143 + t.Fatal(err)
144 + }
145 +}
146 +
147 +func resolveSystemdListenAddress(t *testing.T, repl *caddy.Replacer, input, want string) caddy.NetworkAddress {
148 + t.Helper()
149 + resolved, err := repl.ReplaceOrErr(input, true, true)
150 + if err != nil {
151 + t.Fatal(err)
152 + }
153 + if resolved != want {
154 + t.Fatalf("resolved address = %q; want %q", resolved, want)
155 + }
156 + address, err := caddy.ParseNetworkAddress(resolved)
157 + if err != nil {
158 + t.Fatal(err)
159 + }
160 + return address
161 +}
162 +
163 +func checkInheritedStream(t *testing.T, ln net.Listener) {
164 + t.Helper()
165 + done := make(chan error, 1)
166 + go func() {
167 + conn, err := ln.Accept()
168 + if err != nil {
169 + done <- err
170 + return
171 + }
172 + defer conn.Close()
173 + buf := make([]byte, 4)
174 + _, err = io.ReadFull(conn, buf)
175 + if err == nil && string(buf) != "ping" {
176 + err = fmt.Errorf("received %q; want ping", buf)
177 + }
178 + done <- err
179 + }()
180 +
181 + conn, err := net.DialTimeout("tcp", ln.Addr().String(), time.Second)
182 + if err != nil {
183 + t.Fatal(err)
184 + }
185 + _, err = conn.Write([]byte("ping"))
186 + _ = conn.Close()
187 + if err != nil {
188 + t.Fatal(err)
189 + }
190 + select {
191 + case err := <-done:
192 + if err != nil {
193 + t.Fatal(err)
194 + }
195 + case <-time.After(time.Second):
196 + t.Fatal("timed out waiting for inherited listener")
197 + }
198 +}
199 +
200 +func checkInheritedDatagram(t *testing.T, pc net.PacketConn) {
201 + t.Helper()
202 + conn, err := net.Dial("udp", pc.LocalAddr().String())
203 + if err != nil {
204 + t.Fatal(err)
205 + }
206 + defer conn.Close()
207 + if _, err := conn.Write([]byte("ping")); err != nil {
208 + t.Fatal(err)
209 + }
210 + if err := pc.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
211 + t.Fatal(err)
212 + }
213 + buf := make([]byte, 4)
214 + n, _, err := pc.ReadFrom(buf)
215 + if err != nil {
216 + t.Fatal(err)
217 + }
218 + if string(buf[:n]) != "ping" {
219 + t.Fatalf("received %q; want ping", buf[:n])
220 + }
221 +}
222 +
223 +func withoutSystemdListenEnv(env []string) []string {
224 + filtered := make([]string, 0, len(env))
225 + for _, entry := range env {
226 + if strings.HasPrefix(entry, "LISTEN_PID=") ||
227 + strings.HasPrefix(entry, "LISTEN_PIDFDID=") ||
228 + strings.HasPrefix(entry, "LISTEN_FDS=") ||
229 + strings.HasPrefix(entry, "LISTEN_FDNAMES=") ||
230 + strings.HasPrefix(entry, systemdListenHelper+"=") {
231 + continue
232 + }
233 + filtered = append(filtered, entry)
234 + }
235 + return filtered
236 +}
internal/systemd/listen.go new
+94
@@ -0,0 +1,94 @@
1 +// Copyright 2015 Matthew Holt and The Caddy Authors
2 +//
3 +// Licensed under the Apache License, Version 2.0 (the "License");
4 +// you may not use this file except in compliance with the License.
5 +// You may obtain a copy of the License at
6 +//
7 +// http://www.apache.org/licenses/LICENSE-2.0
8 +//
9 +// Unless required by applicable law or agreed to in writing, software
10 +// distributed under the License is distributed on an "AS IS" BASIS,
11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +// See the License for the specific language governing permissions and
13 +// limitations under the License.
14 +
15 +package systemd
16 +
17 +import (
18 + "errors"
19 + "fmt"
20 + "math"
21 + "os"
22 + "strconv"
23 + "strings"
24 + "sync"
25 +)
26 +
27 +const systemdListenFDStart = 3 // stdin, stdout and stderr occupy descriptors 0 to 2
28 +
29 +var cachedSystemdListenFDs = sync.OnceValues(func() (map[string]int, error) {
30 + return parseSystemdListenFDs(os.Getpid(), os.LookupEnv)
31 +})
32 +
33 +// ListenFD returns the first inherited descriptor with name.
34 +func ListenFD(name string) (int, error) {
35 + descriptors, err := cachedSystemdListenFDs()
36 + if err != nil {
37 + return 0, err
38 + }
39 + return systemdListenFDByName(descriptors, name)
40 +}
41 +
42 +func parseSystemdListenFDs(pid int, lookupEnv func(string) (string, bool)) (map[string]int, error) {
43 + listenPID, ok := lookupEnv("LISTEN_PID")
44 + if !ok {
45 + return nil, errors.New("systemd socket activation: LISTEN_PID is unset")
46 + }
47 + parsedPID, err := strconv.Atoi(listenPID)
48 + if err != nil {
49 + return nil, fmt.Errorf("systemd socket activation: parsing LISTEN_PID: %w", err)
50 + }
51 + if parsedPID != pid {
52 + return nil, fmt.Errorf("systemd socket activation: LISTEN_PID does not match process: %d != %d", parsedPID, pid)
53 + }
54 +
55 + listenFDs, ok := lookupEnv("LISTEN_FDS")
56 + if !ok {
57 + return nil, errors.New("systemd socket activation: LISTEN_FDS is unset")
58 + }
59 + fdCount, err := strconv.Atoi(listenFDs)
60 + if err != nil {
61 + return nil, fmt.Errorf("systemd socket activation: parsing LISTEN_FDS: %w", err)
62 + }
63 + if fdCount <= 0 || fdCount > math.MaxInt-systemdListenFDStart {
64 + return nil, fmt.Errorf("systemd socket activation: invalid LISTEN_FDS count: %d", fdCount)
65 + }
66 +
67 + listenFDNames, ok := lookupEnv("LISTEN_FDNAMES")
68 + if !ok {
69 + return nil, errors.New("systemd socket activation: LISTEN_FDNAMES is unset")
70 + }
71 + names := strings.Split(listenFDNames, ":")
72 + if len(names) != fdCount {
73 + return nil, fmt.Errorf("systemd socket activation: LISTEN_FDS does not match LISTEN_FDNAMES count: %d != %d", fdCount, len(names))
74 + }
75 +
76 + descriptors := make(map[string]int, len(names))
77 + for index, name := range names {
78 + if _, exists := descriptors[name]; !exists {
79 + descriptors[name] = systemdListenFDStart + index
80 + }
81 + }
82 + return descriptors, nil
83 +}
84 +
85 +func systemdListenFDByName(descriptors map[string]int, name string) (int, error) {
86 + if name == "" {
87 + return 0, errors.New("systemd listen descriptor name is empty")
88 + }
89 + descriptor, ok := descriptors[name]
90 + if !ok {
91 + return 0, fmt.Errorf("systemd listen descriptor name not found: %q", name)
92 + }
93 + return descriptor, nil
94 +}
internal/systemd/listen_test.go new
+111
@@ -0,0 +1,111 @@
1 +// Copyright 2015 Matthew Holt and The Caddy Authors
2 +//
3 +// Licensed under the Apache License, Version 2.0 (the "License");
4 +// you may not use this file except in compliance with the License.
5 +// You may obtain a copy of the License at
6 +//
7 +// http://www.apache.org/licenses/LICENSE-2.0
8 +//
9 +// Unless required by applicable law or agreed to in writing, software
10 +// distributed under the License is distributed on an "AS IS" BASIS,
11 +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +// See the License for the specific language governing permissions and
13 +// limitations under the License.
14 +
15 +package systemd
16 +
17 +import (
18 + "maps"
19 + "testing"
20 +)
21 +
22 +func TestParseSystemdListenFDs(t *testing.T) {
23 + for _, tc := range []struct {
24 + name string
25 + pid int
26 + env map[string]string
27 + want map[string]int
28 + wantErr bool
29 + }{
30 + {
31 + name: "named descriptors including duplicates",
32 + pid: 42,
33 + env: map[string]string{
34 + "LISTEN_PID": "42",
35 + "LISTEN_FDS": "5",
36 + "LISTEN_FDNAMES": "web:dns:web:admin:web.api",
37 + },
38 + want: map[string]int{
39 + "web": 3,
40 + "dns": 4,
41 + "admin": 6,
42 + "web.api": 7,
43 + },
44 + },
45 + {name: "missing pid", pid: 42, env: map[string]string{}, wantErr: true},
46 + {name: "malformed pid", pid: 42, env: map[string]string{"LISTEN_PID": "x"}, wantErr: true},
47 + {name: "different pid", pid: 42, env: map[string]string{"LISTEN_PID": "41"}, wantErr: true},
48 + {name: "missing descriptor count", pid: 42, env: map[string]string{"LISTEN_PID": "42"}, wantErr: true},
49 + {name: "zero descriptors", pid: 42, env: map[string]string{"LISTEN_PID": "42", "LISTEN_FDS": "0"}, wantErr: true},
50 + {name: "negative descriptor count", pid: 42, env: map[string]string{"LISTEN_PID": "42", "LISTEN_FDS": "-1"}, wantErr: true},
51 + {name: "missing names", pid: 42, env: map[string]string{"LISTEN_PID": "42", "LISTEN_FDS": "1"}, wantErr: true},
52 + {name: "too few names", pid: 42, env: map[string]string{"LISTEN_PID": "42", "LISTEN_FDS": "2", "LISTEN_FDNAMES": "web"}, wantErr: true},
53 + {name: "too many names", pid: 42, env: map[string]string{"LISTEN_PID": "42", "LISTEN_FDS": "1", "LISTEN_FDNAMES": "web:dns"}, wantErr: true},
54 + } {
55 + t.Run(tc.name, func(t *testing.T) {
56 + got, err := parseSystemdListenFDs(tc.pid, func(name string) (string, bool) {
57 + value, ok := tc.env[name]
58 + return value, ok
59 + })
60 + if tc.wantErr {
61 + if err == nil {
62 + t.Fatal("expected an error")
63 + }
64 + return
65 + }
66 + if err != nil {
67 + t.Fatal(err)
68 + }
69 + if !maps.Equal(got, tc.want) {
70 + t.Fatalf("descriptor mapping = %#v; want %#v", got, tc.want)
71 + }
72 + })
73 + }
74 +}
75 +
76 +func TestSystemdListenFDByName(t *testing.T) {
77 + descriptors := map[string]int{
78 + "web": 3,
79 + "dns": 4,
80 + "web.api": 5,
81 + }
82 +
83 + for _, tc := range []struct {
84 + input string
85 + want int
86 + wantErr bool
87 + }{
88 + {input: "web", want: 3},
89 + {input: "dns", want: 4},
90 + {input: "web.api", want: 5},
91 + {input: "", wantErr: true},
92 + {input: "missing", wantErr: true},
93 + {input: "web:1", wantErr: true},
94 + } {
95 + t.Run(tc.input, func(t *testing.T) {
96 + got, err := systemdListenFDByName(descriptors, tc.input)
97 + if tc.wantErr {
98 + if err == nil {
99 + t.Fatal("expected an error")
100 + }
101 + return
102 + }
103 + if err != nil {
104 + t.Fatal(err)
105 + }
106 + if got != tc.want {
107 + t.Fatalf("descriptor = %d; want %d", got, tc.want)
108 + }
109 + })
110 + }
111 +}
replacer.go
+11
@@ -29,6 +29,8 @@ import (
29 "time"
30
31 "go.uber.org/zap"
32 +
33 + "github.com/caddyserver/caddy/v2/internal/systemd"
34 )
35
36 // NewReplacer returns a new Replacer.
@@ -460,6 +462,13 @@ func (f globalDefaultReplacementProvider) replace(key string) (any, bool) {
462 if strings.HasPrefix(key, envPrefix) {
463 return os.Getenv(key[len(envPrefix):]), true
464 }
465 + if name, ok := strings.CutPrefix(key, systemdListenPrefix); ok {
466 + descriptor, err := systemd.ListenFD(name)
467 + if err != nil {
468 + return nil, false
469 + }
470 + return descriptor, true
471 + }
472
473 switch key {
474 case "system.hostname":
@@ -531,5 +540,7 @@ const phOpen, phClose, phEscape = '{', '}', '\\'
540
541 const filePrefix = "file."
542
543 +const systemdListenPrefix = "systemd.listen."
544 +
545 // maxFileCacheBytes bounds the memoized file contents to 8 MiB (8,388,608 bytes) per replace call.
546 const maxFileCacheBytes = 8 << 20