diff --git a/cmd/clawtool-installer/app.go b/cmd/clawtool-installer/app.go
index c1cc23f..74fb29a 100644
--- a/cmd/clawtool-installer/app.go
+++ b/cmd/clawtool-installer/app.go
@@ -568,6 +568,46 @@ func (a *App) CircleClear() string {
return `{"ok":true}`
}
+// LanStatus reports whether the daemon is LAN-exposed (reachable by
+// circle peers) or loopback-only. Reads `clawtool daemon lan status`.
+func (a *App) LanStatus() string {
+ bin, err := locateClawtool()
+ if err != nil {
+ return jsonErr(err.Error())
+ }
+ cmd := exec.Command(bin, "daemon", "lan", "status")
+ hideConsole(cmd)
+ out, _ := cmd.Output()
+ b, _ := json.Marshal(struct {
+ OK bool `json:"ok"`
+ Enabled bool `json:"enabled"`
+ }{OK: true, Enabled: firstLine(out) == "on"})
+ return string(b)
+}
+
+// LanEnable exposes this device to LAN circle peers; LanDisable returns it
+// to loopback-only. Both restart the daemon (the call blocks until it's
+// back up); the first enable also triggers the platform firewall prompt.
+func (a *App) LanEnable() string { return a.lanSet("on") }
+func (a *App) LanDisable() string { return a.lanSet("off") }
+
+func (a *App) lanSet(mode string) string {
+ bin, err := locateClawtool()
+ if err != nil {
+ return jsonErr(err.Error())
+ }
+ cmd := exec.Command(bin, "daemon", "lan", mode)
+ hideConsole(cmd)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ msg := firstLine(out)
+ if msg == "" {
+ msg = "could not change LAN exposure"
+ }
+ return jsonErr(msg)
+ }
+ return `{"ok":true}`
+}
+
// ensureDaemonBase resolves the daemon's loopback base URL, starting the
// daemon if it isn't recorded yet.
func (a *App) ensureDaemonBase() (string, error) {
diff --git a/cmd/clawtool-installer/frontend/dist/index.html b/cmd/clawtool-installer/frontend/dist/index.html
index 90782ea..fd8ae0d 100644
--- a/cmd/clawtool-installer/frontend/dist/index.html
+++ b/cmd/clawtool-installer/frontend/dist/index.html
@@ -143,6 +143,17 @@
.reveal.show { display: block; }
.toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%) translateY(12px); background: var(--fg); color: var(--bg); font-size: 12.5px; padding: 8px 16px; border-radius: 999px; opacity: 0; transition: opacity .2s, transform .2s; pointer-events: none; }
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
+ .switchrow { display: flex; align-items: flex-start; gap: 14px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--hair); }
+ .switchrow .txt { flex: 1; }
+ .switchrow .st { font-weight: 600; font-size: 13.5px; }
+ .switchrow .sd { color: var(--secondary); font-size: 12.5px; margin-top: 3px; line-height: 1.45; }
+ .switch { position: relative; width: 40px; height: 24px; flex: none; cursor: pointer; display: inline-block; }
+ .switch input { position: absolute; opacity: 0; width: 0; height: 0; }
+ .switch .track { position: absolute; inset: 0; background: var(--hair); border-radius: 999px; transition: background .18s; }
+ .switch input:checked ~ .track { background: var(--green); }
+ .switch .knob { position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: transform .18s; box-shadow: 0 1px 2px rgba(0,0,0,.25); }
+ .switch input:checked ~ .knob { transform: translateX(16px); }
+ .switch.busy { opacity: .55; pointer-events: none; }
@@ -377,6 +388,8 @@ Updates
// ── Cross-device (circle key) ───────────────────────────────
const COPY = ' ';
let circleState = { has_key: false, key: "" };
+ let lanState = { enabled: false };
+ let lanBusy = false;
function toast(msg) {
let t = $("toast"); if (!t) { t = document.createElement("div"); t.id = "toast"; t.className = "toast"; document.body.appendChild(t); }
t.textContent = msg; t.classList.add("show"); clearTimeout(t._h); t._h = setTimeout(() => t.classList.remove("show"), 1900);
@@ -393,31 +406,54 @@ Updates
async function loadCircle() {
const st = parse(await call("CircleStatus"), { ok: false });
circleState = (st && st.ok) ? st : { has_key: false, key: "" };
+ const ln = parse(await call("LanStatus"), { ok: false });
+ lanState = (ln && ln.ok) ? ln : { enabled: false };
renderXdCard(); refreshXdStat();
}
+ function lanSwitchHTML() {
+ return 'Reachable on your LAN
' +
+ '
Let circle devices on your network read this device’s agent list. Code execution stays local-only; the first time, your OS may ask to allow it through the firewall.
' +
+ '
';
+ }
+ async function lanToggle(e) {
+ const want = !!e.target.checked;
+ lanBusy = true; renderXdCard();
+ toast(want ? "Enabling LAN reachability…" : "Disabling…");
+ const r = parse(await call(want ? "LanEnable" : "LanDisable"), { ok: false });
+ lanBusy = false;
+ if (r && r.ok) { lanState.enabled = want; toast(want ? "Reachable on your LAN ✓" : "Loopback-only ✓"); }
+ else { toast((r && r.error) ? r.error : "Could not change LAN reachability"); }
+ renderXdCard();
+ }
function renderXdCard() {
const el = $("xd-card"); if (!el) return;
+ let inner;
if (circleState.has_key) {
const k = circleState.key || "";
- el.innerHTML =
- 'Cross-device peering On
' +
+ inner =
+ '
Cross-device peering On
' +
'
Devices that share this circle key can see each other’s agents over your network.
' +
'
' + esc(maskKey(k)) + '' + COPY + '
' +
'
On your other devices, open clawtool → Network → Join with a key , then paste this key.
' +
- '
Leave circle
';
- $("ck-copy").addEventListener("click", () => copyText(k, "Circle key copied"));
- $("ck-leave").addEventListener("click", circleLeave);
+ 'Leave circle
';
} else {
- el.innerHTML =
- 'Cross-device peering Off
' +
+ inner =
+ '
Cross-device peering Off
' +
'
Create a circle to let your devices see each other’s agents. Generate a key here, then join the same circle on your other devices.
' +
'
Generate circle key Join with a key…
' +
- '
';
+ '';
+ }
+ el.innerHTML = '' + inner + lanSwitchHTML() + '
';
+ if (circleState.has_key) {
+ $("ck-copy").addEventListener("click", () => copyText(circleState.key || "", "Circle key copied"));
+ $("ck-leave").addEventListener("click", circleLeave);
+ } else {
$("ck-gen").addEventListener("click", circleGenerate);
$("ck-join-toggle").addEventListener("click", () => { $("ck-join").classList.toggle("show"); const i = $("ck-input"); if (i) i.focus(); });
$("ck-join-go").addEventListener("click", circleJoin);
$("ck-input").addEventListener("keydown", (e) => { if (e.key === "Enter") circleJoin(); });
}
+ const sw = $("lan-sw"); if (sw) sw.addEventListener("change", lanToggle);
}
async function circleGenerate() {
const b = $("ck-gen"); if (b) { b.disabled = true; b.textContent = "Generating…"; }
diff --git a/internal/cli/daemon.go b/internal/cli/daemon.go
index b580302..1d8e37c 100644
--- a/internal/cli/daemon.go
+++ b/internal/cli/daemon.go
@@ -34,6 +34,8 @@ func (a *App) runDaemon(args []string) int {
return rc
}
return a.runDaemonStart()
+ case "lan":
+ return a.runDaemonLan(args[1:])
case "--help", "-h", "help":
a.printDaemonUsage()
return 0
@@ -97,6 +99,48 @@ func (a *App) runDaemonURL() int {
return 0
}
+// runDaemonLan toggles LAN exposure of the shared daemon. `on` rebinds it
+// to 0.0.0.0 with --allow-lan + token auth (loopback stays token-free) so
+// circle peers can reach the read-only endpoints; `off` returns it to the
+// loopback default. Both restart the daemon so Ensure re-reads the marker;
+// the first --allow-lan boot installs the platform firewall rules.
+func (a *App) runDaemonLan(args []string) int {
+ sub := "status"
+ if len(args) > 0 {
+ sub = args[0]
+ }
+ switch sub {
+ case "on", "off":
+ on := sub == "on"
+ if err := daemon.SetLanExposure(on); err != nil {
+ fmt.Fprintf(a.Stderr, "clawtool daemon lan: %v\n", err)
+ return 1
+ }
+ if rc := a.runDaemonStop(); rc != 0 {
+ return rc
+ }
+ if rc := a.runDaemonStart(); rc != 0 {
+ return rc
+ }
+ if on {
+ fmt.Fprintln(a.Stdout, "✓ LAN exposure ON — reachable by circle peers on your network")
+ } else {
+ fmt.Fprintln(a.Stdout, "✓ LAN exposure OFF — daemon bound to loopback only")
+ }
+ return 0
+ case "status":
+ if daemon.LanExposureEnabled() {
+ fmt.Fprintln(a.Stdout, "on")
+ } else {
+ fmt.Fprintln(a.Stdout, "off")
+ }
+ return 0
+ default:
+ fmt.Fprintf(a.Stderr, "clawtool daemon lan: unknown subcommand %q (on|off|status)\n", sub)
+ return 2
+ }
+}
+
func (a *App) printDaemonUsage() {
fmt.Fprint(a.Stderr, `Usage: clawtool daemon
@@ -107,6 +151,9 @@ Subcommands:
status Report pid / port / health / token / log file.
path Print the state-file path.
url Print the daemon's MCP URL (http://127.0.0.1:/mcp).
+ lan
+ Expose the daemon to LAN circle peers (0.0.0.0 + token; loopback
+ stays token-free) or return it to loopback-only. Restarts the daemon.
The daemon is the single backend every host (Codex / OpenCode / Gemini /
Claude Code) fans into. One daemon = one BIAM identity = cross-host
diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go
index 874d232..53c9c39 100644
--- a/internal/daemon/daemon.go
+++ b/internal/daemon/daemon.go
@@ -103,6 +103,67 @@ func LogPath() string {
return filepath.Join(xdg.StateDir(), "daemon.log")
}
+// LanExposurePath is the marker file that opts this device's shared
+// daemon into LAN exposure. Present ⇒ Ensure binds all interfaces with
+// --allow-lan + token auth so circle peers can reach the read-only
+// endpoints; absent ⇒ the default 127.0.0.1 --no-auth loopback daemon
+// (the machine is the trust boundary). Same XDG conventions as StatePath.
+func LanExposurePath() string {
+ return filepath.Join(configDir(), "lan-exposure")
+}
+
+// LanExposureEnabled reports whether LAN exposure has been opted into.
+func LanExposureEnabled() bool {
+ _, err := os.Stat(LanExposurePath())
+ return err == nil
+}
+
+// SetLanExposure toggles the LAN-exposure marker. The caller restarts the
+// daemon afterwards so Ensure re-reads it and rebinds.
+func SetLanExposure(on bool) error {
+ p := LanExposurePath()
+ if !on {
+ if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ return nil
+ }
+ if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
+ return err
+ }
+ return os.WriteFile(p, []byte("1\n"), 0o600)
+}
+
+// daemonServeArgs builds the `serve` argv for the managed daemon.
+//
+// Default: the loopback, no-auth gateway — the machine is the trust
+// boundary and local consumers (codex / claude-code / gemini) reach
+// /mcp over 127.0.0.1 without a token.
+//
+// LAN-exposed: bind all interfaces with --allow-lan + a token file.
+// Security invariant — a non-loopback bind is NEVER --no-auth, so
+// code-executing endpoints stay bearer-gated and only the read-only
+// circle endpoints are reachable by peers (which present the circle
+// key). The first --allow-lan run also installs the platform firewall
+// rules via serve's auto-firewall hook.
+func daemonServeArgs(port int, lanExposed bool) []string {
+ if lanExposed {
+ return []string{
+ "serve",
+ "--listen", fmt.Sprintf("0.0.0.0:%d", port),
+ "--allow-lan",
+ "--token-file", TokenPath(),
+ "--mcp-http",
+ }
+ }
+ return []string{
+ "serve",
+ "--listen", fmt.Sprintf("127.0.0.1:%d", port),
+ "--no-auth",
+ "--mcp-http",
+ }
+}
+
// configDir delegates to the central xdg package so every callsite
// (daemon, secrets, a2a, telemetry, …) shares one fallback chain.
func configDir() string {
@@ -360,12 +421,18 @@ func EnsureFrom(ctx context.Context, exePath string) (*State, error) {
}
}
- cmd := exec.Command(self,
- "serve",
- "--listen", fmt.Sprintf("127.0.0.1:%d", port),
- "--no-auth",
- "--mcp-http",
- )
+ lanExposed := LanExposureEnabled()
+ if lanExposed {
+ // A non-loopback bind enforces a bearer token, so make sure one
+ // exists before serve starts (idempotent — leaves an existing
+ // token untouched).
+ if _, statErr := os.Stat(TokenPath()); statErr != nil {
+ gen := exec.Command(self, "serve", "init-token", TokenPath())
+ gen.Stdout, gen.Stderr = logFile, logFile
+ _ = gen.Run()
+ }
+ }
+ cmd := exec.Command(self, daemonServeArgs(port, lanExposed)...)
cmd.Stdout = logFile
cmd.Stderr = logFile
cmd.Stdin = nil
diff --git a/internal/daemon/lan_test.go b/internal/daemon/lan_test.go
new file mode 100644
index 0000000..073bb3f
--- /dev/null
+++ b/internal/daemon/lan_test.go
@@ -0,0 +1,66 @@
+package daemon
+
+import (
+ "slices"
+ "strings"
+ "testing"
+)
+
+// The managed daemon's bind/auth posture is a security invariant: the
+// loopback default is no-auth (machine is the trust boundary), and a
+// LAN-exposed bind MUST carry a token and MUST NOT be --no-auth, so
+// code-executing endpoints stay bearer-gated against the network.
+func TestDaemonServeArgs_SecurityInvariants(t *testing.T) {
+ loop := daemonServeArgs(8765, false)
+ if !slices.Contains(loop, "127.0.0.1:8765") {
+ t.Errorf("loopback default must bind 127.0.0.1; got %v", loop)
+ }
+ if !slices.Contains(loop, "--no-auth") {
+ t.Errorf("loopback default is no-auth; got %v", loop)
+ }
+ if slices.Contains(loop, "--allow-lan") {
+ t.Errorf("loopback default must NOT pass --allow-lan; got %v", loop)
+ }
+
+ lan := daemonServeArgs(8765, true)
+ if !slices.Contains(lan, "0.0.0.0:8765") {
+ t.Errorf("LAN bind must listen on all interfaces; got %v", lan)
+ }
+ if !slices.Contains(lan, "--allow-lan") {
+ t.Errorf("LAN bind must pass --allow-lan; got %v", lan)
+ }
+ if slices.Contains(lan, "--no-auth") {
+ t.Fatalf("SECURITY: a LAN bind must never be --no-auth; got %v", lan)
+ }
+ if !slices.Contains(lan, "--token-file") {
+ t.Fatalf("SECURITY: a LAN bind must enforce a token; got %v", lan)
+ }
+ // The token path must be the next arg after --token-file and look real.
+ i := slices.Index(lan, "--token-file")
+ if i+1 >= len(lan) || !strings.Contains(lan[i+1], "listener-token") {
+ t.Errorf("--token-file must point at the listener token; got %v", lan)
+ }
+}
+
+func TestLanExposureToggle(t *testing.T) {
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+ if LanExposureEnabled() {
+ t.Fatal("LAN exposure must default to off")
+ }
+ if err := SetLanExposure(true); err != nil {
+ t.Fatalf("enable: %v", err)
+ }
+ if !LanExposureEnabled() {
+ t.Fatal("expected LAN exposure enabled after SetLanExposure(true)")
+ }
+ if err := SetLanExposure(false); err != nil {
+ t.Fatalf("disable: %v", err)
+ }
+ if LanExposureEnabled() {
+ t.Fatal("expected LAN exposure off after SetLanExposure(false)")
+ }
+ // Disabling an already-absent marker is not an error.
+ if err := SetLanExposure(false); err != nil {
+ t.Fatalf("idempotent disable: %v", err)
+ }
+}
diff --git a/internal/server/biam_sse_test.go b/internal/server/biam_sse_test.go
index bba3be4..5e56316 100644
--- a/internal/server/biam_sse_test.go
+++ b/internal/server/biam_sse_test.go
@@ -29,7 +29,7 @@ func newSSETestServer(t *testing.T, token string) (*httptest.Server, *biam.Store
biam.Events.ResetForTest()
mux := http.NewServeMux()
- authed := authMiddleware(token)
+ authed := authMiddleware(token, false)
mux.Handle("/v1/biam/subscribe", authed(http.HandlerFunc(handleBIAMSubscribe)))
srv := httptest.NewServer(mux)
diff --git a/internal/server/circle_test.go b/internal/server/circle_test.go
index 4a4df93..68398db 100644
--- a/internal/server/circle_test.go
+++ b/internal/server/circle_test.go
@@ -14,7 +14,7 @@ import (
// given bearer token, mirroring the production wiring.
func circleMux(token string) *http.ServeMux {
mux := http.NewServeMux()
- mux.Handle("/v1/agents", circleOrBearer(token)(http.HandlerFunc(handleAgents)))
+ mux.Handle("/v1/agents", circleOrBearer(token, false)(http.HandlerFunc(handleAgents)))
return mux
}
diff --git a/internal/server/http.go b/internal/server/http.go
index c97df4d..c992529 100644
--- a/internal/server/http.go
+++ b/internal/server/http.go
@@ -145,7 +145,14 @@ func ServeHTTP(ctx context.Context, opts HTTPOptions) error {
}()
mux := http.NewServeMux()
- authed := authMiddleware(token)
+ // When the daemon is LAN-exposed (--allow-lan) it carries a token so
+ // the network can't reach code-exec endpoints. Local consumers on this
+ // machine, though, still connect over loopback without a token — the
+ // machine remains its own trust boundary. trustLoopback lets requests
+ // from 127.0.0.1/::1 through, so flipping on LAN exposure never forces a
+ // token onto codex / claude-code / gemini talking over loopback.
+ trustLoopback := opts.AllowLAN
+ authed := authMiddleware(token, trustLoopback)
mux.Handle("/v1/health", authed(http.HandlerFunc(handleHealth)))
// /v1/agents is read-only and circle-aware: a peer device in the same
@@ -153,7 +160,7 @@ func ServeHTTP(ctx context.Context, opts HTTPOptions) error {
// agents even without the bearer token. That's what lets a dashboard
// on one machine list the agents running on another. Code-executing
// endpoints below stay bearer-only.
- mux.Handle("/v1/agents", circleOrBearer(token)(http.HandlerFunc(handleAgents)))
+ mux.Handle("/v1/agents", circleOrBearer(token, trustLoopback)(http.HandlerFunc(handleAgents)))
mux.Handle("/v1/send_message", authed(http.HandlerFunc(handleSendMessage)))
mux.Handle("/v1/recipes", authed(http.HandlerFunc(handleRecipes)))
mux.Handle("/v1/recipe/apply", authed(http.HandlerFunc(handleRecipeApply)))
@@ -169,7 +176,7 @@ func ServeHTTP(ctx context.Context, opts HTTPOptions) error {
// first-contact pairing gate before any delivery (it enqueues
// into local agents' inboxes, not code execution — so circle
// auth + the pairing approval are the trust boundary).
- mux.Handle("/v1/relay", circleOrBearer(token)(http.HandlerFunc(handleRelay)))
+ mux.Handle("/v1/relay", circleOrBearer(token, trustLoopback)(http.HandlerFunc(handleRelay)))
// /v1/biam/subscribe — SSE A2A async-push (ADR-024 Phase 4).
// task-scoped, with Last-Event-ID replay against the per-task
// ring buffer in internal/agents/biam.Events.
@@ -407,13 +414,19 @@ func InitTokenFile(path string) (string, error) {
// shared local daemon's no-auth single-user mode (ServeHTTP only
// calls this with "" when opts.NoAuth is set + the loud stderr
// warning was already printed).
-func authMiddleware(expected string) func(http.Handler) http.Handler {
+func authMiddleware(expected string, trustLoopback bool) func(http.Handler) http.Handler {
exp := []byte(expected)
return func(next http.Handler) http.Handler {
if len(exp) == 0 {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Loopback is the machine's own trust boundary: local consumers
+ // reach the LAN-exposed daemon over 127.0.0.1 without a token.
+ if trustLoopback && isLoopbackRequest(r) {
+ next.ServeHTTP(w, r)
+ return
+ }
h := r.Header.Get("Authorization")
const prefix = "Bearer "
if !strings.HasPrefix(h, prefix) {
@@ -445,7 +458,7 @@ func authMiddleware(expected string) func(http.Handler) http.Handler {
// NEVER wrap a code-executing endpoint (send_message, mcp) with this —
// the circle key is deliberately scoped to read-only agent/peer
// enumeration. Those stay bearer-only.
-func circleOrBearer(token string) func(http.Handler) http.Handler {
+func circleOrBearer(token string, trustLoopback bool) func(http.Handler) http.Handler {
exp := []byte(token)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -455,6 +468,12 @@ func circleOrBearer(token string) func(http.Handler) http.Handler {
next.ServeHTTP(w, r)
return
}
+ // Loopback bypass on a LAN-exposed daemon — same machine-is-the-
+ // trust-boundary rule as authMiddleware.
+ if trustLoopback && isLoopbackRequest(r) {
+ next.ServeHTTP(w, r)
+ return
+ }
// 1. Bearer token.
h := r.Header.Get("Authorization")
const prefix = "Bearer "
@@ -818,3 +837,20 @@ func IsLoopbackAddress(addr string) bool {
}
return ip.IsLoopback()
}
+
+// isLoopbackRequest reports whether an HTTP request originated from this
+// machine (RemoteAddr is a loopback IP). Used by the auth middlewares to
+// keep loopback callers token-free even when the daemon is LAN-exposed —
+// the machine is still its own trust boundary. A malformed or missing
+// RemoteAddr is treated as non-loopback (fail closed).
+func isLoopbackRequest(r *http.Request) bool {
+ if r == nil {
+ return false
+ }
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ host = r.RemoteAddr // some transports set a bare host
+ }
+ ip := net.ParseIP(strings.TrimSpace(host))
+ return ip != nil && ip.IsLoopback()
+}
diff --git a/internal/server/http_test.go b/internal/server/http_test.go
index 681ba6c..2600051 100644
--- a/internal/server/http_test.go
+++ b/internal/server/http_test.go
@@ -17,7 +17,7 @@ import (
// token + httptest server so they're independent.
func newTestMux(token string) *http.ServeMux {
mux := http.NewServeMux()
- authed := authMiddleware(token)
+ authed := authMiddleware(token, false)
mux.Handle("/v1/health", authed(http.HandlerFunc(handleHealth)))
mux.Handle("/v1/agents", authed(http.HandlerFunc(handleAgents)))
mux.Handle("/v1/send_message", authed(http.HandlerFunc(handleSendMessage)))
@@ -323,7 +323,7 @@ func TestServeHTTP_RefusesEmptyTokenFile(t *testing.T) {
// other tests.
func newRecipeMux(token string) *http.ServeMux {
mux := http.NewServeMux()
- authed := authMiddleware(token)
+ authed := authMiddleware(token, false)
mux.Handle("/v1/recipes", authed(http.HandlerFunc(handleRecipes)))
mux.Handle("/v1/recipe/apply", authed(http.HandlerFunc(handleRecipeApply)))
return mux
diff --git a/internal/server/loopback_trust_test.go b/internal/server/loopback_trust_test.go
new file mode 100644
index 0000000..50d4298
--- /dev/null
+++ b/internal/server/loopback_trust_test.go
@@ -0,0 +1,79 @@
+package server
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func okHandler() http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
+}
+
+func reqFrom(remote string, header map[string]string) *http.Request {
+ r := httptest.NewRequest(http.MethodGet, "/v1/agents", nil)
+ r.RemoteAddr = remote
+ for k, v := range header {
+ r.Header.Set(k, v)
+ }
+ return r
+}
+
+// On a LAN-exposed daemon (token set, trustLoopback=true), local callers
+// over loopback must pass without a token, while LAN callers must not.
+func TestTrustLoopback_AuthMiddleware(t *testing.T) {
+ h := authMiddleware("secret", true)(okHandler())
+
+ cases := []struct {
+ name string
+ remote string
+ bearer string
+ want int
+ }{
+ {"loopback v4 no token", "127.0.0.1:5000", "", http.StatusOK},
+ {"loopback v6 no token", "[::1]:5000", "", http.StatusOK},
+ {"LAN no token", "192.168.1.50:5000", "", http.StatusUnauthorized},
+ {"LAN wrong token", "192.168.1.50:5000", "nope", http.StatusUnauthorized},
+ {"LAN correct token", "192.168.1.50:5000", "secret", http.StatusOK},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ hdr := map[string]string{}
+ if c.bearer != "" {
+ hdr["Authorization"] = "Bearer " + c.bearer
+ }
+ rw := httptest.NewRecorder()
+ h.ServeHTTP(rw, reqFrom(c.remote, hdr))
+ if rw.Code != c.want {
+ t.Fatalf("%s: got %d, want %d", c.name, rw.Code, c.want)
+ }
+ })
+ }
+}
+
+// Without trustLoopback (relay / explicit token-only deployments), even
+// loopback callers must present the token — the prior behaviour is intact.
+func TestNoTrustLoopback_StillRequiresToken(t *testing.T) {
+ h := authMiddleware("secret", false)(okHandler())
+ rw := httptest.NewRecorder()
+ h.ServeHTTP(rw, reqFrom("127.0.0.1:5000", nil))
+ if rw.Code != http.StatusUnauthorized {
+ t.Fatalf("loopback without trust must require a token; got %d", rw.Code)
+ }
+}
+
+func TestIsLoopbackRequest(t *testing.T) {
+ cases := map[string]bool{
+ "127.0.0.1:80": true,
+ "[::1]:443": true,
+ "192.168.1.2:80": false,
+ "10.0.0.1:9": false,
+ "garbage": false,
+ "": false,
+ }
+ for remote, want := range cases {
+ if got := isLoopbackRequest(reqFrom(remote, nil)); got != want {
+ t.Errorf("isLoopbackRequest(%q) = %v, want %v", remote, got, want)
+ }
+ }
+}
diff --git a/internal/server/peers_handler_test.go b/internal/server/peers_handler_test.go
index d5a0ed2..a7beae4 100644
--- a/internal/server/peers_handler_test.go
+++ b/internal/server/peers_handler_test.go
@@ -23,7 +23,7 @@ func newPeersTestMux(t *testing.T, token string) (*http.ServeMux, *a2a.Registry,
reg := a2a.NewRegistry(filepath.Join(t.TempDir(), "peers.json"))
a2a.SetGlobal(reg)
mux := http.NewServeMux()
- authed := authMiddleware(token)
+ authed := authMiddleware(token, false)
mux.Handle("/v1/peers", authed(http.HandlerFunc(handlePeers)))
mux.Handle("/v1/peers/", authed(http.HandlerFunc(handlePeers)))
cleanup := func() {
@@ -68,7 +68,7 @@ func TestPeers_503WhenRegistryNotInstalled(t *testing.T) {
defer a2a.SetGlobal(prev)
mux := http.NewServeMux()
- authed := authMiddleware("tok")
+ authed := authMiddleware("tok", false)
mux.Handle("/v1/peers", authed(http.HandlerFunc(handlePeers)))
srv := httptest.NewServer(mux)
defer srv.Close()