Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions environment/broker_capability.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package environment

import (
"context"
"encoding/json"
"log/slog"
"os/exec"
"path/filepath"
"sync"
"time"
)

var (
fdutyBrokerOnce sync.Once
fdutyBrokerCapable bool
)

// BrokerEgressEnabled reports whether this runner should advertise egress-broker
// support to safari. Two conditions must both hold:
//
// 1. this build can run the broker (BrokerSupported — Linux only), and
// 2. the bundled fduty can act as a broker-mode client.
//
// Condition 2 is load-bearing: once safari sees broker=1 it delivers the
// per-person app_key out-of-band over the inherited control fd and NEVER in the
// bash env. An older bundled fduty that doesn't understand FLASHDUTY_CRED_FD
// would then have no usable key and every authenticated call would fail. The
// runner does not refresh fduty on self-update, so a fleet runner can carry a
// stale fduty across a runner upgrade. Gating the advertisement here makes the
// rollout self-healing: a stale fduty keeps the legacy env-key path, and once it
// is updated the next reconnect advertises broker mode. It also works in
// zero-egress BYOC, where the runner cannot fetch a newer fduty.
//
// The fduty probe runs at most once and is cached; its cost is a single
// `fduty version --json`.
func BrokerEgressEnabled() bool {
if !BrokerSupported {
return false
}
fdutyBrokerOnce.Do(func() {
fdutyBrokerCapable = probeFdutyBrokerEgress()
})
return fdutyBrokerCapable
}

// probeFdutyBrokerEgress runs the bundled `fduty version --json` and reports
// whether it advertises broker_egress=true. Any failure — fduty missing, an
// older fduty that ignores --json and prints a plain line, or an exec error — is
// treated as "not capable" so the caller falls back to the legacy env-key path
// instead of breaking auth.
func probeFdutyBrokerEgress() bool {
dir := BundledToolsDir()
if dir == "" {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

//nolint:gosec // G204: the path is the runner's own bundled-tools dir, not user input.
out, err := exec.CommandContext(ctx, filepath.Join(dir, "fduty"), "version", "--json").Output()
if err != nil {
slog.Debug("fduty broker-egress probe failed; treating bundled fduty as legacy (env-key path)", "error", err)
return false
}
var v struct {
BrokerEgress bool `json:"broker_egress"`
}
if err := json.Unmarshal(out, &v); err != nil {
slog.Debug("fduty broker-egress probe: non-JSON output; treating bundled fduty as legacy (env-key path)")
return false
}
return v.BrokerEgress
}
53 changes: 53 additions & 0 deletions environment/broker_capability_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//go:build unix

package environment

import (
"os"
"path/filepath"
"testing"
)

// writeFakeFduty drops a stub `fduty` into dir whose `version --json` prints body.
func writeFakeFduty(t *testing.T, dir, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, "fduty"), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
t.Fatalf("write fake fduty: %v", err)
}
}

// TestProbeFdutyBrokerEgress covers the capability probe the runner uses to
// decide whether to advertise broker mode. A capable fduty emits
// broker_egress=true; everything else (missing field, false, legacy plain text,
// nonzero exit) must read as "not capable" so the runner stays on the env-key path.
func TestProbeFdutyBrokerEgress(t *testing.T) {
cases := []struct {
name string
body string // what the fake `fduty version --json` prints
want bool
}{
{"broker-capable json", `echo '{"broker_egress":true,"version":"x"}'`, true},
{"json missing field", `echo '{"version":"x"}'`, false},
{"broker_egress false", `echo '{"broker_egress":false}'`, false},
{"legacy plain text", `echo 'flashduty version 1.2.3 (abc) built today'`, false},
{"nonzero exit", `exit 1`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
writeFakeFduty(t, dir, tc.body)
t.Setenv("FLASHDUTY_RUNNER_BIN_DIR", dir)
if got := probeFdutyBrokerEgress(); got != tc.want {
t.Fatalf("probeFdutyBrokerEgress() = %v, want %v", got, tc.want)
}
})
}
}

// TestProbeFdutyBrokerEgress_Missing: no fduty in the bundled-tools dir → not capable.
func TestProbeFdutyBrokerEgress_Missing(t *testing.T) {
t.Setenv("FLASHDUTY_RUNNER_BIN_DIR", t.TempDir())
if probeFdutyBrokerEgress() {
t.Fatal("probe must be false when fduty is absent")
}
}
6 changes: 4 additions & 2 deletions ws/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,10 @@ func (c *Client) Connect(ctx context.Context) error {
q := u.Query()
q.Set("token", c.token)
// Advertise egress-broker capability so Safari sends the per-person app_key
// out-of-band (never in the bash env). Only Linux builds carry the broker.
if environment.BrokerSupported {
// out-of-band (never in the bash env). Requires both a Linux build AND a
// bundled fduty that can act as a broker-mode client (probed) — otherwise
// Safari would deliver the key out-of-band to an fduty that can't read it.
if environment.BrokerEgressEnabled() {
q.Set("broker", "1")
}
u.RawQuery = q.Encode()
Expand Down
Loading