diff --git a/Chowder/Chowder/Models/ConnectionConfig.swift b/Chowder/Chowder/Models/ConnectionConfig.swift index 6ffbdbf..31a80d2 100644 --- a/Chowder/Chowder/Models/ConnectionConfig.swift +++ b/Chowder/Chowder/Models/ConnectionConfig.swift @@ -5,6 +5,8 @@ struct ConnectionConfig { private static let gatewayURLKey = "gatewayURL" private static let sessionKeyKey = "sessionKey" private static let tokenKeychainKey = "gatewayToken" + private static let cfAccessClientIdKeychainKey = "cfAccessClientId" + private static let cfAccessClientSecretKeychainKey = "cfAccessClientSecret" var gatewayURL: String { get { UserDefaults.standard.string(forKey: Self.gatewayURLKey) ?? "" } @@ -20,8 +22,25 @@ struct ConnectionConfig { get { KeychainService.load(key: Self.tokenKeychainKey) ?? "" } set { KeychainService.save(key: Self.tokenKeychainKey, value: newValue) } } + + // Cloudflare Zero Trust service token credentials. + // When both are set, they are sent as HTTP headers during the WebSocket upgrade. + var cfAccessClientId: String { + get { KeychainService.load(key: Self.cfAccessClientIdKeychainKey) ?? "" } + set { KeychainService.save(key: Self.cfAccessClientIdKeychainKey, value: newValue) } + } + + var cfAccessClientSecret: String { + get { KeychainService.load(key: Self.cfAccessClientSecretKeychainKey) ?? "" } + set { KeychainService.save(key: Self.cfAccessClientSecretKeychainKey, value: newValue) } + } + + var hasCloudflareAccessTokens: Bool { + !cfAccessClientId.isEmpty && !cfAccessClientSecret.isEmpty + } var isConfigured: Bool { !gatewayURL.isEmpty && !token.isEmpty } } + diff --git a/Chowder/Chowder/Services/ChatService.swift b/Chowder/Chowder/Services/ChatService.swift index 0d8d217..429b62d 100644 --- a/Chowder/Chowder/Services/ChatService.swift +++ b/Chowder/Chowder/Services/ChatService.swift @@ -22,6 +22,8 @@ final class ChatService: NSObject { private let gatewayURL: String private let token: String private let sessionKey: String + private let cfAccessClientId: String + private let cfAccessClientSecret: String private var webSocketTask: URLSessionWebSocketTask? private var urlSession: URLSession? @@ -56,10 +58,12 @@ final class ChatService: NSObject { private var visibleRunStartedAt: Date? private var hiddenRunIds: Set = [] - init(gatewayURL: String, token: String, sessionKey: String = "agent:main:main") { + init(gatewayURL: String, token: String, sessionKey: String = "agent:main:main", cfAccessClientId: String = "", cfAccessClientSecret: String = "") { self.gatewayURL = gatewayURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) self.token = token self.sessionKey = sessionKey + self.cfAccessClientId = cfAccessClientId + self.cfAccessClientSecret = cfAccessClientSecret // Use identifierForVendor when available; fall back to a UUID persisted in UserDefaults. if let vendorId = UIDevice.current.identifierForVendor?.uuidString { @@ -166,7 +170,16 @@ final class ChatService: NSObject { let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil) self.urlSession = session - let task = session.webSocketTask(with: url) + // Use URLRequest so we can inject HTTP headers before the WebSocket upgrade. + // CF-Access headers must be present in the initial HTTP upgrade request — + // they cannot be sent after the connection is established. + var request = URLRequest(url: url) + if !cfAccessClientId.isEmpty && !cfAccessClientSecret.isEmpty { + request.setValue(cfAccessClientId, forHTTPHeaderField: "CF-Access-Client-ID") + request.setValue(cfAccessClientSecret, forHTTPHeaderField: "CF-Access-Client-Secret") + log("[CONNECT] CF-Access headers injected") + } + let task = session.webSocketTask(with: request) self.webSocketTask = task log("[CONNECT] Calling task.resume() ...") task.resume() diff --git a/Chowder/Chowder/ViewModels/ChatViewModel.swift b/Chowder/Chowder/ViewModels/ChatViewModel.swift index d556575..04d032c 100644 --- a/Chowder/Chowder/ViewModels/ChatViewModel.swift +++ b/Chowder/Chowder/ViewModels/ChatViewModel.swift @@ -281,7 +281,9 @@ final class ChatViewModel: ChatServiceDelegate, LocationServiceDelegate { let service = ChatService( gatewayURL: config.gatewayURL, token: config.token, - sessionKey: config.sessionKey + sessionKey: config.sessionKey, + cfAccessClientId: config.cfAccessClientId, + cfAccessClientSecret: config.cfAccessClientSecret ) service.delegate = self self.chatService = service diff --git a/Chowder/Chowder/Views/SettingsView.swift b/Chowder/Chowder/Views/SettingsView.swift index a3b86c2..3b644dc 100644 --- a/Chowder/Chowder/Views/SettingsView.swift +++ b/Chowder/Chowder/Views/SettingsView.swift @@ -491,6 +491,9 @@ struct ConnectionDetailView: View { @State private var gatewayURL: String = "" @State private var token: String = "" @State private var sessionKey: String = "" + @State private var cfAccessEnabled: Bool = false + @State private var cfAccessClientId: String = "" + @State private var cfAccessClientSecret: String = "" var body: some View { ScrollView { @@ -537,6 +540,64 @@ struct ConnectionDetailView: View { } } + // Cloudflare Zero Trust + VStack(alignment: .leading, spacing: 6) { + Text("Cloudflare Zero Trust") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .padding(.horizontal, 4) + + GlassCard(padding: 0) { + VStack(spacing: 0) { + HStack { + Text("Enable") + .font(.system(size: 16)) + .foregroundStyle(.primary) + Spacer() + Toggle("", isOn: $cfAccessEnabled.animation()) + .labelsHidden() + } + .padding(.horizontal, 16) + .padding(.vertical, 13) + + if cfAccessEnabled { + Divider().padding(.leading, 16) + + HStack { + Text("Client ID") + .font(.system(size: 16)) + .foregroundStyle(.primary) + .frame(width: 80, alignment: .leading) + + SecureField("Service token client ID", text: $cfAccessClientId) + .font(.system(size: 16)) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + .padding(.horizontal, 16) + .padding(.vertical, 13) + + Divider().padding(.leading, 16) + + HStack { + Text("Secret") + .font(.system(size: 16)) + .foregroundStyle(.primary) + .frame(width: 80, alignment: .leading) + + SecureField("Service token secret", text: $cfAccessClientSecret) + .font(.system(size: 16)) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + .padding(.horizontal, 16) + .padding(.vertical, 13) + } + } + } + } + // Session VStack(alignment: .leading, spacing: 6) { Text("Session") @@ -561,6 +622,8 @@ struct ConnectionDetailView: View { config.gatewayURL = gatewayURL.trimmingCharacters(in: .whitespacesAndNewlines) config.token = token.trimmingCharacters(in: .whitespacesAndNewlines) config.sessionKey = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) + config.cfAccessClientId = cfAccessEnabled ? cfAccessClientId.trimmingCharacters(in: .whitespacesAndNewlines) : "" + config.cfAccessClientSecret = cfAccessEnabled ? cfAccessClientSecret.trimmingCharacters(in: .whitespacesAndNewlines) : "" onSave?() dismiss() } @@ -577,6 +640,9 @@ struct ConnectionDetailView: View { gatewayURL = config.gatewayURL token = config.token sessionKey = config.sessionKey + cfAccessEnabled = config.hasCloudflareAccessTokens + cfAccessClientId = config.cfAccessClientId + cfAccessClientSecret = config.cfAccessClientSecret } } } diff --git a/README.md b/README.md index aee21f7..a0739c5 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ https://github.com/user-attachments/assets/5af73b21-0ec1-4804-8a40-39dbd2f10adb ## Prerequisites - **Mac mini (or any macOS/Linux host)** running OpenClaw gateway -- **Tailscale** installed on both the gateway host and the iOS device (same tailnet) +- **Tailscale** installed on both the gateway host and the iOS device (same tailnet), **or** a **Cloudflare Tunnel** (`cloudflared`) exposing the gateway with **Cloudflare Access** protecting it via service token (see step 3b below) - **Xcode 15+** on a Mac to build and install Chowder - **iOS 17+** on the target device @@ -134,6 +134,41 @@ tailscale ip -4 # Example output: 100.104.164.27 ``` +### 3b. (Alternative) Expose the Gateway via Cloudflare Tunnel + Access + +Instead of Tailscale, you can expose the gateway publicly using a **Cloudflare Tunnel** (`cloudflared`) and protect it with **Cloudflare Access** (part of the Zero Trust platform) using a service token for machine-to-machine authentication. + +**On the gateway host:** + +1. Install and authenticate `cloudflared`: + ```bash + brew install cloudflare/cloudflare/cloudflared + cloudflared tunnel login + ``` +2. Create a tunnel and route it to the gateway: + ```bash + cloudflared tunnel create my-gateway + cloudflared tunnel route dns my-gateway gateway.yourdomain.com + ``` +3. Configure the tunnel to forward to the local gateway port (`~/.cloudflared/config.yml`): + ```yaml + tunnel: + credentials-file: ~/.cloudflared/.json + ingress: + - hostname: gateway.yourdomain.com + service: http://localhost:18789 + - service: http_status:404 + ``` +4. Start the tunnel: `cloudflared tunnel run my-gateway` + +**In the Cloudflare Zero Trust dashboard:** + +1. Go to **Access → Applications** and create an application for `gateway.yourdomain.com` +2. Add a **Service Auth** policy (not Allow) and attach a service token to it +3. Go to **Access → Service Auth → Service Tokens** and create a token — copy the **Client ID** and **Client Secret** + +The gateway URL to use in Chowder will be `wss://gateway.yourdomain.com` (port 443, no explicit port needed). + ### 4. Find Your Gateway Token The gateway token was generated during onboarding. To find it: @@ -166,9 +201,10 @@ In Xcode: 1. Open Chowder -- the Settings sheet appears on first launch 2. *(Optional)* To try UI interactions without OpenClaw, use the **demo** in Settings (e.g. Live Activity demo) — no gateway or token required. 3. Fill in the fields: - - **Gateway**: `ws://:18789` (e.g. `ws://100.104.164.27:18789`) + - **Gateway**: `ws://:18789` (e.g. `ws://100.104.164.27:18789`), or `wss://gateway.yourdomain.com` if using Cloudflare Zero Trust - **Token**: paste the gateway token from step 4 - **Session**: leave as `agent:main:main` (default) or change to target a specific agent + - **Cloudflare Zero Trust** *(optional)*: enable the toggle and paste the **Client ID** and **Client Secret** of your service token (from step 3b). These are sent as `CF-Access-Client-ID` / `CF-Access-Client-Secret` headers during the WebSocket upgrade and stored securely in the Keychain. 4. Tap **Save** Chowder will connect to the gateway, complete the WebSocket handshake, and show **Online** in the header. @@ -347,6 +383,15 @@ Expected result after fix: - If you see `"Filtered: X by toolCallId, Y by timestamp → 0 new items"` every poll, all items are from a previous run - If the agent responds very quickly (simple questions), there may not be any thinking or tool steps to show +### Cloudflare Zero Trust — connection hangs or gets a 302 redirect + +If the WebSocket connection hangs at "waiting for didOpen" or `curl` returns a `302` redirect to `cloudflareaccess.com`: + +- **Check `service_token_status` in the redirect JWT**: decode the `meta` query param — if `"service_token_status": false`, Cloudflare didn't accept the token +- **Verify the Access policy**: the application must have a **Service Auth** policy (not an Allow policy) with the service token explicitly allowed. Identity-based policies (email, IdP groups) do not accept service tokens. +- **Verify the credentials**: go to **Access → Service Auth → Service Tokens** and confirm the Client ID and Secret match what's in Chowder. Regenerating a token invalidates the old secret. +- **Check the port**: Cloudflare only proxies standard HTTPS ports. Use `wss://gateway.yourdomain.com` (port 443) — not `wss://gateway.yourdomain.com:18789`. + ### Gateway not reachable over Tailscale - Ensure `gateway.bind` is set to `"tailnet"` (not `"loopback"`) @@ -361,7 +406,7 @@ Chowder/ Models/ AgentActivity.swift -- Thinking/tool step tracking for shimmer BotIdentity.swift -- Parsed IDENTITY.md model + markdown serialization - ConnectionConfig.swift -- Gateway URL, token, session key storage + ConnectionConfig.swift -- Gateway URL, token, session key, and Cloudflare Zero Trust credentials (Keychain) Message.swift -- Chat message model (Codable, persisted) UserProfile.swift -- Parsed USER.md model + markdown serialization Services/