Skip to content

Commit d32bbaf

Browse files
committed
fix(cli): fail fast when the broker credential fd is not inherited
In broker mode fduty reaches Flashduty through a control socket the runner passes as an inherited fd (FLASHDUTY_CRED_FD). Programs that spawn fduty without passing inherited fds - Python's subprocess (close_fds=True by default), Node's child_process, sudo - leave that fd closed or reused by an unrelated file. The failure then only surfaced at the first request as "broker handshake send: bad file descriptor" (or "socket operation on non-socket"), after the SDK's request URL prefix, with no hint of the fix. newBrokerHTTPClient now checks that the fd is an open socket before building the client and returns an error that names the fd and how to keep it open (run from the shell, Python pass_fds, Node stdio entry). It returns (*http.Client, error), so the non-unix stub returns errBrokerUnsupported directly and root.go drops its nil check.
1 parent b771674 commit d32bbaf

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)