Compare commits
2 Commits
main
...
fix/mdns-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15d4738fb5 | ||
|
|
25f32a1778 |
@ -36,6 +36,7 @@ Status: unreleased.
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
|
- 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.
|
- 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).
|
- 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)
|
### `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.`
|
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.
|
- 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`.
|
- 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)
|
### 0.5) Lock down the Gateway WebSocket (local auth)
|
||||||
|
|
||||||
Gateway auth is **required by default**. If no token/password is configured,
|
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.signal.account": "Signal Account",
|
||||||
"channels.imessage.cliPath": "iMessage CLI Path",
|
"channels.imessage.cliPath": "iMessage CLI Path",
|
||||||
"agents.list[].identity.avatar": "Agent Avatar",
|
"agents.list[].identity.avatar": "Agent Avatar",
|
||||||
|
"discovery.mdns.mode": "mDNS Discovery Mode",
|
||||||
"plugins.enabled": "Enable Plugins",
|
"plugins.enabled": "Enable Plugins",
|
||||||
"plugins.allow": "Plugin Allowlist",
|
"plugins.allow": "Plugin Allowlist",
|
||||||
"plugins.deny": "Plugin Denylist",
|
"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).",
|
"gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).",
|
||||||
"agents.list[].identity.avatar":
|
"agents.list[].identity.avatar":
|
||||||
"Avatar image path (relative to the agent workspace only) or a remote URL/data URL.",
|
"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":
|
"gateway.auth.token":
|
||||||
"Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.",
|
"Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.",
|
||||||
"gateway.auth.password": "Required for Tailscale funnel.",
|
"gateway.auth.password": "Required for Tailscale funnel.",
|
||||||
|
|||||||
@ -17,8 +17,21 @@ export type WideAreaDiscoveryConfig = {
|
|||||||
enabled?: boolean;
|
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 = {
|
export type DiscoveryConfig = {
|
||||||
wideArea?: WideAreaDiscoveryConfig;
|
wideArea?: WideAreaDiscoveryConfig;
|
||||||
|
mdns?: MdnsDiscoveryConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CanvasHostConfig = {
|
export type CanvasHostConfig = {
|
||||||
|
|||||||
@ -272,6 +272,12 @@ export const ClawdbotSchema = z
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
mdns: z
|
||||||
|
.object({
|
||||||
|
mode: z.enum(["off", "minimal", "full"]).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@ -14,36 +14,46 @@ export async function startGatewayDiscovery(params: {
|
|||||||
canvasPort?: number;
|
canvasPort?: number;
|
||||||
wideAreaDiscoveryEnabled: boolean;
|
wideAreaDiscoveryEnabled: boolean;
|
||||||
tailscaleMode: "off" | "serve" | "funnel";
|
tailscaleMode: "off" | "serve" | "funnel";
|
||||||
|
/** mDNS/Bonjour discovery mode (default: minimal). */
|
||||||
|
mdnsMode?: "off" | "minimal" | "full";
|
||||||
logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void };
|
logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||||
}) {
|
}) {
|
||||||
let bonjourStop: (() => Promise<void>) | null = null;
|
let bonjourStop: (() => Promise<void>) | null = null;
|
||||||
|
const mdnsMode = params.mdnsMode ?? "minimal";
|
||||||
|
// mDNS can be disabled via config (mdnsMode: off) or env var.
|
||||||
const bonjourEnabled =
|
const bonjourEnabled =
|
||||||
|
mdnsMode !== "off" &&
|
||||||
process.env.CLAWDBOT_DISABLE_BONJOUR !== "1" &&
|
process.env.CLAWDBOT_DISABLE_BONJOUR !== "1" &&
|
||||||
process.env.NODE_ENV !== "test" &&
|
process.env.NODE_ENV !== "test" &&
|
||||||
!process.env.VITEST;
|
!process.env.VITEST;
|
||||||
|
const mdnsMinimal = mdnsMode !== "full";
|
||||||
const tailscaleEnabled = params.tailscaleMode !== "off";
|
const tailscaleEnabled = params.tailscaleMode !== "off";
|
||||||
const needsTailnetDns = bonjourEnabled || params.wideAreaDiscoveryEnabled;
|
const needsTailnetDns = bonjourEnabled || params.wideAreaDiscoveryEnabled;
|
||||||
const tailnetDns = needsTailnetDns
|
const tailnetDns = needsTailnetDns
|
||||||
? await resolveTailnetDnsHint({ enabled: tailscaleEnabled })
|
? await resolveTailnetDnsHint({ enabled: tailscaleEnabled })
|
||||||
: undefined;
|
: 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 sshPortParsed = sshPortEnv ? Number.parseInt(sshPortEnv, 10) : NaN;
|
||||||
const sshPort = Number.isFinite(sshPortParsed) && sshPortParsed > 0 ? sshPortParsed : undefined;
|
const sshPort = Number.isFinite(sshPortParsed) && sshPortParsed > 0 ? sshPortParsed : undefined;
|
||||||
|
const cliPath = mdnsMinimal ? undefined : resolveBonjourCliPath();
|
||||||
|
|
||||||
try {
|
if (bonjourEnabled) {
|
||||||
const bonjour = await startGatewayBonjourAdvertiser({
|
try {
|
||||||
instanceName: formatBonjourInstanceName(params.machineDisplayName),
|
const bonjour = await startGatewayBonjourAdvertiser({
|
||||||
gatewayPort: params.port,
|
instanceName: formatBonjourInstanceName(params.machineDisplayName),
|
||||||
gatewayTlsEnabled: params.gatewayTls?.enabled ?? false,
|
gatewayPort: params.port,
|
||||||
gatewayTlsFingerprintSha256: params.gatewayTls?.fingerprintSha256,
|
gatewayTlsEnabled: params.gatewayTls?.enabled ?? false,
|
||||||
canvasPort: params.canvasPort,
|
gatewayTlsFingerprintSha256: params.gatewayTls?.fingerprintSha256,
|
||||||
sshPort,
|
canvasPort: params.canvasPort,
|
||||||
tailnetDns,
|
sshPort,
|
||||||
cliPath: resolveBonjourCliPath(),
|
tailnetDns,
|
||||||
});
|
cliPath,
|
||||||
bonjourStop = bonjour.stop;
|
minimal: mdnsMinimal,
|
||||||
} catch (err) {
|
});
|
||||||
params.logDiscovery.warn(`bonjour advertising failed: ${String(err)}`);
|
bonjourStop = bonjour.stop;
|
||||||
|
} catch (err) {
|
||||||
|
params.logDiscovery.warn(`bonjour advertising failed: ${String(err)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.wideAreaDiscoveryEnabled) {
|
if (params.wideAreaDiscoveryEnabled) {
|
||||||
|
|||||||
@ -352,6 +352,7 @@ export async function startGatewayServer(
|
|||||||
: undefined,
|
: undefined,
|
||||||
wideAreaDiscoveryEnabled: cfgAtStart.discovery?.wideArea?.enabled === true,
|
wideAreaDiscoveryEnabled: cfgAtStart.discovery?.wideArea?.enabled === true,
|
||||||
tailscaleMode,
|
tailscaleMode,
|
||||||
|
mdnsMode: cfgAtStart.discovery?.mdns?.mode,
|
||||||
logDiscovery,
|
logDiscovery,
|
||||||
});
|
});
|
||||||
bonjourStop = discovery.bonjourStop;
|
bonjourStop = discovery.bonjourStop;
|
||||||
|
|||||||
@ -138,6 +138,42 @@ describe("gateway bonjour advertiser", () => {
|
|||||||
expect(shutdown).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("attaches conflict listeners for services", async () => {
|
||||||
// Allow advertiser to run in unit tests.
|
// Allow advertiser to run in unit tests.
|
||||||
delete process.env.VITEST;
|
delete process.env.VITEST;
|
||||||
|
|||||||
@ -20,6 +20,11 @@ export type GatewayBonjourAdvertiseOpts = {
|
|||||||
canvasPort?: number;
|
canvasPort?: number;
|
||||||
tailnetDns?: string;
|
tailnetDns?: string;
|
||||||
cliPath?: string;
|
cliPath?: string;
|
||||||
|
/**
|
||||||
|
* Minimal mode - omit sensitive fields (cliPath, sshPort) from TXT records.
|
||||||
|
* Reduces information disclosure for better operational security.
|
||||||
|
*/
|
||||||
|
minimal?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isDisabledByEnv() {
|
function isDisabledByEnv() {
|
||||||
@ -115,12 +120,24 @@ export async function startGatewayBonjourAdvertiser(
|
|||||||
if (typeof opts.tailnetDns === "string" && opts.tailnetDns.trim()) {
|
if (typeof opts.tailnetDns === "string" && opts.tailnetDns.trim()) {
|
||||||
txtBase.tailnetDns = 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();
|
txtBase.cliPath = opts.cliPath.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
const services: Array<{ label: string; svc: BonjourService }> = [];
|
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({
|
const gateway = responder.createService({
|
||||||
name: safeServiceName(instanceName),
|
name: safeServiceName(instanceName),
|
||||||
type: "clawdbot-gw",
|
type: "clawdbot-gw",
|
||||||
@ -128,11 +145,7 @@ export async function startGatewayBonjourAdvertiser(
|
|||||||
port: opts.gatewayPort,
|
port: opts.gatewayPort,
|
||||||
domain: "local",
|
domain: "local",
|
||||||
hostname,
|
hostname,
|
||||||
txt: {
|
txt: gatewayTxt,
|
||||||
...txtBase,
|
|
||||||
sshPort: String(opts.sshPort ?? 22),
|
|
||||||
transport: "gateway",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
services.push({
|
services.push({
|
||||||
label: "gateway",
|
label: "gateway",
|
||||||
@ -149,7 +162,7 @@ export async function startGatewayBonjourAdvertiser(
|
|||||||
logDebug(
|
logDebug(
|
||||||
`bonjour: starting (hostname=${hostname}, instance=${JSON.stringify(
|
`bonjour: starting (hostname=${hostname}, instance=${JSON.stringify(
|
||||||
safeServiceName(instanceName),
|
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) {
|
for (const { label, svc } of services) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user