From 19a22f910188a0636216128bb7e4def1cc5264e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fa=CC=81bio=20Matavelli?= Date: Wed, 2 Sep 2026 15:19:46 +0100 Subject: [PATCH] BUG/MINOR: configure: pick the first valid UNIX socket from HAPROXY_MASTER_CLI Since HAProxy commit 8a02257d, HAPROXY_MASTER_CLI advertises the master CLI as a list of addresses separated by ";", for example "unix@/var/run/master.sock;sockpair@7". The whole value was handed over to the runtime client, which failed to dial a socket path that does not exist and made the Data Plane API exit. HAProxy reacts to that with exit-on-failure and kills every process, which is the failure reported in the issue. Split the value, keep only the UNIX addresses and use the first one that is already bound, falling back to the first valid candidate so that a delayed runtime start still picks it up. The UNIX socket check now rejects the empty string and every address family other than "unix@". Together with the caller no longer overriding the master runtime when no socket was found, a value set through --master-runtime or haproxy.master_runtime is not silently replaced by an empty string any more. --- configure_data_plane.go | 8 ++- misc/misc.go | 50 ++++++++++++++-- misc/misc_test.go | 124 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+), 8 deletions(-) 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) + } + }) + } +}