diff --git a/apps/ios/Sources/Bridge/BridgeConnectionController.swift b/apps/ios/Sources/Bridge/BridgeConnectionController.swift index 25e25a774..122c00b97 100644 --- a/apps/ios/Sources/Bridge/BridgeConnectionController.swift +++ b/apps/ios/Sources/Bridge/BridgeConnectionController.swift @@ -20,8 +20,9 @@ final class BridgeConnectionController { self.appModel = appModel BridgeSettingsStore.bootstrapPersistence() - self.discovery.setDebugLoggingEnabled( - UserDefaults.standard.bool(forKey: "bridge.discovery.debugLogs")) + let defaults = UserDefaults.standard + self.discovery.setDebugLoggingEnabled(defaults.bool(forKey: "bridge.discovery.debugLogs")) + self.discovery.setServiceDomain(defaults.string(forKey: "bridge.discovery.domain")) self.updateFromDiscovery() self.observeDiscovery() @@ -35,6 +36,10 @@ final class BridgeConnectionController { self.discovery.setDebugLoggingEnabled(enabled) } + func setDiscoveryDomain(_ domain: String?) { + self.discovery.setServiceDomain(domain) + } + func setScenePhase(_ phase: ScenePhase) { switch phase { case .background: diff --git a/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift b/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift index dbe31189c..820e8929d 100644 --- a/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift +++ b/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift @@ -27,6 +27,7 @@ final class BridgeDiscoveryModel { private var browser: NWBrowser? private var debugLoggingEnabled = false private var lastStableIDs = Set() + private var serviceDomain: String = ClawdisBonjour.bridgeServiceDomain func setDebugLoggingEnabled(_ enabled: Bool) { let wasEnabled = self.debugLoggingEnabled @@ -39,13 +40,25 @@ final class BridgeDiscoveryModel { } } + func setServiceDomain(_ domain: String?) { + let normalized = ClawdisBonjour.normalizeServiceDomain(domain) + guard normalized != self.serviceDomain else { return } + self.appendDebugLog("service domain: \(self.serviceDomain) → \(normalized)") + self.serviceDomain = normalized + + if self.browser != nil { + self.stop() + self.start() + } + } + func start() { if self.browser != nil { return } self.appendDebugLog("start()") let params = NWParameters.tcp params.includePeerToPeer = true let browser = NWBrowser( - for: .bonjour(type: ClawdisBonjour.bridgeServiceType, domain: ClawdisBonjour.bridgeServiceDomain), + for: .bonjour(type: ClawdisBonjour.bridgeServiceType, domain: self.serviceDomain), using: params) browser.stateUpdateHandler = { [weak self] state in diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index b226db058..446345482 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -26,6 +26,7 @@ struct SettingsTab: View { @AppStorage("bridge.manual.enabled") private var manualBridgeEnabled: Bool = false @AppStorage("bridge.manual.host") private var manualBridgeHost: String = "" @AppStorage("bridge.manual.port") private var manualBridgePort: Int = 18790 + @AppStorage("bridge.discovery.domain") private var discoveryDomain: String = "" @AppStorage("bridge.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false @State private var connectStatus = ConnectStatusStore() @State private var connectingBridgeID: String? @@ -132,6 +133,20 @@ struct SettingsTab: View { TextField("Port", value: self.$manualBridgePort, format: .number) .keyboardType(.numberPad) + TextField("Discovery Domain", text: self.$discoveryDomain) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .onChange(of: self.discoveryDomain) { _, newValue in + self.bridgeController.setDiscoveryDomain(newValue) + } + + Text( + "Default discovery domain is “local.” (mDNS on the same LAN). " + + + "For Wide-Area Bonjour / Unicast DNS-SD (e.g. over Tailscale), set a unicast DNS zone like “clawdis.internal.” and configure Tailnet split DNS accordingly.") + .font(.footnote) + .foregroundStyle(.secondary) + Button { Task { await self.connectManual() } } label: { @@ -179,6 +194,7 @@ struct SettingsTab: View { } .onAppear { self.localIPAddress = Self.primaryIPv4Address() + self.bridgeController.setDiscoveryDomain(self.discoveryDomain) } .onChange(of: self.preferredBridgeStableID) { _, newValue in let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apps/shared/ClawdisKit/Sources/ClawdisKit/BonjourTypes.swift b/apps/shared/ClawdisKit/Sources/ClawdisKit/BonjourTypes.swift index 83825bc2d..45aed6a98 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisKit/BonjourTypes.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisKit/BonjourTypes.swift @@ -4,4 +4,18 @@ public enum ClawdisBonjour { // v0: internal-only, subject to rename. public static let bridgeServiceType = "_clawdis-bridge._tcp" public static let bridgeServiceDomain = "local." + + public static func normalizeServiceDomain(_ raw: String?) -> String { + let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return self.bridgeServiceDomain + } + + let lower = trimmed.lowercased() + if lower == "local" || lower == "local." { + return self.bridgeServiceDomain + } + + return lower.hasSuffix(".") ? lower : (lower + ".") + } } diff --git a/docs/bonjour.md b/docs/bonjour.md index 5fdda3ead..0c206a6b8 100644 --- a/docs/bonjour.md +++ b/docs/bonjour.md @@ -8,6 +8,75 @@ read_when: Clawdis uses Bonjour (mDNS / DNS-SD) as a **LAN-only convenience** to discover a running Gateway and (optionally) its bridge transport. It is best-effort and does **not** replace SSH or Tailnet-based connectivity. +## Wide-Area Bonjour (Unicast DNS-SD) over Tailscale + +If you want Iris/iPad auto-discovery while the Gateway is on another network (e.g. Vienna ⇄ London), you can keep the `NWBrowser` UX but switch discovery from multicast mDNS (`local.`) to **unicast DNS-SD** (“Wide-Area Bonjour”) over Tailscale. + +High level: + +1) Run a DNS server on the gateway host (reachable via tailnet IP). +2) Publish DNS-SD records for `_clawdis-bridge._tcp` in a dedicated zone (example: `clawdis.internal.`). +3) Configure Tailscale **split DNS** so `clawdis.internal` resolves via that DNS server for clients (including iOS). +4) In Iris: Settings → Bridge → Advanced → set **Discovery Domain** to `clawdis.internal.` + +### Example: CoreDNS on macOS (gateway host) + +On the gateway host (macOS): + +```bash +brew install coredns + +sudo mkdir -p /opt/homebrew/etc/coredns +sudo tee /opt/homebrew/etc/coredns/Corefile >/dev/null <<'EOF' +clawdis.internal:53 { + log + errors + file /opt/homebrew/etc/coredns/clawdis.internal.db +} +EOF + +# Replace `` with the gateway machine’s tailnet IP. +sudo tee /opt/homebrew/etc/coredns/clawdis.internal.db >/dev/null <<'EOF' +$ORIGIN clawdis.internal. +$TTL 60 + +@ IN SOA ns.clawdis.internal. hostmaster.clawdis.internal. ( + 2025121701 ; serial + 60 ; refresh + 60 ; retry + 604800 ; expire + 60 ; minimum +) + +@ IN NS ns +ns IN A + +gw-london IN A + +_clawdis-bridge._tcp IN PTR ClawdisBridgeLondon._clawdis-bridge._tcp +ClawdisBridgeLondon._clawdis-bridge._tcp IN SRV 0 0 18790 gw-london +ClawdisBridgeLondon._clawdis-bridge._tcp IN TXT "displayName=Mac Studio (London)" +EOF + +sudo brew services start coredns +``` + +Validate from any tailnet-connected machine: + +```bash +dns-sd -B _clawdis-bridge._tcp clawdis.internal. +dig @ -p 53 _clawdis-bridge._tcp.clawdis.internal PTR +short +``` + +### Tailscale DNS settings + +In the Tailscale admin console: + +- Add a nameserver pointing at the gateway’s tailnet IP (UDP/TCP 53). +- Add split DNS so the domain `clawdis.internal` uses that nameserver. + +Once clients accept tailnet DNS, Iris can browse `_clawdis-bridge._tcp` in `clawdis.internal.` without multicast. + ## What advertises Only the **Node Gateway** (`clawd` / `clawdis gateway`) advertises Bonjour beacons. diff --git a/docs/ios/connect.md b/docs/ios/connect.md index c4aa856a7..142bd8224 100644 --- a/docs/ios/connect.md +++ b/docs/ios/connect.md @@ -17,7 +17,10 @@ The Gateway WebSocket stays loopback-only (`ws://127.0.0.1:18789`). Iris talks t ## Prerequisites - You can run the Gateway on the “master” machine. -- Iris (iOS app) is on the same LAN (Bonjour/mDNS must work). +- Iris (iOS app) can reach the gateway bridge: + - Same LAN with Bonjour/mDNS, **or** + - Same Tailscale tailnet using Wide-Area Bonjour / unicast DNS-SD (see below), **or** + - Manual bridge host/port (fallback) - You can run the CLI (`clawdis`) on the gateway machine (or via SSH). ## 1) Start the Gateway (with bridge enabled) @@ -49,6 +52,16 @@ dns-sd -L "" _clawdis-bridge._tcp local. More debugging notes: `docs/bonjour.md`. +### Tailnet (Vienna ⇄ London) discovery via unicast DNS-SD + +If Iris and the gateway are on different networks but connected via Tailscale, multicast mDNS won’t cross the boundary. Use Wide-Area Bonjour / unicast DNS-SD instead: + +1) Set up a DNS-SD zone (example `clawdis.internal.`) on the gateway host and publish `_clawdis-bridge._tcp` records. +2) Configure Tailscale split DNS for `clawdis.internal` pointing at that DNS server. +3) In Iris: Settings → Bridge → Advanced → set **Discovery Domain** to `clawdis.internal.` + +Details and example CoreDNS config: `docs/bonjour.md`. + ## 3) Connect from Iris (iOS) In Iris: