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