From 9ba2ea4d1242d10fb7ab484486f2be8a00fab3b2 Mon Sep 17 00:00:00 2001 From: Pavel Siomachkin Date: Sat, 6 Sep 2025 16:27:52 +0200 Subject: [PATCH 1/3] Add support for named socket activation with fdname/name and fdgramname/name syntax --- listeners.go | 53 ++++++++++- listeners_test.go | 222 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+), 1 deletion(-) diff --git a/listeners.go b/listeners.go index 01adc615d..ac8003140 100644 --- a/listeners.go +++ b/listeners.go @@ -305,6 +305,39 @@ func IsFdNetwork(netw string) bool { return strings.HasPrefix(netw, "fd") } +// getFdByName returns the file descriptor number for the given +// socket name from systemd's LISTEN_FDNAMES environment variable. +// Socket names are provided by systemd via socket activation. +func getFdByName(name string) (int, error) { + if name == "" { + return 0, fmt.Errorf("socket name cannot be empty") + } + + fdNamesStr := os.Getenv("LISTEN_FDNAMES") + fdCountStr := os.Getenv("LISTEN_FDS") + + if fdNamesStr == "" { + return 0, fmt.Errorf("LISTEN_FDNAMES environment variable not set") + } + + if fdCountStr == "" { + return 0, fmt.Errorf("LISTEN_FDS environment variable not set") + } + + // Parse the socket names + names := strings.Split(fdNamesStr, ":") + + // Find the index of the requested name + for i, fdName := range names { + if fdName == name { + // File descriptors start at 3 (after stdin=0, stdout=1, stderr=2) + return 3 + i, nil + } + } + + return 0, fmt.Errorf("socket name '%s' not found in LISTEN_FDNAMES", name) +} + // ParseNetworkAddress parses addr into its individual // components. The input string is expected to be of // the form "network/host:port-range" where any part is @@ -336,9 +369,27 @@ func ParseNetworkAddressWithDefaults(addr, defaultNetwork string, defaultPort ui }, err } if IsFdNetwork(network) { + fdAddr := host + + // Handle named socket activation (fdname/name, fdgramname/name) + if strings.HasPrefix(network, "fdname") || strings.HasPrefix(network, "fdgramname") { + fdNum, err := getFdByName(host) + if err != nil { + return NetworkAddress{}, fmt.Errorf("named socket activation: %v", err) + } + fdAddr = strconv.Itoa(fdNum) + + // Normalize network to standard fd/fdgram + if strings.HasPrefix(network, "fdname") { + network = "fd" + } else { + network = "fdgram" + } + } + return NetworkAddress{ Network: network, - Host: host, + Host: fdAddr, }, nil } var start, end uint64 diff --git a/listeners_test.go b/listeners_test.go index a4cadd3aa..ecd6f0f3d 100644 --- a/listeners_test.go +++ b/listeners_test.go @@ -15,6 +15,7 @@ package caddy import ( + "os" "reflect" "testing" @@ -652,3 +653,224 @@ func TestSplitUnixSocketPermissionsBits(t *testing.T) { } } } + +// TestGetFdByName tests the getFdByName function for systemd socket activation. +func TestGetFdByName(t *testing.T) { + // Save original environment + originalFdNames := os.Getenv("LISTEN_FDNAMES") + originalFds := os.Getenv("LISTEN_FDS") + + // Restore environment after test + defer func() { + if originalFdNames != "" { + os.Setenv("LISTEN_FDNAMES", originalFdNames) + } else { + os.Unsetenv("LISTEN_FDNAMES") + } + if originalFds != "" { + os.Setenv("LISTEN_FDS", originalFds) + } else { + os.Unsetenv("LISTEN_FDS") + } + }() + + tests := []struct { + name string + fdNames string + fdCount string + socketName string + expectedFd int + expectError bool + }{ + { + name: "simple http socket", + fdNames: "http", + fdCount: "1", + socketName: "http", + expectedFd: 3, + }, + { + name: "multiple sockets - first", + fdNames: "http:https:dns", + fdCount: "3", + socketName: "http", + expectedFd: 3, + }, + { + name: "multiple sockets - second", + fdNames: "http:https:dns", + fdCount: "3", + socketName: "https", + expectedFd: 4, + }, + { + name: "multiple sockets - third", + fdNames: "http:https:dns", + fdCount: "3", + socketName: "dns", + expectedFd: 5, + }, + { + name: "socket not found", + fdNames: "http:https", + fdCount: "2", + socketName: "missing", + expectError: true, + }, + { + name: "empty socket name", + fdNames: "http", + fdCount: "1", + socketName: "", + expectError: true, + }, + { + name: "missing LISTEN_FDNAMES", + fdNames: "", + fdCount: "1", + socketName: "http", + expectError: true, + }, + { + name: "missing LISTEN_FDS", + fdNames: "http", + fdCount: "", + socketName: "http", + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Set up environment + if tc.fdNames != "" { + os.Setenv("LISTEN_FDNAMES", tc.fdNames) + } else { + os.Unsetenv("LISTEN_FDNAMES") + } + if tc.fdCount != "" { + os.Setenv("LISTEN_FDS", tc.fdCount) + } else { + os.Unsetenv("LISTEN_FDS") + } + + // Test the function + fd, err := getFdByName(tc.socketName) + + if tc.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } + } else { + if err != nil { + t.Errorf("Expected no error but got: %v", err) + } + if fd != tc.expectedFd { + t.Errorf("Expected FD %d but got %d", tc.expectedFd, fd) + } + } + }) + } +} + +// TestParseNetworkAddressFdName tests parsing of fdname and fdgramname addresses. +func TestParseNetworkAddressFdName(t *testing.T) { + // Save and restore environment + originalFdNames := os.Getenv("LISTEN_FDNAMES") + originalFds := os.Getenv("LISTEN_FDS") + defer func() { + if originalFdNames != "" { + os.Setenv("LISTEN_FDNAMES", originalFdNames) + } else { + os.Unsetenv("LISTEN_FDNAMES") + } + if originalFds != "" { + os.Setenv("LISTEN_FDS", originalFds) + } else { + os.Unsetenv("LISTEN_FDS") + } + }() + + // Set up test environment + os.Setenv("LISTEN_FDNAMES", "http:https:dns") + os.Setenv("LISTEN_FDS", "3") + + tests := []struct { + input string + expectAddr NetworkAddress + expectErr bool + }{ + { + input: "fdname/http", + expectAddr: NetworkAddress{ + Network: "fd", + Host: "3", + }, + }, + { + input: "fdname/https", + expectAddr: NetworkAddress{ + Network: "fd", + Host: "4", + }, + }, + { + input: "fdname/dns", + expectAddr: NetworkAddress{ + Network: "fd", + Host: "5", + }, + }, + { + input: "fdgramname/http", + expectAddr: NetworkAddress{ + Network: "fdgram", + Host: "3", + }, + }, + { + input: "fdgramname/https", + expectAddr: NetworkAddress{ + Network: "fdgram", + Host: "4", + }, + }, + { + input: "fdname/nonexistent", + expectErr: true, + }, + { + input: "fdgramname/nonexistent", + expectErr: true, + }, + // Test that old fd/N syntax still works + { + input: "fd/7", + expectAddr: NetworkAddress{ + Network: "fd", + Host: "7", + }, + }, + { + input: "fdgram/8", + expectAddr: NetworkAddress{ + Network: "fdgram", + Host: "8", + }, + }, + } + + for i, tc := range tests { + actualAddr, err := ParseNetworkAddress(tc.input) + + if tc.expectErr && err == nil { + t.Errorf("Test %d (%s): Expected error but got none", i, tc.input) + } + if !tc.expectErr && err != nil { + t.Errorf("Test %d (%s): Expected no error but got: %v", i, tc.input, err) + } + if !tc.expectErr && !reflect.DeepEqual(tc.expectAddr, actualAddr) { + t.Errorf("Test %d (%s): Expected %+v but got %+v", i, tc.input, tc.expectAddr, actualAddr) + } + } +} From 35658131f909b1dc4379fa67798647de65de2cfa Mon Sep 17 00:00:00 2001 From: Pavel Siomachkin Date: Sat, 6 Sep 2025 17:13:05 +0200 Subject: [PATCH 2/3] Simplify getFdByName by removing unused LISTEN_FDS validation --- listeners.go | 6 ------ listeners_test.go | 33 --------------------------------- 2 files changed, 39 deletions(-) diff --git a/listeners.go b/listeners.go index ac8003140..82db5d2e0 100644 --- a/listeners.go +++ b/listeners.go @@ -314,16 +314,10 @@ func getFdByName(name string) (int, error) { } fdNamesStr := os.Getenv("LISTEN_FDNAMES") - fdCountStr := os.Getenv("LISTEN_FDS") - if fdNamesStr == "" { return 0, fmt.Errorf("LISTEN_FDNAMES environment variable not set") } - if fdCountStr == "" { - return 0, fmt.Errorf("LISTEN_FDS environment variable not set") - } - // Parse the socket names names := strings.Split(fdNamesStr, ":") diff --git a/listeners_test.go b/listeners_test.go index ecd6f0f3d..774e33df7 100644 --- a/listeners_test.go +++ b/listeners_test.go @@ -658,7 +658,6 @@ func TestSplitUnixSocketPermissionsBits(t *testing.T) { func TestGetFdByName(t *testing.T) { // Save original environment originalFdNames := os.Getenv("LISTEN_FDNAMES") - originalFds := os.Getenv("LISTEN_FDS") // Restore environment after test defer func() { @@ -667,17 +666,11 @@ func TestGetFdByName(t *testing.T) { } else { os.Unsetenv("LISTEN_FDNAMES") } - if originalFds != "" { - os.Setenv("LISTEN_FDS", originalFds) - } else { - os.Unsetenv("LISTEN_FDS") - } }() tests := []struct { name string fdNames string - fdCount string socketName string expectedFd int expectError bool @@ -685,56 +678,42 @@ func TestGetFdByName(t *testing.T) { { name: "simple http socket", fdNames: "http", - fdCount: "1", socketName: "http", expectedFd: 3, }, { name: "multiple sockets - first", fdNames: "http:https:dns", - fdCount: "3", socketName: "http", expectedFd: 3, }, { name: "multiple sockets - second", fdNames: "http:https:dns", - fdCount: "3", socketName: "https", expectedFd: 4, }, { name: "multiple sockets - third", fdNames: "http:https:dns", - fdCount: "3", socketName: "dns", expectedFd: 5, }, { name: "socket not found", fdNames: "http:https", - fdCount: "2", socketName: "missing", expectError: true, }, { name: "empty socket name", fdNames: "http", - fdCount: "1", socketName: "", expectError: true, }, { name: "missing LISTEN_FDNAMES", fdNames: "", - fdCount: "1", - socketName: "http", - expectError: true, - }, - { - name: "missing LISTEN_FDS", - fdNames: "http", - fdCount: "", socketName: "http", expectError: true, }, @@ -748,11 +727,6 @@ func TestGetFdByName(t *testing.T) { } else { os.Unsetenv("LISTEN_FDNAMES") } - if tc.fdCount != "" { - os.Setenv("LISTEN_FDS", tc.fdCount) - } else { - os.Unsetenv("LISTEN_FDS") - } // Test the function fd, err := getFdByName(tc.socketName) @@ -777,23 +751,16 @@ func TestGetFdByName(t *testing.T) { func TestParseNetworkAddressFdName(t *testing.T) { // Save and restore environment originalFdNames := os.Getenv("LISTEN_FDNAMES") - originalFds := os.Getenv("LISTEN_FDS") defer func() { if originalFdNames != "" { os.Setenv("LISTEN_FDNAMES", originalFdNames) } else { os.Unsetenv("LISTEN_FDNAMES") } - if originalFds != "" { - os.Setenv("LISTEN_FDS", originalFds) - } else { - os.Unsetenv("LISTEN_FDS") - } }() // Set up test environment os.Setenv("LISTEN_FDNAMES", "http:https:dns") - os.Setenv("LISTEN_FDS", "3") tests := []struct { input string From a819b7309b2399f06d682ba49646c9761d653f1b Mon Sep 17 00:00:00 2001 From: Pavel Siomachkin Date: Sat, 6 Sep 2025 22:32:26 +0200 Subject: [PATCH 3/3] Add support for indexed named sockets to handle duplicate names --- listeners.go | 55 ++++++++++++++++++++----- listeners_test.go | 101 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 143 insertions(+), 13 deletions(-) diff --git a/listeners.go b/listeners.go index 82db5d2e0..82cdd8ed4 100644 --- a/listeners.go +++ b/listeners.go @@ -38,6 +38,10 @@ import ( "github.com/caddyserver/caddy/v2/internal" ) +// listenFdsStart is the first file descriptor number for systemd socket activation. +// File descriptors 0, 1, 2 are reserved for stdin, stdout, stderr. +const listenFdsStart = 3 + // NetworkAddress represents one or more network addresses. // It contains the individual components for a parsed network // address of the form accepted by ParseNetworkAddress(). @@ -308,8 +312,12 @@ func IsFdNetwork(netw string) bool { // getFdByName returns the file descriptor number for the given // socket name from systemd's LISTEN_FDNAMES environment variable. // Socket names are provided by systemd via socket activation. -func getFdByName(name string) (int, error) { - if name == "" { +// +// The name can optionally include an index to handle multiple sockets +// with the same name: "web:0" for first, "web:1" for second, etc. +// If no index is specified, defaults to index 0 (first occurrence). +func getFdByName(nameWithIndex string) (int, error) { + if nameWithIndex == "" { return 0, fmt.Errorf("socket name cannot be empty") } @@ -318,18 +326,45 @@ func getFdByName(name string) (int, error) { return 0, fmt.Errorf("LISTEN_FDNAMES environment variable not set") } - // Parse the socket names - names := strings.Split(fdNamesStr, ":") + // Parse name and optional index + parts := strings.Split(nameWithIndex, ":") + if len(parts) > 2 { + return 0, fmt.Errorf("invalid socket name format '%s': too many colons", nameWithIndex) + } - // Find the index of the requested name - for i, fdName := range names { - if fdName == name { - // File descriptors start at 3 (after stdin=0, stdout=1, stderr=2) - return 3 + i, nil + name := parts[0] + targetIndex := 0 + + if len(parts) > 1 { + var err error + targetIndex, err = strconv.Atoi(parts[1]) + if err != nil { + return 0, fmt.Errorf("invalid socket index '%s': %v", parts[1], err) + } + if targetIndex < 0 { + return 0, fmt.Errorf("socket index cannot be negative: %d", targetIndex) } } - return 0, fmt.Errorf("socket name '%s' not found in LISTEN_FDNAMES", name) + // Parse the socket names + names := strings.Split(fdNamesStr, ":") + + // Find the Nth occurrence of the requested name + matchCount := 0 + for i, fdName := range names { + if fdName == name { + if matchCount == targetIndex { + return listenFdsStart + i, nil + } + matchCount++ + } + } + + if matchCount == 0 { + return 0, fmt.Errorf("socket name '%s' not found in LISTEN_FDNAMES", name) + } + + return 0, fmt.Errorf("socket name '%s' found %d times, but index %d requested", name, matchCount, targetIndex) } // ParseNetworkAddress parses addr into its individual diff --git a/listeners_test.go b/listeners_test.go index 774e33df7..c2cc255f2 100644 --- a/listeners_test.go +++ b/listeners_test.go @@ -682,23 +682,65 @@ func TestGetFdByName(t *testing.T) { expectedFd: 3, }, { - name: "multiple sockets - first", + name: "multiple different sockets - first", fdNames: "http:https:dns", socketName: "http", expectedFd: 3, }, { - name: "multiple sockets - second", + name: "multiple different sockets - second", fdNames: "http:https:dns", socketName: "https", expectedFd: 4, }, { - name: "multiple sockets - third", + name: "multiple different sockets - third", fdNames: "http:https:dns", socketName: "dns", expectedFd: 5, }, + { + name: "duplicate names - first occurrence (no index)", + fdNames: "web:web:api", + socketName: "web", + expectedFd: 3, + }, + { + name: "duplicate names - first occurrence (explicit index 0)", + fdNames: "web:web:api", + socketName: "web:0", + expectedFd: 3, + }, + { + name: "duplicate names - second occurrence (index 1)", + fdNames: "web:web:api", + socketName: "web:1", + expectedFd: 4, + }, + { + name: "complex duplicates - first api", + fdNames: "web:api:web:api:dns", + socketName: "api:0", + expectedFd: 4, + }, + { + name: "complex duplicates - second api", + fdNames: "web:api:web:api:dns", + socketName: "api:1", + expectedFd: 6, + }, + { + name: "complex duplicates - first web", + fdNames: "web:api:web:api:dns", + socketName: "web:0", + expectedFd: 3, + }, + { + name: "complex duplicates - second web", + fdNames: "web:api:web:api:dns", + socketName: "web:1", + expectedFd: 5, + }, { name: "socket not found", fdNames: "http:https", @@ -717,6 +759,30 @@ func TestGetFdByName(t *testing.T) { socketName: "http", expectError: true, }, + { + name: "index out of range", + fdNames: "web:web", + socketName: "web:2", + expectError: true, + }, + { + name: "negative index", + fdNames: "web", + socketName: "web:-1", + expectError: true, + }, + { + name: "invalid index format", + fdNames: "web", + socketName: "web:abc", + expectError: true, + }, + { + name: "too many colons", + fdNames: "web", + socketName: "web:0:extra", + expectError: true, + }, } for _, tc := range tests { @@ -788,6 +854,20 @@ func TestParseNetworkAddressFdName(t *testing.T) { Host: "5", }, }, + { + input: "fdname/http:0", + expectAddr: NetworkAddress{ + Network: "fd", + Host: "3", + }, + }, + { + input: "fdname/https:0", + expectAddr: NetworkAddress{ + Network: "fd", + Host: "4", + }, + }, { input: "fdgramname/http", expectAddr: NetworkAddress{ @@ -802,6 +882,13 @@ func TestParseNetworkAddressFdName(t *testing.T) { Host: "4", }, }, + { + input: "fdgramname/http:0", + expectAddr: NetworkAddress{ + Network: "fdgram", + Host: "3", + }, + }, { input: "fdname/nonexistent", expectErr: true, @@ -810,6 +897,14 @@ func TestParseNetworkAddressFdName(t *testing.T) { input: "fdgramname/nonexistent", expectErr: true, }, + { + input: "fdname/http:99", + expectErr: true, + }, + { + input: "fdname/invalid:abc", + expectErr: true, + }, // Test that old fd/N syntax still works { input: "fd/7",