diff --git a/docs/bonjour.md b/docs/bonjour.md index b941e6a20..a36b2f2e8 100644 --- a/docs/bonjour.md +++ b/docs/bonjour.md @@ -71,7 +71,7 @@ For a tailnet-only setup, bind it to the Tailscale IP instead: - Set `bridge.bind: "tailnet"` in `~/.clawdis/clawdis.json`. - Restart the Gateway (or restart the macOS menubar app via `./scripts/restart-mac.sh` on that machine). -This keeps the bridge reachable only from devices on your tailnet (unless you intentionally expose it some other way). +This keeps the bridge reachable only from devices on your tailnet (while still listening on loopback for local/SSH port-forwards). ## What advertises diff --git a/src/config/config.ts b/src/config/config.ts index 9c4cfbe21..8d2a99f7c 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -165,7 +165,7 @@ export type BridgeConfig = { * Bind address policy for the node bridge server. * - auto: prefer tailnet IP when present, else LAN (0.0.0.0) * - lan: 0.0.0.0 (reachable on local network + any forwarded interfaces) - * - tailnet: bind only to the Tailscale interface IP (100.64.0.0/10) + * - tailnet: bind to the Tailscale interface IP (100.64.0.0/10) plus loopback * - loopback: 127.0.0.1 */ bind?: BridgeBindMode; diff --git a/src/infra/bridge/server.test.ts b/src/infra/bridge/server.test.ts index e9ad67996..8125db1ee 100644 --- a/src/infra/bridge/server.test.ts +++ b/src/infra/bridge/server.test.ts @@ -49,6 +49,17 @@ function sendLine(socket: net.Socket, obj: unknown) { describe("node bridge server", () => { let baseDir = ""; + const pickNonLoopbackIPv4 = () => { + const ifaces = os.networkInterfaces(); + for (const entries of Object.values(ifaces)) { + for (const info of entries ?? []) { + if (info.family === "IPv4" && info.internal === false) + return info.address; + } + } + return null; + }; + beforeAll(async () => { process.env.CLAWDIS_ENABLE_BRIDGE_IN_TESTS = "1"; baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-bridge-test-")); @@ -77,6 +88,31 @@ describe("node bridge server", () => { await server.close(); }); + it("also listens on loopback when bound to a non-loopback host", async () => { + const host = pickNonLoopbackIPv4(); + if (!host) return; + + const server = await startNodeBridgeServer({ + host, + port: 0, + pairingBaseDir: baseDir, + }); + + const socket = net.connect({ host: "127.0.0.1", port: server.port }); + await new Promise((resolve, reject) => { + socket.once("connect", resolve); + socket.once("error", reject); + }); + const readLine = createLineReader(socket); + sendLine(socket, { type: "hello", nodeId: "n-loopback" }); + const line = await readLine(); + const msg = JSON.parse(line) as { type: string; code?: string }; + expect(msg.type).toBe("error"); + expect(msg.code).toBe("NOT_PAIRED"); + socket.destroy(); + await server.close(); + }); + it("pairs after approval and then accepts hello", async () => { const server = await startNodeBridgeServer({ host: "127.0.0.1", diff --git a/src/infra/bridge/server.ts b/src/infra/bridge/server.ts index 2e84b6bec..2cd1f0b53 100644 --- a/src/infra/bridge/server.ts +++ b/src/infra/bridge/server.ts @@ -209,7 +209,21 @@ export async function startNodeBridgeServer( const connections = new Map(); - const server = net.createServer((socket) => { + const shouldAlsoListenOnLoopback = (host: string | undefined) => { + const h = String(host ?? "") + .trim() + .toLowerCase(); + if (!h) return false; // default listen() already includes loopback + if (h === "0.0.0.0" || h === "::") return false; // already includes loopback + if (h === "localhost") return false; + if (h === "127.0.0.1" || h.startsWith("127.")) return false; + if (h === "::1") return false; + return true; + }; + + const loopbackHost = "127.0.0.1"; + + const onConnection = (socket: net.Socket) => { socket.setNoDelay(true); let buffer = ""; @@ -656,17 +670,45 @@ export async function startNodeBridgeServer( socket.on("error", () => { // close handler will run after close }); - }); + }; + const servers: net.Server[] = []; + const primary = net.createServer(onConnection); await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(opts.port, opts.host, () => resolve()); + const onError = (err: Error) => reject(err); + primary.once("error", onError); + primary.listen(opts.port, opts.host, () => { + primary.off("error", onError); + resolve(); + }); }); + servers.push(primary); - const address = server.address(); + const address = primary.address(); const port = typeof address === "object" && address ? address.port : opts.port; + if (shouldAlsoListenOnLoopback(opts.host)) { + const loopback = net.createServer(onConnection); + try { + await new Promise((resolve, reject) => { + const onError = (err: Error) => reject(err); + loopback.once("error", onError); + loopback.listen(port, loopbackHost, () => { + loopback.off("error", onError); + resolve(); + }); + }); + servers.push(loopback); + } catch { + try { + loopback.close(); + } catch { + /* ignore */ + } + } + } + return { port, close: async () => { @@ -678,8 +720,13 @@ export async function startNodeBridgeServer( } } connections.clear(); - await new Promise((resolve, reject) => - server.close((err) => (err ? reject(err) : resolve())), + await Promise.all( + servers.map( + (s) => + new Promise((resolve, reject) => + s.close((err) => (err ? reject(err) : resolve())), + ), + ), ); }, listConnected: () => [...connections.values()].map((c) => c.nodeInfo),