Skip to content

Commit 19a22f9

Browse files
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.
1 parent 8c7bce5 commit 19a22f9

3 files changed

Lines changed: 174 additions & 8 deletions

File tree

configure_data_plane.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,11 @@ func configureAPI(skipBasicAuth bool, maxBodySize int64) (http.Handler, func())
9999
// Override options with env variables
100100
if os.Getenv("HAPROXY_MWORKER") == "1" {
101101
mWorker = true
102-
masterRuntime := os.Getenv("HAPROXY_MASTER_CLI")
103-
if misc.IsUnixSocketAddr(masterRuntime) {
104-
haproxyOptions.MasterRuntime = strings.Replace(masterRuntime, "unix@", "", 1)
102+
masterCLI := os.Getenv("HAPROXY_MASTER_CLI")
103+
if masterRuntime, ok := misc.MasterSocketFromEnv(masterCLI); ok {
104+
haproxyOptions.MasterRuntime = masterRuntime
105+
} else if masterCLI != "" {
106+
log.Warningf("No usable UNIX socket in HAPROXY_MASTER_CLI (%s), keeping the configured master runtime", masterCLI)
105107
}
106108
}
107109

misc/misc.go

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -155,16 +155,56 @@ func DiscoverChildPaths(path string, spec json.RawMessage) (models.Endpoints, er
155155
return es, nil
156156
}
157157

158+
// IsUnixSocketAddr reports whether addr designates a UNIX socket, either as a
159+
// bare filesystem path or prefixed with the "unix@" address family used by
160+
// HAProxy. Every other address family ("ipv4@", "sockpair@", "fd@", ...) and
161+
// host:port addresses are rejected, as is the empty string.
158162
func IsUnixSocketAddr(addr string) bool {
159-
if strings.HasPrefix(addr, "ipv4@") || strings.HasPrefix(addr, "ipv6@") {
163+
if addr == "" {
160164
return false
161165
}
162166

163-
// check if it has semicolon
164-
if strings.Contains(addr, ":") {
165-
return false
167+
if family, _, found := strings.Cut(addr, "@"); found {
168+
return family == "unix"
169+
}
170+
171+
// A bare address containing a colon is a host:port, not a socket path.
172+
return !strings.Contains(addr, ":")
173+
}
174+
175+
// MasterSocketFromEnv extracts the master CLI socket path from the raw value of
176+
// the HAPROXY_MASTER_CLI environment variable. HAProxy advertises its master
177+
// CLI sockets as a ";"-separated list, for example
178+
// "unix@/var/run/master.sock;sockpair@7", and the Data Plane API can only talk
179+
// to the UNIX ones.
180+
//
181+
// The first socket already bound on the filesystem wins. When none of the
182+
// candidates exists yet the first valid one is returned anyway, so that a
183+
// delayed runtime start can pick it up once HAProxy binds it. The second return
184+
// value is false when the value holds no usable UNIX socket at all, in which
185+
// case the caller must keep whatever master runtime it was configured with.
186+
func MasterSocketFromEnv(value string) (string, bool) {
187+
var candidates []string
188+
189+
for addr := range strings.SplitSeq(value, ";") {
190+
addr = strings.TrimSpace(addr)
191+
if !IsUnixSocketAddr(addr) {
192+
continue
193+
}
194+
socket := strings.TrimPrefix(addr, "unix@")
195+
if socket == "" {
196+
continue
197+
}
198+
if info, err := os.Stat(socket); err == nil && info.Mode()&os.ModeSocket != 0 {
199+
return socket, true
200+
}
201+
candidates = append(candidates, socket)
202+
}
203+
204+
if len(candidates) == 0 {
205+
return "", false
166206
}
167-
return true
207+
return candidates[0], true
168208
}
169209

170210
func ParseTimeout(tOut string) *int64 {

misc/misc_test.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ package misc
1717

1818
import (
1919
"math/rand"
20+
"net"
21+
"os"
22+
"path/filepath"
2023
"testing"
2124
)
2225

@@ -32,3 +35,124 @@ func TestRandomString(t *testing.T) {
3235
}
3336
}
3437
}
38+
39+
func TestIsUnixSocketAddr(t *testing.T) {
40+
tests := []struct {
41+
addr string
42+
want bool
43+
}{
44+
{addr: "", want: false},
45+
{addr: "/var/run/haproxy.sock", want: true},
46+
{addr: "unix@/var/run/haproxy.sock", want: true},
47+
{addr: "sockpair@7", want: false},
48+
{addr: "fd@3", want: false},
49+
{addr: "ipv4@127.0.0.1:1234", want: false},
50+
{addr: "ipv6@::1:1234", want: false},
51+
{addr: "127.0.0.1:1234", want: false},
52+
}
53+
54+
for _, tt := range tests {
55+
t.Run(tt.addr, func(t *testing.T) {
56+
if got := IsUnixSocketAddr(tt.addr); got != tt.want {
57+
t.Errorf("IsUnixSocketAddr(%q) = %v, want %v", tt.addr, got, tt.want)
58+
}
59+
})
60+
}
61+
}
62+
63+
// listenUnix binds a UNIX socket named name inside dir and returns its path.
64+
func listenUnix(t *testing.T, dir, name string) string {
65+
t.Helper()
66+
67+
socket := filepath.Join(dir, name)
68+
l, err := net.Listen("unix", socket)
69+
if err != nil {
70+
t.Fatalf("cannot listen on %s: %v", socket, err)
71+
}
72+
t.Cleanup(func() { l.Close() })
73+
74+
return socket
75+
}
76+
77+
func TestMasterSocketFromEnv(t *testing.T) {
78+
// os.MkdirTemp instead of t.TempDir: the latter embeds the test name in the
79+
// path, which easily overflows the 104 bytes of sun_path on some systems.
80+
dir, err := os.MkdirTemp("", "dpapi")
81+
if err != nil {
82+
t.Fatalf("cannot create temporary directory: %v", err)
83+
}
84+
t.Cleanup(func() { os.RemoveAll(dir) })
85+
86+
bound := listenUnix(t, dir, "master.sock")
87+
second := listenUnix(t, dir, "second.sock")
88+
missing := filepath.Join(dir, "missing.sock")
89+
regular := filepath.Join(dir, "regular")
90+
if err := os.WriteFile(regular, nil, 0o600); err != nil {
91+
t.Fatalf("cannot create regular file: %v", err)
92+
}
93+
94+
tests := []struct {
95+
name string
96+
value string
97+
want string
98+
wantOK bool
99+
}{
100+
{
101+
name: "empty value",
102+
value: "",
103+
want: "",
104+
wantOK: false,
105+
},
106+
{
107+
name: "only a sockpair",
108+
value: "sockpair@7",
109+
want: "",
110+
wantOK: false,
111+
},
112+
{
113+
name: "unix socket followed by a sockpair",
114+
value: "unix@" + bound + ";sockpair@7",
115+
want: bound,
116+
wantOK: true,
117+
},
118+
{
119+
name: "sockpair listed first",
120+
value: "sockpair@7;unix@" + bound,
121+
want: bound,
122+
wantOK: true,
123+
},
124+
{
125+
name: "first bound socket wins",
126+
value: "unix@" + missing + ";unix@" + second,
127+
want: second,
128+
wantOK: true,
129+
},
130+
{
131+
name: "a regular file is not a socket",
132+
value: "unix@" + regular + ";unix@" + bound,
133+
want: bound,
134+
wantOK: true,
135+
},
136+
{
137+
name: "nothing bound yet falls back to the first candidate",
138+
value: "unix@" + missing + ";sockpair@7",
139+
want: missing,
140+
wantOK: true,
141+
},
142+
{
143+
name: "bare path without the unix prefix",
144+
value: bound,
145+
want: bound,
146+
wantOK: true,
147+
},
148+
}
149+
150+
for _, tt := range tests {
151+
t.Run(tt.name, func(t *testing.T) {
152+
got, ok := MasterSocketFromEnv(tt.value)
153+
if got != tt.want || ok != tt.wantOK {
154+
t.Errorf("MasterSocketFromEnv(%q) = (%q, %v), want (%q, %v)", tt.value, got, ok, tt.want, tt.wantOK)
155+
}
156+
})
157+
}
158+
}

0 commit comments

Comments
 (0)