fix: bridge tailnet bind also listens on loopback
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user