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`.
|
- 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).
|
- 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
|
## What advertises
|
||||||
|
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export type BridgeConfig = {
|
|||||||
* Bind address policy for the node bridge server.
|
* Bind address policy for the node bridge server.
|
||||||
* - auto: prefer tailnet IP when present, else LAN (0.0.0.0)
|
* - auto: prefer tailnet IP when present, else LAN (0.0.0.0)
|
||||||
* - lan: 0.0.0.0 (reachable on local network + any forwarded interfaces)
|
* - 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
|
* - loopback: 127.0.0.1
|
||||||
*/
|
*/
|
||||||
bind?: BridgeBindMode;
|
bind?: BridgeBindMode;
|
||||||
|
|||||||
@@ -49,6 +49,17 @@ function sendLine(socket: net.Socket, obj: unknown) {
|
|||||||
describe("node bridge server", () => {
|
describe("node bridge server", () => {
|
||||||
let baseDir = "";
|
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 () => {
|
beforeAll(async () => {
|
||||||
process.env.CLAWDIS_ENABLE_BRIDGE_IN_TESTS = "1";
|
process.env.CLAWDIS_ENABLE_BRIDGE_IN_TESTS = "1";
|
||||||
baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-bridge-test-"));
|
baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-bridge-test-"));
|
||||||
@@ -77,6 +88,31 @@ describe("node bridge server", () => {
|
|||||||
await server.close();
|
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 () => {
|
it("pairs after approval and then accepts hello", async () => {
|
||||||
const server = await startNodeBridgeServer({
|
const server = await startNodeBridgeServer({
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
|
|||||||
@@ -209,7 +209,21 @@ export async function startNodeBridgeServer(
|
|||||||
|
|
||||||
const connections = new Map<string, ConnectionState>();
|
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);
|
socket.setNoDelay(true);
|
||||||
|
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
@@ -656,17 +670,45 @@ export async function startNodeBridgeServer(
|
|||||||
socket.on("error", () => {
|
socket.on("error", () => {
|
||||||
// close handler will run after close
|
// close handler will run after close
|
||||||
});
|
});
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const servers: net.Server[] = [];
|
||||||
|
const primary = net.createServer(onConnection);
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
server.once("error", reject);
|
const onError = (err: Error) => reject(err);
|
||||||
server.listen(opts.port, opts.host, () => resolve());
|
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 =
|
const port =
|
||||||
typeof address === "object" && address ? address.port : opts.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 {
|
return {
|
||||||
port,
|
port,
|
||||||
close: async () => {
|
close: async () => {
|
||||||
@@ -678,8 +720,13 @@ export async function startNodeBridgeServer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
connections.clear();
|
connections.clear();
|
||||||
await new Promise<void>((resolve, reject) =>
|
await Promise.all(
|
||||||
server.close((err) => (err ? reject(err) : resolve())),
|
servers.map(
|
||||||
|
(s) =>
|
||||||
|
new Promise<void>((resolve, reject) =>
|
||||||
|
s.close((err) => (err ? reject(err) : resolve())),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
listConnected: () => [...connections.values()].map((c) => c.nodeInfo),
|
listConnected: () => [...connections.values()].map((c) => c.nodeInfo),
|
||||||
|
|||||||
Reference in New Issue
Block a user