diff --git a/caddyconfig/httpcaddyfile/addresses_test.go b/caddyconfig/httpcaddyfile/addresses_test.go index 232460d0f..1933b32e5 100644 --- a/caddyconfig/httpcaddyfile/addresses_test.go +++ b/caddyconfig/httpcaddyfile/addresses_test.go @@ -1,9 +1,24 @@ package httpcaddyfile import ( + "strings" "testing" ) +func TestSystemdListenPlaceholderSurvivesAdaptation(t *testing.T) { + adapted := adaptCaddyfile(t, `{ + auto_https off + } + + http://localhost { + bind fd/{systemd.listen.web} + respond ok + }`) + if !strings.Contains(adapted, `"listen":["fd/{systemd.listen.web}"]`) { + t.Fatalf("adapted listener lost systemd placeholder: %s", adapted) + } +} + func TestParseAddress(t *testing.T) { for i, test := range []struct { input string diff --git a/caddytest/integration/systemd_socket_activation_linux_test.go b/caddytest/integration/systemd_socket_activation_linux_test.go new file mode 100644 index 000000000..94711c2f4 --- /dev/null +++ b/caddytest/integration/systemd_socket_activation_linux_test.go @@ -0,0 +1,236 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package integration + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "net" + "os" + "os/exec" + "strconv" + "strings" + "testing" + "time" + + "github.com/caddyserver/caddy/v2" +) + +const systemdListenHelper = "CADDY_SYSTEMD_LISTEN_HELPER" + +func TestSystemdListenFDIntegration(t *testing.T) { + if os.Getenv(systemdListenHelper) == "1" { + runSystemdListenFDHelper(t) + return + } + + tcpListener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + tcpFile, err := tcpListener.(*net.TCPListener).File() + if err != nil { + t.Fatal(err) + } + _ = tcpListener.Close() + defer tcpFile.Close() + + udpConn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + udpFile, err := udpConn.File() + if err != nil { + t.Fatal(err) + } + _ = udpConn.Close() + defer udpFile.Close() + + h3Conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + h3File, err := h3Conn.File() + if err != nil { + t.Fatal(err) + } + _ = h3Conn.Close() + defer h3File.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestSystemdListenFDIntegration$", "-test.v") + cmd.ExtraFiles = []*os.File{tcpFile, udpFile, h3File} + cmd.Env = append(withoutSystemdListenEnv(os.Environ()), + systemdListenHelper+"=1", + "LISTEN_FDS=3", + "LISTEN_FDNAMES=web:dns:h3", + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("helper failed: %v\n%s", err, output) + } +} + +func runSystemdListenFDHelper(t *testing.T) { + t.Setenv("LISTEN_PID", strconv.Itoa(os.Getpid())) + repl := caddy.NewReplacer() + + stream := resolveSystemdListenAddress(t, repl, "fd/{systemd.listen.web}", "fd/3") + lnAny, err := stream.Listen(context.Background(), 0, net.ListenConfig{}) + if err != nil { + t.Fatal(err) + } + ln, ok := lnAny.(net.Listener) + if !ok { + t.Fatalf("stream listener has type %T", lnAny) + } + defer ln.Close() + checkInheritedStream(t, ln) + if _, err := repl.ReplaceOrErr("fd/{systemd.listen.missing}", true, true); err == nil { + t.Fatal("unknown descriptor name did not fail replacement") + } + + // The inherited descriptor mapping belongs to the process. Keep using the + // first valid snapshot even if later code changes the environment. + t.Setenv("LISTEN_FDNAMES", "changed") + + datagram := resolveSystemdListenAddress(t, repl, "fdgram/{systemd.listen.dns}", "fdgram/4") + pcAny, err := datagram.Listen(context.Background(), 0, net.ListenConfig{}) + if err != nil { + t.Fatal(err) + } + pc, ok := pcAny.(net.PacketConn) + if !ok { + t.Fatalf("datagram listener has type %T", pcAny) + } + defer pc.Close() + checkInheritedDatagram(t, pc) + + h3 := resolveSystemdListenAddress(t, repl, "fdgram/{systemd.listen.h3}", "fdgram/5") + var tlsConfig *tls.Config + tlsConfig = &tls.Config{ + GetConfigForClient: func(*tls.ClientHelloInfo) (*tls.Config, error) { + return tlsConfig, nil + }, + } + quicListener, err := h3.ListenQUIC(context.Background(), 0, net.ListenConfig{}, caddy.ListenQUICOptions{ + TLSConfig: tlsConfig, + }) + if err != nil { + t.Fatal(err) + } + if err := quicListener.Close(); err != nil { + t.Fatal(err) + } + if err := quicListener.Close(); err != nil { + t.Fatal(err) + } +} + +func resolveSystemdListenAddress(t *testing.T, repl *caddy.Replacer, input, want string) caddy.NetworkAddress { + t.Helper() + resolved, err := repl.ReplaceOrErr(input, true, true) + if err != nil { + t.Fatal(err) + } + if resolved != want { + t.Fatalf("resolved address = %q; want %q", resolved, want) + } + address, err := caddy.ParseNetworkAddress(resolved) + if err != nil { + t.Fatal(err) + } + return address +} + +func checkInheritedStream(t *testing.T, ln net.Listener) { + t.Helper() + done := make(chan error, 1) + go func() { + conn, err := ln.Accept() + if err != nil { + done <- err + return + } + defer conn.Close() + buf := make([]byte, 4) + _, err = io.ReadFull(conn, buf) + if err == nil && string(buf) != "ping" { + err = fmt.Errorf("received %q; want ping", buf) + } + done <- err + }() + + conn, err := net.DialTimeout("tcp", ln.Addr().String(), time.Second) + if err != nil { + t.Fatal(err) + } + _, err = conn.Write([]byte("ping")) + _ = conn.Close() + if err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for inherited listener") + } +} + +func checkInheritedDatagram(t *testing.T, pc net.PacketConn) { + t.Helper() + conn, err := net.Dial("udp", pc.LocalAddr().String()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + if _, err := conn.Write([]byte("ping")); err != nil { + t.Fatal(err) + } + if err := pc.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + buf := make([]byte, 4) + n, _, err := pc.ReadFrom(buf) + if err != nil { + t.Fatal(err) + } + if string(buf[:n]) != "ping" { + t.Fatalf("received %q; want ping", buf[:n]) + } +} + +func withoutSystemdListenEnv(env []string) []string { + filtered := make([]string, 0, len(env)) + for _, entry := range env { + if strings.HasPrefix(entry, "LISTEN_PID=") || + strings.HasPrefix(entry, "LISTEN_PIDFDID=") || + strings.HasPrefix(entry, "LISTEN_FDS=") || + strings.HasPrefix(entry, "LISTEN_FDNAMES=") || + strings.HasPrefix(entry, systemdListenHelper+"=") { + continue + } + filtered = append(filtered, entry) + } + return filtered +} diff --git a/internal/systemd/listen.go b/internal/systemd/listen.go new file mode 100644 index 000000000..ac4f26125 --- /dev/null +++ b/internal/systemd/listen.go @@ -0,0 +1,94 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package systemd + +import ( + "errors" + "fmt" + "math" + "os" + "strconv" + "strings" + "sync" +) + +const systemdListenFDStart = 3 // stdin, stdout and stderr occupy descriptors 0 to 2 + +var cachedSystemdListenFDs = sync.OnceValues(func() (map[string]int, error) { + return parseSystemdListenFDs(os.Getpid(), os.LookupEnv) +}) + +// ListenFD returns the first inherited descriptor with name. +func ListenFD(name string) (int, error) { + descriptors, err := cachedSystemdListenFDs() + if err != nil { + return 0, err + } + return systemdListenFDByName(descriptors, name) +} + +func parseSystemdListenFDs(pid int, lookupEnv func(string) (string, bool)) (map[string]int, error) { + listenPID, ok := lookupEnv("LISTEN_PID") + if !ok { + return nil, errors.New("systemd socket activation: LISTEN_PID is unset") + } + parsedPID, err := strconv.Atoi(listenPID) + if err != nil { + return nil, fmt.Errorf("systemd socket activation: parsing LISTEN_PID: %w", err) + } + if parsedPID != pid { + return nil, fmt.Errorf("systemd socket activation: LISTEN_PID does not match process: %d != %d", parsedPID, pid) + } + + listenFDs, ok := lookupEnv("LISTEN_FDS") + if !ok { + return nil, errors.New("systemd socket activation: LISTEN_FDS is unset") + } + fdCount, err := strconv.Atoi(listenFDs) + if err != nil { + return nil, fmt.Errorf("systemd socket activation: parsing LISTEN_FDS: %w", err) + } + if fdCount <= 0 || fdCount > math.MaxInt-systemdListenFDStart { + return nil, fmt.Errorf("systemd socket activation: invalid LISTEN_FDS count: %d", fdCount) + } + + listenFDNames, ok := lookupEnv("LISTEN_FDNAMES") + if !ok { + return nil, errors.New("systemd socket activation: LISTEN_FDNAMES is unset") + } + names := strings.Split(listenFDNames, ":") + if len(names) != fdCount { + return nil, fmt.Errorf("systemd socket activation: LISTEN_FDS does not match LISTEN_FDNAMES count: %d != %d", fdCount, len(names)) + } + + descriptors := make(map[string]int, len(names)) + for index, name := range names { + if _, exists := descriptors[name]; !exists { + descriptors[name] = systemdListenFDStart + index + } + } + return descriptors, nil +} + +func systemdListenFDByName(descriptors map[string]int, name string) (int, error) { + if name == "" { + return 0, errors.New("systemd listen descriptor name is empty") + } + descriptor, ok := descriptors[name] + if !ok { + return 0, fmt.Errorf("systemd listen descriptor name not found: %q", name) + } + return descriptor, nil +} diff --git a/internal/systemd/listen_test.go b/internal/systemd/listen_test.go new file mode 100644 index 000000000..b3e0e8bfe --- /dev/null +++ b/internal/systemd/listen_test.go @@ -0,0 +1,111 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package systemd + +import ( + "maps" + "testing" +) + +func TestParseSystemdListenFDs(t *testing.T) { + for _, tc := range []struct { + name string + pid int + env map[string]string + want map[string]int + wantErr bool + }{ + { + name: "named descriptors including duplicates", + pid: 42, + env: map[string]string{ + "LISTEN_PID": "42", + "LISTEN_FDS": "5", + "LISTEN_FDNAMES": "web:dns:web:admin:web.api", + }, + want: map[string]int{ + "web": 3, + "dns": 4, + "admin": 6, + "web.api": 7, + }, + }, + {name: "missing pid", pid: 42, env: map[string]string{}, wantErr: true}, + {name: "malformed pid", pid: 42, env: map[string]string{"LISTEN_PID": "x"}, wantErr: true}, + {name: "different pid", pid: 42, env: map[string]string{"LISTEN_PID": "41"}, wantErr: true}, + {name: "missing descriptor count", pid: 42, env: map[string]string{"LISTEN_PID": "42"}, wantErr: true}, + {name: "zero descriptors", pid: 42, env: map[string]string{"LISTEN_PID": "42", "LISTEN_FDS": "0"}, wantErr: true}, + {name: "negative descriptor count", pid: 42, env: map[string]string{"LISTEN_PID": "42", "LISTEN_FDS": "-1"}, wantErr: true}, + {name: "missing names", pid: 42, env: map[string]string{"LISTEN_PID": "42", "LISTEN_FDS": "1"}, wantErr: true}, + {name: "too few names", pid: 42, env: map[string]string{"LISTEN_PID": "42", "LISTEN_FDS": "2", "LISTEN_FDNAMES": "web"}, wantErr: true}, + {name: "too many names", pid: 42, env: map[string]string{"LISTEN_PID": "42", "LISTEN_FDS": "1", "LISTEN_FDNAMES": "web:dns"}, wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := parseSystemdListenFDs(tc.pid, func(name string) (string, bool) { + value, ok := tc.env[name] + return value, ok + }) + if tc.wantErr { + if err == nil { + t.Fatal("expected an error") + } + return + } + if err != nil { + t.Fatal(err) + } + if !maps.Equal(got, tc.want) { + t.Fatalf("descriptor mapping = %#v; want %#v", got, tc.want) + } + }) + } +} + +func TestSystemdListenFDByName(t *testing.T) { + descriptors := map[string]int{ + "web": 3, + "dns": 4, + "web.api": 5, + } + + for _, tc := range []struct { + input string + want int + wantErr bool + }{ + {input: "web", want: 3}, + {input: "dns", want: 4}, + {input: "web.api", want: 5}, + {input: "", wantErr: true}, + {input: "missing", wantErr: true}, + {input: "web:1", wantErr: true}, + } { + t.Run(tc.input, func(t *testing.T) { + got, err := systemdListenFDByName(descriptors, tc.input) + if tc.wantErr { + if err == nil { + t.Fatal("expected an error") + } + return + } + if err != nil { + t.Fatal(err) + } + if got != tc.want { + t.Fatalf("descriptor = %d; want %d", got, tc.want) + } + }) + } +} diff --git a/replacer.go b/replacer.go index 7a324165d..85d86cfc2 100644 --- a/replacer.go +++ b/replacer.go @@ -29,6 +29,8 @@ import ( "time" "go.uber.org/zap" + + "github.com/caddyserver/caddy/v2/internal/systemd" ) // NewReplacer returns a new Replacer. @@ -460,6 +462,13 @@ func (f globalDefaultReplacementProvider) replace(key string) (any, bool) { if strings.HasPrefix(key, envPrefix) { return os.Getenv(key[len(envPrefix):]), true } + if name, ok := strings.CutPrefix(key, systemdListenPrefix); ok { + descriptor, err := systemd.ListenFD(name) + if err != nil { + return nil, false + } + return descriptor, true + } switch key { case "system.hostname": @@ -531,5 +540,7 @@ const phOpen, phClose, phEscape = '{', '}', '\\' const filePrefix = "file." +const systemdListenPrefix = "systemd.listen." + // maxFileCacheBytes bounds the memoized file contents to 8 MiB (8,388,608 bytes) per replace call. const maxFileCacheBytes = 8 << 20