diff --git a/configure_data_plane.go b/configure_data_plane.go index f768b8a6..d807eeca 100644 --- a/configure_data_plane.go +++ b/configure_data_plane.go @@ -99,9 +99,11 @@ func configureAPI(skipBasicAuth bool, maxBodySize int64) (http.Handler, func()) // Override options with env variables if os.Getenv("HAPROXY_MWORKER") == "1" { mWorker = true - masterRuntime := os.Getenv("HAPROXY_MASTER_CLI") - if misc.IsUnixSocketAddr(masterRuntime) { - haproxyOptions.MasterRuntime = strings.Replace(masterRuntime, "unix@", "", 1) + masterCLI := os.Getenv("HAPROXY_MASTER_CLI") + if masterRuntime, ok := misc.MasterSocketFromEnv(masterCLI); ok { + haproxyOptions.MasterRuntime = masterRuntime + } else if masterCLI != "" { + log.Warningf("No usable UNIX socket in HAPROXY_MASTER_CLI (%s), keeping the configured master runtime", masterCLI) } } diff --git a/misc/misc.go b/misc/misc.go index 00dcf545..ca62dc8a 100644 --- a/misc/misc.go +++ b/misc/misc.go @@ -155,16 +155,56 @@ func DiscoverChildPaths(path string, spec json.RawMessage) (models.Endpoints, er return es, nil } +// IsUnixSocketAddr reports whether addr designates a UNIX socket, either as a +// bare filesystem path or prefixed with the "unix@" address family used by +// HAProxy. Every other address family ("ipv4@", "sockpair@", "fd@", ...) and +// host:port addresses are rejected, as is the empty string. func IsUnixSocketAddr(addr string) bool { - if strings.HasPrefix(addr, "ipv4@") || strings.HasPrefix(addr, "ipv6@") { + if addr == "" { return false } - // check if it has semicolon - if strings.Contains(addr, ":") { - return false + if family, _, found := strings.Cut(addr, "@"); found { + return family == "unix" + } + + // A bare address containing a colon is a host:port, not a socket path. + return !strings.Contains(addr, ":") +} + +// MasterSocketFromEnv extracts the master CLI socket path from the raw value of +// the HAPROXY_MASTER_CLI environment variable. HAProxy advertises its master +// CLI sockets as a ";"-separated list, for example +// "unix@/var/run/master.sock;sockpair@7", and the Data Plane API can only talk +// to the UNIX ones. +// +// The first socket already bound on the filesystem wins. When none of the +// candidates exists yet the first valid one is returned anyway, so that a +// delayed runtime start can pick it up once HAProxy binds it. The second return +// value is false when the value holds no usable UNIX socket at all, in which +// case the caller must keep whatever master runtime it was configured with. +func MasterSocketFromEnv(value string) (string, bool) { + var candidates []string + + for addr := range strings.SplitSeq(value, ";") { + addr = strings.TrimSpace(addr) + if !IsUnixSocketAddr(addr) { + continue + } + socket := strings.TrimPrefix(addr, "unix@") + if socket == "" { + continue + } + if info, err := os.Stat(socket); err == nil && info.Mode()&os.ModeSocket != 0 { + return socket, true + } + candidates = append(candidates, socket) + } + + if len(candidates) == 0 { + return "", false } - return true + return candidates[0], true } func ParseTimeout(tOut string) *int64 { diff --git a/misc/misc_test.go b/misc/misc_test.go index 297c9522..97eaf7ce 100644 --- a/misc/misc_test.go +++ b/misc/misc_test.go @@ -17,6 +17,9 @@ package misc import ( "math/rand" + "net" + "os" + "path/filepath" "testing" ) @@ -32,3 +35,124 @@ func TestRandomString(t *testing.T) { } } } + +func TestIsUnixSocketAddr(t *testing.T) { + tests := []struct { + addr string + want bool + }{ + {addr: "", want: false}, + {addr: "/var/run/haproxy.sock", want: true}, + {addr: "unix@/var/run/haproxy.sock", want: true}, + {addr: "sockpair@7", want: false}, + {addr: "fd@3", want: false}, + {addr: "ipv4@127.0.0.1:1234", want: false}, + {addr: "ipv6@::1:1234", want: false}, + {addr: "127.0.0.1:1234", want: false}, + } + + for _, tt := range tests { + t.Run(tt.addr, func(t *testing.T) { + if got := IsUnixSocketAddr(tt.addr); got != tt.want { + t.Errorf("IsUnixSocketAddr(%q) = %v, want %v", tt.addr, got, tt.want) + } + }) + } +} + +// listenUnix binds a UNIX socket named name inside dir and returns its path. +func listenUnix(t *testing.T, dir, name string) string { + t.Helper() + + socket := filepath.Join(dir, name) + l, err := net.Listen("unix", socket) + if err != nil { + t.Fatalf("cannot listen on %s: %v", socket, err) + } + t.Cleanup(func() { l.Close() }) + + return socket +} + +func TestMasterSocketFromEnv(t *testing.T) { + // os.MkdirTemp instead of t.TempDir: the latter embeds the test name in the + // path, which easily overflows the 104 bytes of sun_path on some systems. + dir, err := os.MkdirTemp("", "dpapi") + if err != nil { + t.Fatalf("cannot create temporary directory: %v", err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + + bound := listenUnix(t, dir, "master.sock") + second := listenUnix(t, dir, "second.sock") + missing := filepath.Join(dir, "missing.sock") + regular := filepath.Join(dir, "regular") + if err := os.WriteFile(regular, nil, 0o600); err != nil { + t.Fatalf("cannot create regular file: %v", err) + } + + tests := []struct { + name string + value string + want string + wantOK bool + }{ + { + name: "empty value", + value: "", + want: "", + wantOK: false, + }, + { + name: "only a sockpair", + value: "sockpair@7", + want: "", + wantOK: false, + }, + { + name: "unix socket followed by a sockpair", + value: "unix@" + bound + ";sockpair@7", + want: bound, + wantOK: true, + }, + { + name: "sockpair listed first", + value: "sockpair@7;unix@" + bound, + want: bound, + wantOK: true, + }, + { + name: "first bound socket wins", + value: "unix@" + missing + ";unix@" + second, + want: second, + wantOK: true, + }, + { + name: "a regular file is not a socket", + value: "unix@" + regular + ";unix@" + bound, + want: bound, + wantOK: true, + }, + { + name: "nothing bound yet falls back to the first candidate", + value: "unix@" + missing + ";sockpair@7", + want: missing, + wantOK: true, + }, + { + name: "bare path without the unix prefix", + value: bound, + want: bound, + wantOK: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := MasterSocketFromEnv(tt.value) + if got != tt.want || ok != tt.wantOK { + t.Errorf("MasterSocketFromEnv(%q) = (%q, %v), want (%q, %v)", tt.value, got, ok, tt.want, tt.wantOK) + } + }) + } +}