fix: bridge tailnet bind also listens on loopback

This commit is contained in:
Peter Steinberger
2025-12-25 01:37:47 +00:00
parent dc93350e0a
commit 81e11c1d91
4 changed files with 92 additions and 9 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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<void>((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",

View File

@@ -209,7 +209,21 @@ export async function startNodeBridgeServer(
const connections = new Map<string, ConnectionState>();
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<void>((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<void>((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<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())),
await Promise.all(
servers.map(
(s) =>
new Promise<void>((resolve, reject) =>
s.close((err) => (err ? reject(err) : resolve())),
),
),
);
},
listConnected: () => [...connections.values()].map((c) => c.nodeInfo),