Skip to content

Commit 641c52c

Browse files
authored
Merge pull request #211 from flashcatcloud/fix/broker-fd-preflight
fix(cli): fail fast when the broker credential fd is not inherited
2 parents b771674 + d32bbaf commit 641c52c

4 files changed

Lines changed: 62 additions & 15 deletions

File tree

‎internal/cli/broker_dial_other.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import (
77
"net/http"
88
)
99

10-
func newBrokerHTTPClient(int) *http.Client { return nil }
10+
func newBrokerHTTPClient(int) (*http.Client, error) { return nil, errBrokerUnsupported }
1111

1212
var errBrokerUnsupported = errors.New("flashduty: broker mode is not supported on this platform")
1313

‎internal/cli/broker_dial_unix.go‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,6 @@ import (
1414
"time"
1515
)
1616

17-
// errBrokerUnsupported is returned when broker mode is requested on a build that
18-
// cannot provide it. On unix this is effectively unreachable (newBrokerHTTPClient
19-
// never returns nil), but defaultNewClient references it on every platform.
20-
var errBrokerUnsupported = errors.New("flashduty: broker mode is not supported on this platform")
21-
2217
// errBrokerClosed is returned (wrapped) when the runner-side broker control
2318
// channel is gone: the runner exited, or reclaimed the channel once the
2419
// command that started this process finished, so fduty calls from a
@@ -110,7 +105,21 @@ func (d *brokerDialer) dial(_ context.Context, _, _ string) (net.Conn, error) {
110105
// every connection over the inherited control fd. Timeout matches the SDK's
111106
// historical default (30s) so behavior is unchanged for non-streaming calls;
112107
// streaming export relies on request context like before.
113-
func newBrokerHTTPClient(credFD int) *http.Client {
108+
//
109+
// It first checks that credFD is an open socket in this process. The runner
110+
// hands the control end to bash, and only processes that inherit fd credFD
111+
// reach fduty with it intact: Python's subprocess (close_fds=True by default),
112+
// Node's child_process and sudo all close it. Without the check that surfaces
113+
// as a handshake EBADF/ENOTSOCK at the first request, after the SDK's URL
114+
// prefix, with nothing saying how to fix it.
115+
func newBrokerHTTPClient(credFD int) (*http.Client, error) {
116+
if _, err := syscall.GetsockoptInt(credFD, syscall.SOL_SOCKET, syscall.SO_TYPE); err != nil {
117+
return nil, fmt.Errorf("FLASHDUTY_CRED_FD=%d is not an open socket in this process (%v): "+
118+
"the program that started fduty did not pass the credential channel down. "+
119+
"Run fduty from the shell, or keep fd %d open when spawning it: "+
120+
"Python subprocess.run(cmd, pass_fds=(%d,)); Node: set entry %d of spawn's stdio array to %d",
121+
credFD, err, credFD, credFD, credFD, credFD)
122+
}
114123
d := &brokerDialer{credFD: credFD}
115124
return &http.Client{
116125
Timeout: 30 * time.Second,
@@ -125,5 +134,5 @@ func newBrokerHTTPClient(credFD int) *http.Client {
125134
IdleConnTimeout: 90 * time.Second,
126135
ResponseHeaderTimeout: 0,
127136
},
128-
}
137+
}, nil
129138
}

‎internal/cli/broker_dial_unix_test.go‎

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,9 @@ func TestBrokerHTTPClient_DialAndRewrite(t *testing.T) {
106106
defer func() { _ = syscall.Close(childFD) }()
107107
defer stop()
108108

109-
client := newBrokerHTTPClient(childFD)
110-
if client == nil {
111-
t.Fatal("newBrokerHTTPClient returned nil")
109+
client, err := newBrokerHTTPClient(childFD)
110+
if err != nil {
111+
t.Fatalf("newBrokerHTTPClient: %v", err)
112112
}
113113
defer client.CloseIdleConnections() // release dispatched keep-alive conns
114114
// The CLI's base URL is an http placeholder; broker rewrites host.
@@ -208,6 +208,41 @@ func TestDefaultNewClient_RejectsStdioFD(t *testing.T) {
208208
}
209209
}
210210

211+
// TestDefaultNewClient_CredFDNotInherited covers a caller that drops the
212+
// inherited control fd before exec (Python's subprocess closes fds >= 3 by
213+
// default): the fd number is then closed, or reused by an unrelated file.
214+
// Either way defaultNewClient must fail up front with an error that names the
215+
// fd and the fix, instead of a handshake errno at the first request.
216+
func TestDefaultNewClient_CredFDNotInherited(t *testing.T) {
217+
t.Setenv("HOME", t.TempDir())
218+
t.Setenv("FLASHDUTY_APP_KEY", "")
219+
220+
f, err := os.CreateTemp(t.TempDir(), "not-a-socket")
221+
if err != nil {
222+
t.Fatal(err)
223+
}
224+
defer func() { _ = f.Close() }()
225+
pair, err := syscall.Socketpair(syscall.AF_UNIX, controlSockType, 0)
226+
if err != nil {
227+
t.Fatalf("socketpair: %v", err)
228+
}
229+
closedFD := pair[0]
230+
_ = syscall.Close(pair[0])
231+
_ = syscall.Close(pair[1])
232+
233+
for name, fd := range map[string]int{"closed fd": closedFD, "regular file": int(f.Fd())} {
234+
t.Setenv("FLASHDUTY_CRED_FD", strconv.Itoa(fd))
235+
_, err := defaultNewClient()
236+
if err == nil {
237+
t.Fatalf("%s: defaultNewClient must fail", name)
238+
}
239+
want := "FLASHDUTY_CRED_FD=" + strconv.Itoa(fd) + " is not an open socket"
240+
if msg := err.Error(); !strings.HasPrefix(msg, want) || !strings.Contains(msg, "pass_fds=("+strconv.Itoa(fd)+",)") {
241+
t.Fatalf("%s: error must start with %q and name pass_fds, got: %v", name, want, msg)
242+
}
243+
}
244+
}
245+
211246
// TestBrokerHTTPClient_RefusedReturnsError verifies the dialer surfaces the
212247
// broker's 0xFF refusal (e.g. the runner failed to mint a connection) as a real
213248
// error instead of hanging or wrapping a nil conn.
@@ -232,7 +267,10 @@ func TestBrokerHTTPClient_RefusedReturnsError(t *testing.T) {
232267
}
233268
}()
234269

235-
client := newBrokerHTTPClient(childFD)
270+
client, err := newBrokerHTTPClient(childFD)
271+
if err != nil {
272+
t.Fatalf("newBrokerHTTPClient: %v", err)
273+
}
236274
req, _ := http.NewRequestWithContext(context.Background(), "GET",
237275
"http://flashduty.broker.local/x?app_key=SENTINEL", nil)
238276
if _, err := client.Do(req); err == nil {

‎internal/cli/root.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -234,9 +234,9 @@ func defaultNewClient() (*flashduty.Client, error) {
234234
if perr != nil || fd < 3 {
235235
return nil, fmt.Errorf("invalid FLASHDUTY_CRED_FD=%q", fdStr)
236236
}
237-
hc := newBrokerHTTPClient(fd)
238-
if hc == nil {
239-
return nil, errBrokerUnsupported
237+
hc, err := newBrokerHTTPClient(fd)
238+
if err != nil {
239+
return nil, err
240240
}
241241
opts = append(opts, flashduty.WithHTTPClient(hc))
242242
appKey = "broker-sentinel" // non-empty: go-flashduty rejects ""; broker overwrites it

0 commit comments

Comments
 (0)