Compare commits
2 Commits
main
...
fix/mdns-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15d4738fb5 | ||
|
|
25f32a1778 |
@ -36,6 +36,7 @@ Status: unreleased.
|
||||
|
||||
### Fixes
|
||||
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
|
||||
- Security: add mDNS discovery mode with minimal default to reduce information disclosure. (#1882) Thanks @orlyjamie.
|
||||
- Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93.
|
||||
- Gateway: default auth now fail-closed (token/password required; Tailscale Serve identity remains allowed).
|
||||
|
||||
|
||||
@ -3175,6 +3175,20 @@ Auto-generated certs require `openssl` on PATH; if generation fails, the bridge
|
||||
}
|
||||
```
|
||||
|
||||
### `discovery.mdns` (Bonjour / mDNS broadcast mode)
|
||||
|
||||
Controls LAN mDNS discovery broadcasts (`_clawdbot-gw._tcp`).
|
||||
|
||||
- `minimal` (default): omit `cliPath` + `sshPort` from TXT records
|
||||
- `full`: include `cliPath` + `sshPort` in TXT records
|
||||
- `off`: disable mDNS broadcasts entirely
|
||||
|
||||
```json5
|
||||
{
|
||||
discovery: { mdns: { mode: "minimal" } }
|
||||
}
|
||||
```
|
||||
|
||||
### `discovery.wideArea` (Wide-Area Bonjour / unicast DNS‑SD)
|
||||
|
||||
When enabled, the Gateway writes a unicast DNS-SD zone for `_clawdbot-bridge._tcp` under `~/.clawdbot/dns/` using the standard discovery domain `clawdbot.internal.`
|
||||
|
||||
@ -287,6 +287,49 @@ Rules of thumb:
|
||||
- If you must bind to LAN, firewall the port to a tight allowlist of source IPs; do not port-forward it broadly.
|
||||
- Never expose the Gateway unauthenticated on `0.0.0.0`.
|
||||
|
||||
### 0.4.1) mDNS/Bonjour discovery (information disclosure)
|
||||
|
||||
The Gateway broadcasts its presence via mDNS (`_clawdbot-gw._tcp` on port 5353) for local device discovery. In full mode, this includes TXT records that may expose operational details:
|
||||
|
||||
- `cliPath`: full filesystem path to the CLI binary (reveals username and install location)
|
||||
- `sshPort`: advertises SSH availability on the host
|
||||
- `displayName`, `lanHost`: hostname information
|
||||
|
||||
**Operational security consideration:** Broadcasting infrastructure details makes reconnaissance easier for anyone on the local network. Even "harmless" info like filesystem paths and SSH availability helps attackers map your environment.
|
||||
|
||||
**Recommendations:**
|
||||
|
||||
1. **Minimal mode** (default, recommended for exposed gateways): omit sensitive fields from mDNS broadcasts:
|
||||
```json5
|
||||
{
|
||||
discovery: {
|
||||
mdns: { mode: "minimal" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Disable entirely** if you don't need local device discovery:
|
||||
```json5
|
||||
{
|
||||
discovery: {
|
||||
mdns: { mode: "off" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Full mode** (opt-in): include `cliPath` + `sshPort` in TXT records:
|
||||
```json5
|
||||
{
|
||||
discovery: {
|
||||
mdns: { mode: "full" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Environment variable** (alternative): set `CLAWDBOT_DISABLE_BONJOUR=1` to disable mDNS without config changes.
|
||||
|
||||
In minimal mode, the Gateway still broadcasts enough for device discovery (`role`, `gatewayPort`, `transport`) but omits `cliPath` and `sshPort`. Apps that need CLI path information can fetch it via the authenticated WebSocket connection instead.
|
||||
|
||||
### 0.5) Lock down the Gateway WebSocket (local auth)
|
||||
|
||||
Gateway auth is **required by default**. If no token/password is configured,
|
||||
|
||||
@ -338,6 +338,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"channels.signal.account": "Signal Account",
|
||||
"channels.imessage.cliPath": "iMessage CLI Path",
|
||||
"agents.list[].identity.avatar": "Agent Avatar",
|
||||
"discovery.mdns.mode": "mDNS Discovery Mode",
|
||||
"plugins.enabled": "Enable Plugins",
|
||||
"plugins.allow": "Plugin Allowlist",
|
||||
"plugins.deny": "Plugin Denylist",
|
||||
@ -369,6 +370,8 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).",
|
||||
"agents.list[].identity.avatar":
|
||||
"Avatar image path (relative to the agent workspace only) or a remote URL/data URL.",
|
||||
"discovery.mdns.mode":
|
||||
'mDNS broadcast mode ("minimal" default, "full" includes cliPath/sshPort, "off" disables mDNS).',
|
||||
"gateway.auth.token":
|
||||
"Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.",
|
||||
"gateway.auth.password": "Required for Tailscale funnel.",
|
||||
|
||||
@ -17,8 +17,21 @@ export type WideAreaDiscoveryConfig = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type MdnsDiscoveryMode = "off" | "minimal" | "full";
|
||||
|
||||
export type MdnsDiscoveryConfig = {
|
||||
/**
|
||||
* mDNS/Bonjour discovery broadcast mode (default: minimal).
|
||||
* - off: disable mDNS entirely
|
||||
* - minimal: omit cliPath/sshPort from TXT records
|
||||
* - full: include cliPath/sshPort in TXT records
|
||||
*/
|
||||
mode?: MdnsDiscoveryMode;
|
||||
};
|
||||
|
||||
export type DiscoveryConfig = {
|
||||
wideArea?: WideAreaDiscoveryConfig;
|
||||
mdns?: MdnsDiscoveryConfig;
|
||||
};
|
||||
|
||||
export type CanvasHostConfig = {
|
||||
|
||||
@ -272,6 +272,12 @@ export const ClawdbotSchema = z
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
mdns: z
|
||||
.object({
|
||||
mode: z.enum(["off", "minimal", "full"]).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
@ -14,36 +14,46 @@ export async function startGatewayDiscovery(params: {
|
||||
canvasPort?: number;
|
||||
wideAreaDiscoveryEnabled: boolean;
|
||||
tailscaleMode: "off" | "serve" | "funnel";
|
||||
/** mDNS/Bonjour discovery mode (default: minimal). */
|
||||
mdnsMode?: "off" | "minimal" | "full";
|
||||
logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||
}) {
|
||||
let bonjourStop: (() => Promise<void>) | null = null;
|
||||
const mdnsMode = params.mdnsMode ?? "minimal";
|
||||
// mDNS can be disabled via config (mdnsMode: off) or env var.
|
||||
const bonjourEnabled =
|
||||
mdnsMode !== "off" &&
|
||||
process.env.CLAWDBOT_DISABLE_BONJOUR !== "1" &&
|
||||
process.env.NODE_ENV !== "test" &&
|
||||
!process.env.VITEST;
|
||||
const mdnsMinimal = mdnsMode !== "full";
|
||||
const tailscaleEnabled = params.tailscaleMode !== "off";
|
||||
const needsTailnetDns = bonjourEnabled || params.wideAreaDiscoveryEnabled;
|
||||
const tailnetDns = needsTailnetDns
|
||||
? await resolveTailnetDnsHint({ enabled: tailscaleEnabled })
|
||||
: undefined;
|
||||
const sshPortEnv = process.env.CLAWDBOT_SSH_PORT?.trim();
|
||||
const sshPortEnv = mdnsMinimal ? undefined : process.env.CLAWDBOT_SSH_PORT?.trim();
|
||||
const sshPortParsed = sshPortEnv ? Number.parseInt(sshPortEnv, 10) : NaN;
|
||||
const sshPort = Number.isFinite(sshPortParsed) && sshPortParsed > 0 ? sshPortParsed : undefined;
|
||||
const cliPath = mdnsMinimal ? undefined : resolveBonjourCliPath();
|
||||
|
||||
try {
|
||||
const bonjour = await startGatewayBonjourAdvertiser({
|
||||
instanceName: formatBonjourInstanceName(params.machineDisplayName),
|
||||
gatewayPort: params.port,
|
||||
gatewayTlsEnabled: params.gatewayTls?.enabled ?? false,
|
||||
gatewayTlsFingerprintSha256: params.gatewayTls?.fingerprintSha256,
|
||||
canvasPort: params.canvasPort,
|
||||
sshPort,
|
||||
tailnetDns,
|
||||
cliPath: resolveBonjourCliPath(),
|
||||
});
|
||||
bonjourStop = bonjour.stop;
|
||||
} catch (err) {
|
||||
params.logDiscovery.warn(`bonjour advertising failed: ${String(err)}`);
|
||||
if (bonjourEnabled) {
|
||||
try {
|
||||
const bonjour = await startGatewayBonjourAdvertiser({
|
||||
instanceName: formatBonjourInstanceName(params.machineDisplayName),
|
||||
gatewayPort: params.port,
|
||||
gatewayTlsEnabled: params.gatewayTls?.enabled ?? false,
|
||||
gatewayTlsFingerprintSha256: params.gatewayTls?.fingerprintSha256,
|
||||
canvasPort: params.canvasPort,
|
||||
sshPort,
|
||||
tailnetDns,
|
||||
cliPath,
|
||||
minimal: mdnsMinimal,
|
||||
});
|
||||
bonjourStop = bonjour.stop;
|
||||
} catch (err) {
|
||||
params.logDiscovery.warn(`bonjour advertising failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (params.wideAreaDiscoveryEnabled) {
|
||||
|
||||
@ -352,6 +352,7 @@ export async function startGatewayServer(
|
||||
: undefined,
|
||||
wideAreaDiscoveryEnabled: cfgAtStart.discovery?.wideArea?.enabled === true,
|
||||
tailscaleMode,
|
||||
mdnsMode: cfgAtStart.discovery?.mdns?.mode,
|
||||
logDiscovery,
|
||||
});
|
||||
bonjourStop = discovery.bonjourStop;
|
||||
|
||||
@ -138,6 +138,42 @@ describe("gateway bonjour advertiser", () => {
|
||||
expect(shutdown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("omits cliPath and sshPort in minimal mode", async () => {
|
||||
// Allow advertiser to run in unit tests.
|
||||
delete process.env.VITEST;
|
||||
process.env.NODE_ENV = "development";
|
||||
|
||||
vi.spyOn(os, "hostname").mockReturnValue("test-host");
|
||||
|
||||
const destroy = vi.fn().mockResolvedValue(undefined);
|
||||
const advertise = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
createService.mockImplementation((options: Record<string, unknown>) => {
|
||||
return {
|
||||
advertise,
|
||||
destroy,
|
||||
serviceState: "announced",
|
||||
on: vi.fn(),
|
||||
getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`,
|
||||
getHostname: () => asString(options.hostname, "unknown"),
|
||||
getPort: () => Number(options.port ?? -1),
|
||||
};
|
||||
});
|
||||
|
||||
const started = await startGatewayBonjourAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
cliPath: "/opt/homebrew/bin/clawdbot",
|
||||
minimal: true,
|
||||
});
|
||||
|
||||
const [gatewayCall] = createService.mock.calls as Array<[Record<string, unknown>]>;
|
||||
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.sshPort).toBeUndefined();
|
||||
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.cliPath).toBeUndefined();
|
||||
|
||||
await started.stop();
|
||||
});
|
||||
|
||||
it("attaches conflict listeners for services", async () => {
|
||||
// Allow advertiser to run in unit tests.
|
||||
delete process.env.VITEST;
|
||||
|
||||
@ -20,6 +20,11 @@ export type GatewayBonjourAdvertiseOpts = {
|
||||
canvasPort?: number;
|
||||
tailnetDns?: string;
|
||||
cliPath?: string;
|
||||
/**
|
||||
* Minimal mode - omit sensitive fields (cliPath, sshPort) from TXT records.
|
||||
* Reduces information disclosure for better operational security.
|
||||
*/
|
||||
minimal?: boolean;
|
||||
};
|
||||
|
||||
function isDisabledByEnv() {
|
||||
@ -115,12 +120,24 @@ export async function startGatewayBonjourAdvertiser(
|
||||
if (typeof opts.tailnetDns === "string" && opts.tailnetDns.trim()) {
|
||||
txtBase.tailnetDns = opts.tailnetDns.trim();
|
||||
}
|
||||
if (typeof opts.cliPath === "string" && opts.cliPath.trim()) {
|
||||
// In minimal mode, omit cliPath to avoid exposing filesystem structure.
|
||||
// This info can be obtained via the authenticated WebSocket if needed.
|
||||
if (!opts.minimal && typeof opts.cliPath === "string" && opts.cliPath.trim()) {
|
||||
txtBase.cliPath = opts.cliPath.trim();
|
||||
}
|
||||
|
||||
const services: Array<{ label: string; svc: BonjourService }> = [];
|
||||
|
||||
// Build TXT record for the gateway service.
|
||||
// In minimal mode, omit sshPort to avoid advertising SSH availability.
|
||||
const gatewayTxt: Record<string, string> = {
|
||||
...txtBase,
|
||||
transport: "gateway",
|
||||
};
|
||||
if (!opts.minimal) {
|
||||
gatewayTxt.sshPort = String(opts.sshPort ?? 22);
|
||||
}
|
||||
|
||||
const gateway = responder.createService({
|
||||
name: safeServiceName(instanceName),
|
||||
type: "clawdbot-gw",
|
||||
@ -128,11 +145,7 @@ export async function startGatewayBonjourAdvertiser(
|
||||
port: opts.gatewayPort,
|
||||
domain: "local",
|
||||
hostname,
|
||||
txt: {
|
||||
...txtBase,
|
||||
sshPort: String(opts.sshPort ?? 22),
|
||||
transport: "gateway",
|
||||
},
|
||||
txt: gatewayTxt,
|
||||
});
|
||||
services.push({
|
||||
label: "gateway",
|
||||
@ -149,7 +162,7 @@ export async function startGatewayBonjourAdvertiser(
|
||||
logDebug(
|
||||
`bonjour: starting (hostname=${hostname}, instance=${JSON.stringify(
|
||||
safeServiceName(instanceName),
|
||||
)}, gatewayPort=${opts.gatewayPort}, sshPort=${opts.sshPort ?? 22})`,
|
||||
)}, gatewayPort=${opts.gatewayPort}${opts.minimal ? ", minimal=true" : `, sshPort=${opts.sshPort ?? 22}`})`,
|
||||
);
|
||||
|
||||
for (const { label, svc } of services) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user