diff --git a/CLI/CMUXCLI+CommandSuggestions.swift b/CLI/CMUXCLI+CommandSuggestions.swift index 4f92479744a7..2fe4952d719b 100644 --- a/CLI/CMUXCLI+CommandSuggestions.swift +++ b/CLI/CMUXCLI+CommandSuggestions.swift @@ -212,6 +212,7 @@ extension CMUXCLI { "vm-pty-attach", "vm-pty-connect", "vm-ssh-attach", + "vpn", "wait-for", "welcome", "workspace", diff --git a/CLI/CMUXCLI+VPN.swift b/CLI/CMUXCLI+VPN.swift new file mode 100644 index 000000000000..bee6c01c4fac --- /dev/null +++ b/CLI/CMUXCLI+VPN.swift @@ -0,0 +1,242 @@ +import Darwin +import Foundation + +/// `cmux vpn` — the WireGuard tunnel between this Mac and the user's private +/// Cloud VM network. +/// +/// Cloud machines live on one private network per account and open no public +/// inbound port, so their session daemons are reachable only through this +/// tunnel. The cmux app owns enrollment (keypair, device identity, the config +/// file at `~/.cmuxterm/wireguard/cmux.conf`); this command owns the +/// privileged bring-up, because creating a utun and installing routes needs +/// root and `sudo` in the user's own terminal is the honest way to ask. +/// +/// Two backends, one command: +/// - **wg-quick** (shipping): `up` runs `sudo wg-quick up` on the app-written +/// config. Requires `brew install wireguard-tools`. +/// - **NetworkExtension** (long-term, entitlement-gated): when a build carries +/// the packet-tunnel entitlement, the app manages the tunnel itself and +/// `cmux vpn up` will hand off to it instead of shelling out. The socket +/// response advertises `network_extension_available` so this command steers +/// without a new CLI release. +extension CMUXCLI { + private static let wgQuickCandidates = [ + "/opt/homebrew/bin/wg-quick", + "/usr/local/bin/wg-quick", + "/opt/local/bin/wg-quick", + ] + + func runVPNCommand(commandArgs: [String], client: SocketClient, jsonOutput: Bool) throws { + let sub = commandArgs.first?.lowercased() ?? "status" + switch sub { + case "up": + try runVPNUp(client: client, jsonOutput: jsonOutput) + case "down": + try runVPNDown(client: client, jsonOutput: jsonOutput) + case "status": + try runVPNStatus(client: client, jsonOutput: jsonOutput) + case "revoke": + try runVPNRevoke(client: client, jsonOutput: jsonOutput) + default: + throw CLIError(message: "Usage: cmux vpn ") + } + } + + private func runVPNUp(client: SocketClient, jsonOutput: Bool) throws { + // The app enrolls (idempotently) and writes the completed config; this + // process never sees the private key except as bytes inside that + // 0600 file it does not read. + let response = try client.sendV2(method: "vm.tunnel_config", responseTimeout: 120) + guard let configPath = response["config_path"] as? String, !configPath.isEmpty else { + throw CLIError(message: "The cmux app did not return a tunnel config. Update cmux and retry.") + } + let interfaceUp = (response["interface_up"] as? Bool) ?? false + + if interfaceUp { + if jsonOutput { + print(jsonString(["status": "up", "config_path": configPath, "changed": false])) + } else { + print(String(localized: "cli.vpn.alreadyUp", defaultValue: "Tunnel is already up.")) + printVPNAddresses(response) + } + return + } + + guard let wgQuick = Self.firstExecutable(Self.wgQuickCandidates) else { + throw CLIError(message: """ + wg-quick is not installed, and this cmux build cannot manage the tunnel itself. + + What to do: + brew install wireguard-tools + cmux vpn up + """) + } + + if !jsonOutput { + if (response["created"] as? Bool) == true { + print(String(localized: "cli.vpn.enrolled", defaultValue: "Enrolled this Mac on your Cloud VM network.")) + } else if (response["rotated"] as? Bool) == true { + print(String(localized: "cli.vpn.rotated", defaultValue: "Refreshed this Mac's tunnel keys.")) + } + print(String(localized: "cli.vpn.bringingUp", defaultValue: "Bringing the tunnel up (sudo will prompt for your password)…")) + } + let status = runInteractiveProcess( + executablePath: "/usr/bin/sudo", + arguments: [wgQuick, "up", configPath] + ) + guard status == 0 else { + throw CLIError(message: """ + wg-quick up failed (exit \(status)). + + What to do: + Check the output above. If the interface half-started, run `cmux vpn down` first, then retry. + """) + } + if jsonOutput { + print(jsonString(["status": "up", "config_path": configPath, "changed": true])) + } else { + print(String(localized: "cli.vpn.up", defaultValue: "Tunnel is up.")) + printVPNAddresses(response) + } + } + + private func runVPNDown(client: SocketClient, jsonOutput: Bool) throws { + let response = try client.sendV2(method: "vm.tunnel_status", responseTimeout: 30) + let configPath = (response["config_path"] as? String) ?? "" + let interfaceUp = (response["interface_up"] as? Bool) ?? false + if !interfaceUp { + if jsonOutput { + print(jsonString(["status": "down", "changed": false])) + } else { + print(String(localized: "cli.vpn.notUp", defaultValue: "Tunnel is not up.")) + } + return + } + guard !configPath.isEmpty, FileManager.default.fileExists(atPath: configPath) else { + throw CLIError(message: "No tunnel config found at \(configPath). Run `cmux vpn up` to re-create it.") + } + guard let wgQuick = Self.firstExecutable(Self.wgQuickCandidates) else { + throw CLIError(message: "wg-quick is not installed. Install with: brew install wireguard-tools") + } + let status = runInteractiveProcess( + executablePath: "/usr/bin/sudo", + arguments: [wgQuick, "down", configPath] + ) + guard status == 0 else { + throw CLIError(message: "wg-quick down failed (exit \(status)). Check the output above.") + } + if jsonOutput { + print(jsonString(["status": "down", "changed": true])) + } else { + print(String(localized: "cli.vpn.down", defaultValue: "Tunnel is down.")) + } + } + + private func runVPNStatus(client: SocketClient, jsonOutput: Bool) throws { + let response = try client.sendV2(method: "vm.tunnel_status", responseTimeout: 30) + if jsonOutput { + print(jsonString(response)) + return + } + let interfaceUp = (response["interface_up"] as? Bool) ?? false + let configPresent = (response["config_present"] as? Bool) ?? false + let neAvailable = (response["network_extension_available"] as? Bool) ?? false + if interfaceUp { + print(String(localized: "cli.vpn.status.up", defaultValue: "Tunnel: up")) + } else if configPresent { + print(String(localized: "cli.vpn.status.down", defaultValue: "Tunnel: down (enrolled; run `cmux vpn up`)")) + } else { + print(String(localized: "cli.vpn.status.notSetUp", defaultValue: "Tunnel: not set up (run `cmux vpn up`)")) + } + if let path = response["config_path"] as? String, configPresent { + let format = String(localized: "cli.vpn.status.config", defaultValue: "Config: %@") + print(String(format: format, path)) + } + let backendFormat = String(localized: "cli.vpn.status.backend", defaultValue: "Backend: %@") + print(String(format: backendFormat, neAvailable ? "app-managed (NetworkExtension)" : "wg-quick")) + if !neAvailable, Self.firstExecutable(Self.wgQuickCandidates) == nil { + print(String( + localized: "cli.vpn.status.wgQuickMissing", + defaultValue: "wg-quick is not installed. Install with: brew install wireguard-tools" + )) + } + } + + private func runVPNRevoke(client: SocketClient, jsonOutput: Bool) throws { + // Best effort: take the interface down first so a revoked config isn't + // left routing traffic into a tunnel the server already deleted. + let status = try? client.sendV2(method: "vm.tunnel_status", responseTimeout: 30) + if (status?["interface_up"] as? Bool) == true, + let configPath = status?["config_path"] as? String, + let wgQuick = Self.firstExecutable(Self.wgQuickCandidates) { + _ = runInteractiveProcess( + executablePath: "/usr/bin/sudo", + arguments: [wgQuick, "down", configPath] + ) + } + let response = try client.sendV2(method: "vm.tunnel_revoke", responseTimeout: 60) + if jsonOutput { + print(jsonString(response)) + } else { + print(String(localized: "cli.vpn.revoked", defaultValue: "This Mac is unenrolled from your Cloud VM network.")) + } + } + + private func printVPNAddresses(_ response: [String: Any]) { + let address = (response["address_v4"] as? String).flatMap { $0.isEmpty ? nil : $0 } + ?? (response["address_v6"] as? String).flatMap { $0.isEmpty ? nil : $0 } + if let address { + let format = String( + localized: "cli.vpn.address", + defaultValue: "Your address on the network: %@" + ) + print(String(format: format, address)) + } + } + + private static func firstExecutable(_ candidates: [String]) -> String? { + candidates.first { FileManager.default.isExecutableFile(atPath: $0) } + } + + /// Run a subprocess on the caller's own tty (sudo needs it for its + /// password prompt; wg-quick's output is the user's feedback). + /// + /// Foundation's `Process` puts the child in its own process group, so a + /// child that reads the tty (sudo's password prompt) is stopped with + /// SIGTTIN and the command looks hung. Foreground the child's group for + /// its lifetime and restore ours after — the same dance the feed TUI does. + func runInteractiveProcess(executablePath: String, arguments: [String]) -> Int32 { + let process = Process() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + process.standardInput = FileHandle.standardInput + process.standardOutput = FileHandle.standardOutput + process.standardError = FileHandle.standardError + let originalForegroundProcessGroup = tcgetpgrp(STDIN_FILENO) + var didForegroundChild = false + do { + try process.run() + } catch { + FileHandle.standardError.write(Data("could not run \(executablePath): \(error.localizedDescription)\n".utf8)) + return 1 + } + if originalForegroundProcessGroup > 0 { + let childProcessGroup = getpgid(process.processIdentifier) + if childProcessGroup > 0 && childProcessGroup != originalForegroundProcessGroup { + if (try? setTerminalForegroundProcessGroup(childProcessGroup)) != nil { + // The child may already have stopped on SIGTTIN before we + // foregrounded it; wake it so the prompt appears. + _ = Darwin.kill(-childProcessGroup, SIGCONT) + didForegroundChild = true + } + } + } + defer { + if didForegroundChild { + try? setTerminalForegroundProcessGroup(originalForegroundProcessGroup) + } + } + process.waitUntilExit() + return process.terminationStatus + } +} diff --git a/CLI/cmux.swift b/CLI/cmux.swift index a2ece07109d9..30c5782fa71c 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -5528,6 +5528,9 @@ struct CMUXCLI { case "agent-hibernation": try runAgentHibernation(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput) + case "vpn": + try runVPNCommand(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput) + case "auth", "login", "logout": let authArgs = command == "auth" ? commandArgs : [command] + commandArgs let sub = authArgs.first?.lowercased() ?? "status" @@ -18233,6 +18236,25 @@ struct CMUXCLI { cmux events --cursor-file ~/.cache/cmux/events.seq --reconnect cmux events --after 42 --name feed.item.received """ + case "vpn": + return """ + Usage: cmux vpn + + The WireGuard tunnel between this Mac and your private Cloud VM + network. Cloud machines have no public ports, so `cmux vm` attach, + exec, and port verbs need this tunnel up. + + up Enroll this Mac (first run) and bring the tunnel up. + Uses wg-quick and prompts for sudo; install with + `brew install wireguard-tools`. + down Take the tunnel down. Enrollment is kept. + status Show tunnel state, config path, and backend. + revoke Take the tunnel down and unenroll this Mac. The server + deletes its side, so the saved config stops working. + + The cmux app writes the config to ~/.cmuxterm/wireguard/cmux.conf + with the private key generated on this Mac; the key never leaves it. + """ case "auth": return """ Usage: cmux auth @@ -18258,6 +18280,8 @@ struct CMUXCLI { Usage: cmux \(command) [args...] Manage cloud VMs. `cloud` is an alias for `vm`. Requires `cmux auth login`. + Machines live on your private network with no public ports; run `cmux vpn up` + once per boot so this Mac can reach them (see `cmux help vpn`). Subcommands: ls List your cloud VMs. diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index e2f33896f540..575f81235c96 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -58665,6 +58665,261 @@ } } }, + "cli.vpn.address": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your address on the network: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネットワーク上のあなたのアドレス: %@" + } + } + } + }, + "cli.vpn.alreadyUp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tunnel is already up." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トンネルはすでに接続されています。" + } + } + } + }, + "cli.vpn.bringingUp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Bringing the tunnel up (sudo will prompt for your password)…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トンネルを接続しています(sudo のパスワード入力が求められます)…" + } + } + } + }, + "cli.vpn.down": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tunnel is down." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トンネルを切断しました。" + } + } + } + }, + "cli.vpn.enrolled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enrolled this Mac on your Cloud VM network." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この Mac を Cloud VM ネットワークに登録しました。" + } + } + } + }, + "cli.vpn.notUp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tunnel is not up." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トンネルは接続されていません。" + } + } + } + }, + "cli.vpn.revoked": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This Mac is unenrolled from your Cloud VM network." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この Mac は Cloud VM ネットワークから登録解除されました。" + } + } + } + }, + "cli.vpn.rotated": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refreshed this Mac's tunnel keys." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この Mac のトンネル鍵を更新しました。" + } + } + } + }, + "cli.vpn.status.backend": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Backend: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "バックエンド: %@" + } + } + } + }, + "cli.vpn.status.config": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Config: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定ファイル: %@" + } + } + } + }, + "cli.vpn.status.down": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tunnel: down (enrolled; run `cmux vpn up`)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トンネル: 切断(登録済み。`cmux vpn up` を実行してください)" + } + } + } + }, + "cli.vpn.status.notSetUp": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tunnel: not set up (run `cmux vpn up`)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トンネル: 未設定(`cmux vpn up` を実行してください)" + } + } + } + }, + "cli.vpn.status.up": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tunnel: up" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トンネル: 接続中" + } + } + } + }, + "cli.vpn.status.wgQuickMissing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "wg-quick is not installed. Install with: brew install wireguard-tools" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "wg-quick がインストールされていません。`brew install wireguard-tools` でインストールしてください。" + } + } + } + }, + "cli.vpn.up": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tunnel is up." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "トンネルが接続されました。" + } + } + } + }, "cli.vm.tree.detached": { "extractionState": "manual", "localizations": { diff --git a/Sources/Cloud/CloudMachineLinkManager.swift b/Sources/Cloud/CloudMachineLinkManager.swift index 2ca7f2eddf0a..1609457f9d26 100644 --- a/Sources/Cloud/CloudMachineLinkManager.swift +++ b/Sources/Cloud/CloudMachineLinkManager.swift @@ -237,7 +237,15 @@ actor CloudMachineLinkManager { } catch { return } - guard let approval = try? await client.approveCmuxRemoteEnrollment(id: machineID, invitationId: invitationID) else { + let approval: VMCmuxRemoteApproval + do { + approval = try await client.approveCmuxRemoteEnrollment(id: machineID, invitationId: invitationID) + } catch VMClientError.httpStatus(404, _) { + // The machine was destroyed while this loop was waiting; it + // cannot come back under this id, so retrying only floods the + // control plane until the deadline. + return + } catch { continue } if approval.state == "approved" { diff --git a/Sources/Cloud/VMClient.swift b/Sources/Cloud/VMClient.swift index 996abaddc9a9..bd12543cbb47 100644 --- a/Sources/Cloud/VMClient.swift +++ b/Sources/Cloud/VMClient.swift @@ -471,6 +471,28 @@ enum VMAttachEndpoint { case websocket(VMWebSocketPtyEndpoint) } +/// This Mac's WireGuard tunnel into the account's private Cloud VM network, as +/// `/api/vm/tunnel` returns it. `clientConfig` is a complete wg-quick config +/// whose `PrivateKey` line is blank — the private key never leaves this Mac, +/// so the caller fills it in from local state before use. +struct VMTunnelEndpoint { + let tunnelId: String + let provider: String + let deviceFingerprint: String + let clientConfig: String + let clientPublicKey: String + let serverPublicKey: String + let endpointHost: String? + let endpointPort: Int + let routes: [String] + let addressV4: String? + let addressV6: String? + let networkCidr: String? + let networkCidrV6: String? + let created: Bool + let rotated: Bool +} + /// Talks to the manaflow cloud VM backend at `/api/vm/*`. Stack Auth tokens come from /// the injected `AuthCoordinator`; the HTTP base URL from `AuthEnvironment.vmAPIBaseURL`. /// @@ -930,6 +952,70 @@ actor VMClient { ) } + /// Enroll (or refresh) this Mac's WireGuard tunnel into the user's private + /// Cloud VM network. Idempotent per device: safe to call on every launch. + /// The server never sees a private key — only `clientPublicKey` travels. + func enrollTunnel( + clientPublicKey: String, + deviceFingerprint: String, + deviceName: String? = nil + ) async throws -> VMTunnelEndpoint { + var body: [String: Any] = [ + "clientPublicKey": clientPublicKey, + "deviceFingerprint": deviceFingerprint, + ] + if let deviceName, !deviceName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + body["deviceName"] = deviceName + } + let (data, http) = try await request("POST", path: "/api/vm/tunnel", jsonBody: body) + try ensureOK(http, data: data) + return try Self.decodeTunnelEndpoint(decodeJSONObject(data)) + } + + /// Unenroll this Mac. The server deletes the provider-side tunnel, so any + /// config still on disk stops working immediately. + func revokeTunnel(deviceFingerprint: String) async throws { + guard var components = URLComponents(string: "/api/vm/tunnel") else { + throw VMClientError.malformedResponse("could not build tunnel revoke path") + } + components.queryItems = [URLQueryItem(name: "deviceFingerprint", value: deviceFingerprint)] + let path = components.string ?? "/api/vm/tunnel" + let (data, http) = try await request("DELETE", path: path) + try ensureOK(http, data: data) + } + + private nonisolated static func decodeTunnelEndpoint(_ obj: [String: Any]) throws -> VMTunnelEndpoint { + guard let tunnelId = obj["tunnelId"] as? String, + let provider = obj["provider"] as? String, + let deviceFingerprint = obj["deviceFingerprint"] as? String, + let clientConfig = obj["clientConfig"] as? String, + let clientPublicKey = obj["clientPublicKey"] as? String, + let serverPublicKey = obj["serverPublicKey"] as? String, + let endpointPort = optionalInt(obj["endpointPort"]) + else { + throw VMClientError.malformedResponse("Cloud VM tunnel response was missing required fields.") + } + let address = obj["address"] as? [String: Any] + let network = obj["network"] as? [String: Any] + return VMTunnelEndpoint( + tunnelId: tunnelId, + provider: provider, + deviceFingerprint: deviceFingerprint, + clientConfig: clientConfig, + clientPublicKey: clientPublicKey, + serverPublicKey: serverPublicKey, + endpointHost: obj["endpointHost"] as? String, + endpointPort: endpointPort, + routes: (obj["routes"] as? [String]) ?? [], + addressV4: address?["ipv4"] as? String, + addressV6: address?["ipv6"] as? String, + networkCidr: network?["cidr"] as? String, + networkCidrV6: network?["cidrV6"] as? String, + created: (obj["created"] as? Bool) ?? false, + rotated: (obj["rotated"] as? Bool) ?? false + ) + } + func listSessions(id: String) async throws -> [VMCloudSession] { let encodedID = try pathSegment(id, fieldName: "vm id") let (data, http) = try await request("GET", path: "/api/vm/\(encodedID)/sessions") diff --git a/Sources/Cloud/VMClientSocketCommands.swift b/Sources/Cloud/VMClientSocketCommands.swift index a4f737febf9c..3c183f3e94d2 100644 --- a/Sources/Cloud/VMClientSocketCommands.swift +++ b/Sources/Cloud/VMClientSocketCommands.swift @@ -213,6 +213,62 @@ extension TerminalController { let payload = try CloudAgentSkillLauncher.promptPayload() return ["prompt": payload.prompt, "skill_path": payload.skillPath] } + case "vm.tunnel_config": + // Enrolls this Mac into the user's private Cloud VM network and + // returns the completed wg-quick config. The private key stays in + // app-owned files; it crosses only the same-user control socket, + // the same trust boundary every other vm verb already accepts + // (see the aiAccounts trust-model note below). `cmux vpn up` is + // the caller: it writes nothing itself, it just brings up the + // config path this returns. + return v2VmCall(id: id) { + let manager = VMTunnelManager() + let state = try await manager.enroll(client: VMClient.shared) + return [ + "config_path": state.configPath, + "interface_name": state.interfaceName, + "tunnel_id": state.endpoint.tunnelId, + "provider": state.endpoint.provider, + "device_fingerprint": state.endpoint.deviceFingerprint, + "address_v4": state.endpoint.addressV4 ?? NSNull(), + "address_v6": state.endpoint.addressV6 ?? NSNull(), + "network_cidr": state.endpoint.networkCidr ?? NSNull(), + "network_cidr_v6": state.endpoint.networkCidrV6 ?? NSNull(), + "endpoint_host": state.endpoint.endpointHost ?? NSNull(), + "endpoint_port": state.endpoint.endpointPort, + "routes": state.endpoint.routes, + "created": state.endpoint.created, + "rotated": state.endpoint.rotated, + "interface_up": manager.wgQuickInterfaceUp(), + "network_extension_available": VMTunnelManager.networkExtensionAvailable(), + ] + } + case "vm.tunnel_status": + // Read-only: local interface state plus whatever enrollment state + // is already on disk. Never enrolls, so it is safe for scripts. + return v2VmCall(id: id) { + let manager = VMTunnelManager() + let fingerprint = (try? manager.deviceFingerprint()) ?? "" + let hasConfig = FileManager.default.fileExists(atPath: manager.configURL.path) + return [ + "config_path": manager.configURL.path, + "config_present": hasConfig, + "interface_name": VMTunnelManager.interfaceName, + "interface_up": manager.wgQuickInterfaceUp(), + "device_fingerprint": fingerprint, + "network_extension_available": VMTunnelManager.networkExtensionAvailable(), + ] + } + case "vm.tunnel_revoke": + // Unenrolls this Mac server-side and removes the local config so a + // later `cmux vpn up` re-enrolls from scratch. + return v2VmCall(id: id) { + let manager = VMTunnelManager() + let fingerprint = try manager.deviceFingerprint() + try await VMClient.shared.revokeTunnel(deviceFingerprint: fingerprint) + try? FileManager.default.removeItem(at: manager.configURL) + return ["revoked": true] + } case "vm.ssh_info": guard let vmId = Self.socketWorkerString(params["id"]), !vmId.isEmpty else { return v2Error(id: id, code: "invalid_params", message: "vm.ssh_info requires `id`. Run `cmux vm ls` to find one.") diff --git a/Sources/Cloud/VMTunnelManager.swift b/Sources/Cloud/VMTunnelManager.swift new file mode 100644 index 000000000000..9f536306c819 --- /dev/null +++ b/Sources/Cloud/VMTunnelManager.swift @@ -0,0 +1,260 @@ +import CryptoKit +import Foundation +import Security + +/// This Mac's membership in the user's private Cloud VM network. +/// +/// Every Cloud VM the user owns sits on one provider-side private network, and +/// the machines open no public inbound port — so this Mac can only reach their +/// session daemons through a WireGuard tunnel into that network. This type owns +/// the local half of that tunnel: +/// +/// - a Curve25519 keypair generated here, whose private half never leaves this +/// Mac (the control plane receives only the public key), +/// - a stable per-installation device fingerprint, so re-enrolling on every +/// launch resolves to the same tunnel and the same address on the network, +/// - the assembled wg-quick config at `~/.cmuxterm/wireguard/cmux.conf` (0600), +/// which is the one artifact both bring-up paths consume. +/// +/// Bringing the interface up needs privileges the app process does not have, +/// and there are two backends for it: +/// +/// - **wg-quick** (`cmux vpn up`) — the shipping path. The CLI runs +/// `sudo wg-quick up` against the config this manager wrote; sudo in the +/// user's own terminal is the honest privilege prompt. +/// - **NetworkExtension** — the long-term path, pending the +/// `com.apple.developer.networking.networkextension` entitlement. When a +/// build carries it, the app can own the tunnel as a real macOS VPN with no +/// admin prompt. `networkExtensionAvailable` gates that branch at runtime so +/// the same build degrades to the CLI path when the entitlement is absent. +struct VMTunnelManager: Sendable { + struct LocalTunnelState: Sendable { + let endpoint: VMTunnelEndpoint + /// Path of the written wg-quick config (private key included, 0600). + let configPath: String + /// The wg-quick interface name derived from the config filename. + let interfaceName: String + } + + enum TunnelError: Error, CustomStringConvertible { + case keyStorageFailed(String) + case configMalformed(String) + + var description: String { + switch self { + case .keyStorageFailed(let detail): + return "Could not store the WireGuard key for this Mac: \(detail)" + case .configMalformed(let detail): + return "The tunnel config from the Cloud VM service could not be completed: \(detail)" + } + } + } + + /// wg-quick names the interface after the config file, so this is both. + static let interfaceName = "cmux" + + let home: URL + + init(home: URL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)) { + self.home = home + } + + /// `~/.cmuxterm/wireguard`, 0700 — alongside the cmux-tui client state, + /// which follows the same file-permission model for its device key. + var stateDir: URL { + home.appendingPathComponent(".cmuxterm", isDirectory: true) + .appendingPathComponent("wireguard", isDirectory: true) + } + + var privateKeyURL: URL { stateDir.appendingPathComponent("private.key", isDirectory: false) } + var deviceIDURL: URL { stateDir.appendingPathComponent("device-id", isDirectory: false) } + var configURL: URL { stateDir.appendingPathComponent("\(Self.interfaceName).conf", isDirectory: false) } + + /// wg-quick(8) records the created utun's name here — but on macOS the + /// file is root-only (0400), so liveness detection must not depend on it; + /// see `wgQuickInterfaceUp()`. + var runtimeNameFileURL: URL { + URL(fileURLWithPath: "/var/run/wireguard/\(Self.interfaceName).name", isDirectory: false) + } + + /// Whether this build can own the tunnel as a NetworkExtension VPN. + /// + /// Reads the signed entitlement rather than trying to configure a manager, + /// so an unentitled build never shows the user a doomed VPN prompt. Today + /// no build carries the entitlement; when release signing gains it, this + /// flips to true with no code change and `cmux vpn up` starts steering to + /// the app-managed tunnel. + static func networkExtensionAvailable() -> Bool { + guard let task = SecTaskCreateFromSelf(nil) else { return false } + let key = "com.apple.developer.networking.networkextension" as CFString + guard let raw = SecTaskCopyValueForEntitlement(task, key, nil) else { return false } + if let capabilities = raw as? [String] { + return capabilities.contains("packet-tunnel-provider") + } + return false + } + + /// The stable per-installation device fingerprint, minted on first use. + /// Distinct from the per-machine cmux-tui fingerprints: this one names the + /// Mac itself on the account's network. + func deviceFingerprint() throws -> String { + if let existing = try? String(contentsOf: deviceIDURL, encoding: .utf8) { + let trimmed = existing.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + } + let minted = "mac-" + UUID().uuidString.lowercased() + try ensureStateDir() + try write(minted + "\n", to: deviceIDURL) + return minted + } + + /// The Mac's WireGuard keypair, minted on first use. Returns base64 halves; + /// only the public one may travel. + func keypair() throws -> (privateKey: String, publicKey: String) { + if let existing = try? String(contentsOf: privateKeyURL, encoding: .utf8) { + let trimmed = existing.trimmingCharacters(in: .whitespacesAndNewlines) + if let data = Data(base64Encoded: trimmed), data.count == 32, + let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: data) { + return (trimmed, key.publicKey.rawRepresentation.base64EncodedString()) + } + } + let key = Curve25519.KeyAgreement.PrivateKey() + let privateBase64 = key.rawRepresentation.base64EncodedString() + try ensureStateDir() + try write(privateBase64 + "\n", to: privateKeyURL) + return (privateBase64, key.publicKey.rawRepresentation.base64EncodedString()) + } + + /// Enroll this Mac with the control plane and write the completed config. + /// + /// Safe to call on every launch: enrollment is idempotent per device, and + /// rewriting an unchanged config is harmless. A `rotated` response means + /// the server replaced the tunnel's keys to match this Mac's current + /// keypair (a reinstall that minted a new one); the address on the network + /// is preserved either way. + func enroll(client: VMClient, deviceName: String? = nil) async throws -> LocalTunnelState { + let keys = try keypair() + let fingerprint = try deviceFingerprint() + let endpoint = try await client.enrollTunnel( + clientPublicKey: keys.publicKey, + deviceFingerprint: fingerprint, + deviceName: deviceName ?? CloudTuiClientPaths.deviceName() + ) + let config = try Self.completedConfig(endpoint.clientConfig, privateKey: keys.privateKey) + try ensureStateDir() + try write(config, to: configURL) + return LocalTunnelState( + endpoint: endpoint, + configPath: configURL.path, + interfaceName: Self.interfaceName + ) + } + + /// Whether wg-quick currently has this tunnel up, without privileges. + /// + /// wg-quick's own record (`/var/run/wireguard/cmux.name`) is root-only on + /// macOS, so instead this asks the question the network can answer: does + /// any interface hold one of the tunnel's own `[Interface] Address`es from + /// the config this manager wrote? Those are fixed platform-side addresses + /// unique to the tunnel, so a match is the tunnel and nothing else. A + /// future NetworkExtension tunnel reports through NEVPNStatus instead. + func wgQuickInterfaceUp() -> Bool { + guard let config = try? String(contentsOf: configURL, encoding: .utf8) else { return false } + let expected = Self.interfaceAddresses(in: config) + guard !expected.isEmpty else { return false } + var addrs: UnsafeMutablePointer? + guard getifaddrs(&addrs) == 0 else { return false } + defer { freeifaddrs(addrs) } + var cursor = addrs + while let current = cursor { + if let sa = current.pointee.ifa_addr, let address = Self.numericAddress(sa), + expected.contains(address) { + return true + } + cursor = current.pointee.ifa_next + } + return false + } + + /// The `Address =` values in a wg-quick config's `[Interface]` section, + /// with their prefix lengths stripped (`100.64.0.1/32` → `100.64.0.1`). + static func interfaceAddresses(in config: String) -> Set { + var addresses = Set() + var inInterface = false + for rawLine in config.components(separatedBy: "\n") { + let line = rawLine.trimmingCharacters(in: .whitespaces) + if line.hasPrefix("[") { + inInterface = line.lowercased() == "[interface]" + continue + } + guard inInterface else { continue } + let parts = line.split(separator: "=", maxSplits: 1) + guard parts.count == 2, + parts[0].trimmingCharacters(in: .whitespaces).lowercased() == "address" else { continue } + for entry in parts[1].split(separator: ",") { + let value = entry.trimmingCharacters(in: .whitespaces) + let bare = value.split(separator: "/", maxSplits: 1).first.map(String.init) ?? value + if !bare.isEmpty { addresses.insert(bare.lowercased()) } + } + } + return addresses + } + + private static func numericAddress(_ sa: UnsafeMutablePointer) -> String? { + switch Int32(sa.pointee.sa_family) { + case AF_INET: + var addr = sa.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee.sin_addr } + var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN)) + guard inet_ntop(AF_INET, &addr, &buffer, socklen_t(INET_ADDRSTRLEN)) != nil else { return nil } + return String(cString: buffer) + case AF_INET6: + var addr = sa.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { $0.pointee.sin6_addr } + var buffer = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) + guard inet_ntop(AF_INET6, &addr, &buffer, socklen_t(INET6_ADDRSTRLEN)) != nil else { return nil } + return String(cString: buffer).lowercased() + default: + return nil + } + } + + /// Fill the blank `PrivateKey` line the server left in the config. + /// The server-issued config is otherwise complete and final. + static func completedConfig(_ config: String, privateKey: String) throws -> String { + var lines = config.components(separatedBy: "\n") + if let index = lines.firstIndex(where: { line in + let trimmed = line.trimmingCharacters(in: .whitespaces) + return trimmed.lowercased().hasPrefix("privatekey") + }) { + lines[index] = "PrivateKey = \(privateKey)" + return lines.joined(separator: "\n") + } + // No PrivateKey line at all: insert directly under [Interface]. + guard let interfaceIndex = lines.firstIndex(where: { + $0.trimmingCharacters(in: .whitespaces).lowercased() == "[interface]" + }) else { + throw TunnelError.configMalformed("no [Interface] section in server config") + } + lines.insert("PrivateKey = \(privateKey)", at: interfaceIndex + 1) + return lines.joined(separator: "\n") + } + + private func ensureStateDir() throws { + try FileManager.default.createDirectory( + at: stateDir, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + } + + private func write(_ content: String, to url: URL) throws { + guard let data = content.data(using: .utf8) else { + throw TunnelError.keyStorageFailed("could not encode \(url.lastPathComponent)") + } + do { + try data.write(to: url, options: [.atomic]) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } catch { + throw TunnelError.keyStorageFailed("\(url.path): \(error.localizedDescription)") + } + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index ccf3dd686e94..47b4e1854b51 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -773,6 +773,7 @@ D1FF52000000000000000001 /* CMUXCLI+TypedDiffViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1FF52000000000000000002 /* CMUXCLI+TypedDiffViewer.swift */; }; CC0033E15A1B2C3D4E5F0101 /* CMUXCLI+VMTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0033E15A1B2C3D4E5F0102 /* CMUXCLI+VMTransfer.swift */; }; CC0033E15A1B2C3D4E5F0020 /* CMUXCLI+VMTui.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0033E15A1B2C3D4E5F0021 /* CMUXCLI+VMTui.swift */; }; + C8923639133F158AF1B6A0FB /* CMUXCLI+VPN.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA475616EA96D1D0624FC0A1 /* CMUXCLI+VPN.swift */; }; 06CC2F6C1340C7424D1C7E0A /* CMUXCLI+WorkspaceTodo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E5D35CC62B0F252F9EC2AD1 /* CMUXCLI+WorkspaceTodo.swift */; }; C0DE31390000000000000105 /* CMUXCLIErrorOutputRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE31390000000000000106 /* CMUXCLIErrorOutputRegressionTests.swift */; }; 906900000000000000000004 /* CMUXCLIMemoryAttributionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 906900000000000000000003 /* CMUXCLIMemoryAttributionTests.swift */; }; @@ -2797,6 +2798,8 @@ DCFD2C3A4F04C00C62720EE5 /* VMMachineKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EECEC1A2D3CFC13D4343F46 /* VMMachineKind.swift */; }; F1C699902D31503EE3D492DF /* VMMachineKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EECEC1A2D3CFC13D4343F46 /* VMMachineKind.swift */; }; C37800000000000000000001 /* VMSSHCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C37800000000000000000002 /* VMSSHCommandTests.swift */; }; + B43AFCE7099305905825EA0A /* VMTunnelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EC85CE06E4F64991DAA433 /* VMTunnelManager.swift */; }; + ABE558B281D671A6DDDDD066 /* VMTunnelManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74A47FAD328D6669A5B40CE3 /* VMTunnelManagerTests.swift */; }; A500120C /* WindowAccessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001222 /* WindowAccessor.swift */; }; 063BC42CEE257D6213A2E30C /* WindowAndDragTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */; }; A59170000000000000000001 /* WindowAppearanceSnapshotPaneBackgroundTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59170000000000000000002 /* WindowAppearanceSnapshotPaneBackgroundTests.swift */; }; @@ -3843,6 +3846,7 @@ D1FF52000000000000000002 /* CMUXCLI+TypedDiffViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+TypedDiffViewer.swift"; sourceTree = ""; }; CC0033E15A1B2C3D4E5F0102 /* CMUXCLI+VMTransfer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+VMTransfer.swift"; sourceTree = ""; }; CC0033E15A1B2C3D4E5F0021 /* CMUXCLI+VMTui.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+VMTui.swift"; sourceTree = ""; }; + EA475616EA96D1D0624FC0A1 /* CMUXCLI+VPN.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+VPN.swift"; sourceTree = ""; }; 7E5D35CC62B0F252F9EC2AD1 /* CMUXCLI+WorkspaceTodo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+WorkspaceTodo.swift"; sourceTree = ""; }; C0DE31390000000000000106 /* CMUXCLIErrorOutputRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMUXCLIErrorOutputRegressionTests.swift; sourceTree = ""; }; 906900000000000000000003 /* CMUXCLIMemoryAttributionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMUXCLIMemoryAttributionTests.swift; sourceTree = ""; }; @@ -5783,6 +5787,8 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C37800000000000000000004 /* VMDefaultCloudCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDefaultCloudCommandTests.swift; sourceTree = ""; }; 6EECEC1A2D3CFC13D4343F46 /* VMMachineKind.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VMMachineKind.swift; sourceTree = ""; }; C37800000000000000000002 /* VMSSHCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMSSHCommandTests.swift; sourceTree = ""; }; + 03EC85CE06E4F64991DAA433 /* VMTunnelManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VMTunnelManager.swift; sourceTree = ""; }; + 74A47FAD328D6669A5B40CE3 /* VMTunnelManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMTunnelManagerTests.swift; sourceTree = ""; }; A5001222 /* WindowAccessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAccessor.swift; sourceTree = ""; }; BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAndDragTests.swift; sourceTree = ""; }; A59170000000000000000002 /* WindowAppearanceSnapshotPaneBackgroundTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAppearanceSnapshotPaneBackgroundTests.swift; sourceTree = ""; }; @@ -6431,6 +6437,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef D1F0A00800000000000000C2 /* MacPairedMacBackupRecordWire.swift */, D1F0A00100000000000000B4 /* PresenceSettings.swift */, 9CEC6F0035B71AE59FA45BA7 /* VMClientSocketCommands.swift */, + 03EC85CE06E4F64991DAA433 /* VMTunnelManager.swift */, D9143D900F124494A55F859F /* CloudMachinesFeature.swift */, 08610D114C8CF23877176D7E /* MachinesPanelView.swift */, 68672B3C63D4E348AFA4DEF7 /* MachinesPanelViewModel.swift */, @@ -8301,6 +8308,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 7E5D35CC62B0F252F9EC2AD1 /* CMUXCLI+WorkspaceTodo.swift */, CC0033E15A1B2C3D4E5F0002 /* CMUXCLI+Comments.swift */, CC0033E15A1B2C3D4E5F0021 /* CMUXCLI+VMTui.swift */, + EA475616EA96D1D0624FC0A1 /* CMUXCLI+VPN.swift */, CC0033E15A1B2C3D4E5F0102 /* CMUXCLI+VMTransfer.swift */, B9000053A1B2C3D4E5F60719 /* CMUXCLI+InstallPreview.swift */, B9000060A1B2C3D4E5F60719 /* CMUXCLI+HermesAgentHooks.swift */, @@ -8537,6 +8545,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */, F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, A5C017000000000000000010 /* AccountSignInModelTests.swift */, + 74A47FAD328D6669A5B40CE3 /* VMTunnelManagerTests.swift */, A9126001C5E7F90123456789 /* ReopenLastClosedTests.swift */, F17A5F020000000000000002 /* FocusedPanelFlashShortcutTests.swift */, F5588001A1B2C3D4E5F60718 /* AppDelegateOptionDigitShortcutRoutingTests.swift */, @@ -11440,6 +11449,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef ACE4FE1A8F1CC6C0B6A673AB /* VMClient.swift in Sources */, ACE4FE2A8F1CC6C0B6A673AB /* VMClientSocketCommands.swift in Sources */, F1C699902D31503EE3D492DF /* VMMachineKind.swift in Sources */, + B43AFCE7099305905825EA0A /* VMTunnelManager.swift in Sources */, A500120C /* WindowAccessor.swift in Sources */, 9065A0020000000000000001 /* WindowAppKitCapture.swift in Sources */, A5001900A1B2C3D4E5F60718 /* WindowBackgroundComposition.swift in Sources */, @@ -11717,6 +11727,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef D1FF52000000000000000001 /* CMUXCLI+TypedDiffViewer.swift in Sources */, CC0033E15A1B2C3D4E5F0101 /* CMUXCLI+VMTransfer.swift in Sources */, CC0033E15A1B2C3D4E5F0020 /* CMUXCLI+VMTui.swift in Sources */, + C8923639133F158AF1B6A0FB /* CMUXCLI+VPN.swift in Sources */, 06CC2F6C1340C7424D1C7E0A /* CMUXCLI+WorkspaceTodo.swift in Sources */, 918100000000000000000051 /* CodexHookFailureCandidate.swift in Sources */, 918100000000000000000041 /* CodexHookFailureSummary.swift in Sources */, @@ -12529,6 +12540,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A3340002A3340002A3340002 /* ViewerNavigationTests.swift in Sources */, C37800000000000000000003 /* VMDefaultCloudCommandTests.swift in Sources */, C37800000000000000000001 /* VMSSHCommandTests.swift in Sources */, + ABE558B281D671A6DDDDD066 /* VMTunnelManagerTests.swift in Sources */, 063BC42CEE257D6213A2E30C /* WindowAndDragTests.swift in Sources */, A59170000000000000000001 /* WindowAppearanceSnapshotPaneBackgroundTests.swift in Sources */, F4200000A1B2C3D4E5F60718 /* WindowAppearanceSnapshotTests.swift in Sources */, diff --git a/cmuxTests/VMTunnelManagerTests.swift b/cmuxTests/VMTunnelManagerTests.swift new file mode 100644 index 000000000000..32aefc85ddcc --- /dev/null +++ b/cmuxTests/VMTunnelManagerTests.swift @@ -0,0 +1,143 @@ +import CryptoKit +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// The local half of Cloud VM private networking: keys and device identity are +/// minted once and stay stable, and the server-issued config (blank +/// `PrivateKey`) is completed on this Mac without ever sending the key out. +@Suite +struct VMTunnelManagerTests { + private func temporaryHome() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-tunnel-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + @Test + func keypairIsMintedOnceAndStable() throws { + let home = try temporaryHome() + defer { try? FileManager.default.removeItem(at: home) } + let manager = VMTunnelManager(home: home) + + let first = try manager.keypair() + let second = try manager.keypair() + #expect(first.privateKey == second.privateKey) + #expect(first.publicKey == second.publicKey) + + // The public half must be the X25519 derivation of the private half — + // a mismatch would enroll a key the Mac cannot handshake with. + let raw = try #require(Data(base64Encoded: first.privateKey)) + let key = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: raw) + #expect(key.publicKey.rawRepresentation.base64EncodedString() == first.publicKey) + + // 0600: the private key is a credential. + let attrs = try FileManager.default.attributesOfItem(atPath: manager.privateKeyURL.path) + #expect((attrs[.posixPermissions] as? Int) == 0o600) + } + + @Test + func deviceFingerprintIsStablePerInstallation() throws { + let home = try temporaryHome() + defer { try? FileManager.default.removeItem(at: home) } + let manager = VMTunnelManager(home: home) + let first = try manager.deviceFingerprint() + #expect(first.hasPrefix("mac-")) + #expect(try manager.deviceFingerprint() == first) + } + + @Test + func completedConfigFillsTheBlankPrivateKeyLine() throws { + let config = """ + [Interface] + PrivateKey = + Address = 100.64.0.1/32 + MTU = 1200 + + [Peer] + PublicKey = server-key + AllowedIPs = 10.0.0.0/8, fd00::/8 + Endpoint = vpn.example.com:51820 + """ + let completed = try VMTunnelManager.completedConfig(config, privateKey: "PRIVATE") + #expect(completed.contains("PrivateKey = PRIVATE")) + // Everything else must be byte-identical: the server config is final. + #expect(completed.contains("Address = 100.64.0.1/32")) + #expect(completed.contains("Endpoint = vpn.example.com:51820")) + } + + @Test + func completedConfigInsertsWhenNoPrivateKeyLineExists() throws { + let config = """ + [Interface] + Address = 100.64.0.1/32 + + [Peer] + PublicKey = server-key + """ + let completed = try VMTunnelManager.completedConfig(config, privateKey: "PRIVATE") + let lines = completed.components(separatedBy: "\n") + let interfaceIndex = try #require(lines.firstIndex(of: "[Interface]")) + #expect(lines[interfaceIndex + 1] == "PrivateKey = PRIVATE") + } + + @Test + func completedConfigRejectsAConfigWithoutAnInterfaceSection() { + #expect(throws: VMTunnelManager.TunnelError.self) { + _ = try VMTunnelManager.completedConfig("[Peer]\nPublicKey = x", privateKey: "PRIVATE") + } + } + + @Test + func interfaceUpIsFalseWithoutAWrittenConfig() throws { + let home = try temporaryHome() + defer { try? FileManager.default.removeItem(at: home) } + // No config on disk means nothing to match interfaces against. + #expect(VMTunnelManager(home: home).wgQuickInterfaceUp() == false) + } + + @Test + func interfaceAddressesParseOnlyTheInterfaceSection() { + let config = """ + [Interface] + PrivateKey = X + Address = 100.64.0.9/32 + Address = FD7A:7570:6C6B::9/128, 10.9.9.9/24 + MTU = 1380 + + [Peer] + PublicKey = Y + AllowedIPs = 10.0.0.0/8, fd00::/8 + """ + // Prefix lengths stripped, IPv6 lowercased, AllowedIPs never included — + // matching an AllowedIPs range against interface addresses would call + // any 10.x interface "the tunnel". + #expect(VMTunnelManager.interfaceAddresses(in: config) == [ + "100.64.0.9", "fd7a:7570:6c6b::9", "10.9.9.9", + ]) + } + + @Test + func interfaceUpMatchesALiveInterfaceAddress() throws { + let home = try temporaryHome() + defer { try? FileManager.default.removeItem(at: home) } + let manager = VMTunnelManager(home: home) + try FileManager.default.createDirectory(at: manager.stateDir, withIntermediateDirectories: true) + // Loopback is always present, so a config claiming 127.0.0.1 as the + // interface address reads as up — proving detection is address-based. + try """ + [Interface] + Address = 127.0.0.1/32 + + [Peer] + PublicKey = Y + """.write(to: manager.configURL, atomically: true, encoding: .utf8) + #expect(manager.wgQuickInterfaceUp() == true) + } +} diff --git a/web/app/api/account/route.ts b/web/app/api/account/route.ts index 42cf84e690ad..a6a23d7fad5d 100644 --- a/web/app/api/account/route.ts +++ b/web/app/api/account/route.ts @@ -77,6 +77,7 @@ import { import { destroyVm, listUserVms, + deletePrivateNetworkingForAccountDeletion, revokeUserIdentityLeasesForAccountDeletion, runVmWorkflow, } from "../../../services/vms/workflows"; @@ -291,6 +292,17 @@ export async function DELETE(request: Request): Promise { } throw error; } + // After the machines: the provider refuses to delete a network that + // still has attached VMs, so this must follow destroyPersonalCloudVms. + // Tunnel deletion revokes every enrolled computer's WireGuard access. + try { + const networking = await runVmWorkflow(deletePrivateNetworkingForAccountDeletion(userId)); + if (networking.tunnels > 0 || networking.networks > 0) destructiveCleanupStarted = true; + } catch (error) { + logAccountDeleteError("account.delete.private_network_cleanup_failed", error); + throw error; + } + await refreshAccountDeletionTombstoneLease(userId); await deleteVaultRowsAndObjectsForAccount(userId, { beforeObjectDeletion: () => { destructiveCleanupStarted = true; diff --git a/web/app/api/vm/tunnel/route.ts b/web/app/api/vm/tunnel/route.ts new file mode 100644 index 000000000000..07d280d18ce6 --- /dev/null +++ b/web/app/api/vm/tunnel/route.ts @@ -0,0 +1,265 @@ +import { defaultProviderId, isProviderId } from "../../../../services/vms/drivers"; +import { + jsonResponse, + resolveVmRouteAccountScope, + vmErrorResponse, + withAuthedVmApiRoute, +} from "../../../../services/vms/routeHelpers"; +import { setSpanAttributes } from "../../../../services/telemetry"; +import { + enrollVmTunnel, + isWireGuardPublicKey, + listVmTunnels, + readVmTunnel, + revokeVmTunnel, + runVmWorkflow, + type VmTunnelDescriptor, +} from "../../../../services/vms/workflows"; +import { + optionalClientIdentifier, + optionalString, + parseLenientObjectBody, +} from "../../../../services/vms/routeInput"; + +/** + * The account's WireGuard tunnels: how a user's own computer becomes a member + * of the private network their Cloud VMs live on. + * + * `POST` enrolls the calling computer and returns a complete `wg-quick` config + * whose `PrivateKey` line is blank — the caller generated that key and keeps + * it. Enrolling is idempotent per device, so clients call it on every launch + * rather than remembering whether they have enrolled; a public key that does + * not match the record rotates the existing tunnel's keys in place, keeping the + * device's address on the network stable. + * + * This route is deliberately not gated behind the Pro paywall that machine + * creation uses. It provisions no paid resource, and an account whose + * subscription lapsed still needs to reach machines it already owns in order to + * get data off them. + */ + +/** Provider display names are short; a long one is the caller's mistake, not a reason to fail. */ +const MAX_DEVICE_NAME_LENGTH = 63; + +export async function POST(request: Request): Promise { + return withAuthedVmApiRoute( + request, + "/api/vm/tunnel", + { "cmux.vm.operation": "enroll_tunnel" }, + "/api/vm/tunnel failed", + async ({ user, span }) => { + const body = await parseLenientObjectBody(request); + const account = resolveVmRouteAccountScope(user, request); + if (!account.ok) return account.response; + + const provider = providerFromRequest(request, body); + if (!provider.ok) return provider.response; + + const clientPublicKey = optionalString(body.clientPublicKey ?? body.client_public_key); + if (!clientPublicKey || !isWireGuardPublicKey(clientPublicKey)) { + return vmErrorResponse({ + error: "vm_tunnel_invalid_key", + status: 400, + message: "clientPublicKey must be a base64-encoded 32-byte WireGuard public key.", + action: + "Generate a Curve25519 keypair on this computer, keep the private half, and send only " + + "the base64 public key. `wg genkey | tee private.key | wg pubkey` produces one.", + phase: "network", + details: { field: "clientPublicKey" }, + }); + } + + let deviceFingerprint: string | undefined; + try { + deviceFingerprint = optionalClientIdentifier( + body.deviceFingerprint ?? body.device_fingerprint, + "deviceFingerprint", + ); + } catch (err) { + return invalidDeviceFingerprint(err); + } + if (!deviceFingerprint) return missingDeviceFingerprint(); + + setSpanAttributes(span, { + "cmux.vm.provider": provider.id, + "cmux.vm.tunnel.device": deviceFingerprint, + }); + + const tunnel = await runVmWorkflow(enrollVmTunnel({ + userId: user.id, + provider: provider.id, + deviceFingerprint, + deviceName: deviceName(body), + clientPublicKey, + })); + setSpanAttributes(span, { + "cmux.vm.tunnel.id": tunnel.tunnelId, + "cmux.vm.tunnel.created": tunnel.created, + "cmux.vm.tunnel.rotated": tunnel.rotated, + }); + return jsonResponse(tunnelPayload(tunnel)); + }, + ); +} + +/** + * One computer's tunnel with `?deviceFingerprint=`, or the account's enrolled + * computers without it. The list carries no config — reading it must not be a + * way to collect other devices' access material. + */ +export async function GET(request: Request): Promise { + return withAuthedVmApiRoute( + request, + "/api/vm/tunnel", + { "cmux.vm.operation": "get_tunnel" }, + "/api/vm/tunnel failed", + async ({ user, span }) => { + const account = resolveVmRouteAccountScope(user, request); + if (!account.ok) return account.response; + + const url = new URL(request.url); + let deviceFingerprint: string | undefined; + try { + deviceFingerprint = optionalClientIdentifier( + url.searchParams.get("deviceFingerprint"), + "deviceFingerprint", + ); + } catch (err) { + return invalidDeviceFingerprint(err); + } + + if (!deviceFingerprint) { + const tunnels = await runVmWorkflow(listVmTunnels({ userId: user.id })); + return jsonResponse({ tunnels }); + } + + const provider = providerFromRequest(request, {}); + if (!provider.ok) return provider.response; + setSpanAttributes(span, { + "cmux.vm.provider": provider.id, + "cmux.vm.tunnel.device": deviceFingerprint, + }); + const tunnel = await runVmWorkflow(readVmTunnel({ + userId: user.id, + provider: provider.id, + deviceFingerprint, + })); + return jsonResponse(tunnelPayload(tunnel)); + }, + ); +} + +/** Unenroll a computer. The provider tunnel is deleted, so its config stops working at once. */ +export async function DELETE(request: Request): Promise { + return withAuthedVmApiRoute( + request, + "/api/vm/tunnel", + { "cmux.vm.operation": "revoke_tunnel" }, + "/api/vm/tunnel failed", + async ({ user, span }) => { + const account = resolveVmRouteAccountScope(user, request); + if (!account.ok) return account.response; + + const url = new URL(request.url); + const body = await parseLenientObjectBody(request); + let deviceFingerprint: string | undefined; + try { + deviceFingerprint = optionalClientIdentifier( + url.searchParams.get("deviceFingerprint") ?? body.deviceFingerprint ?? body.device_fingerprint, + "deviceFingerprint", + ); + } catch (err) { + return invalidDeviceFingerprint(err); + } + if (!deviceFingerprint) return missingDeviceFingerprint(); + + const provider = providerFromRequest(request, body); + if (!provider.ok) return provider.response; + setSpanAttributes(span, { + "cmux.vm.provider": provider.id, + "cmux.vm.tunnel.device": deviceFingerprint, + }); + const result = await runVmWorkflow(revokeVmTunnel({ + userId: user.id, + provider: provider.id, + deviceFingerprint, + })); + return jsonResponse(result); + }, + ); +} + +type ProviderResult = + | { readonly ok: true; readonly id: ReturnType } + | { readonly ok: false; readonly response: Response }; + +/** + * Which provider's network to enroll into. Networks are per-provider, so this + * has to be explicit rather than "whichever machine you have" — but in practice + * every caller takes the deployment default and the override exists for the + * same rollback reasons the rest of the VM API has one. + */ +function providerFromRequest(request: Request, body: Record): ProviderResult { + const raw = optionalString(body.provider) ?? new URL(request.url).searchParams.get("provider"); + if (!raw) return { ok: true, id: defaultProviderId() }; + if (isProviderId(raw)) return { ok: true, id: raw }; + return { + ok: false, + response: vmErrorResponse({ + error: "vm_invalid_provider", + status: 400, + message: "Unsupported Cloud VM service override.", + action: "Omit `provider` to use the default Cloud VM service.", + phase: "network", + details: { field: "provider" }, + }), + }; +} + +function deviceName(body: Record): string | null { + const raw = optionalString(body.deviceName ?? body.device_name); + return raw ? raw.slice(0, MAX_DEVICE_NAME_LENGTH) : null; +} + +function tunnelPayload(tunnel: VmTunnelDescriptor) { + return { + tunnelId: tunnel.tunnelId, + provider: tunnel.provider, + deviceFingerprint: tunnel.deviceFingerprint, + deviceName: tunnel.deviceName, + clientConfig: tunnel.clientConfig, + clientPublicKey: tunnel.clientPublicKey, + serverPublicKey: tunnel.serverPublicKey, + endpointHost: tunnel.endpointHost, + endpointPort: tunnel.endpointPort, + routes: [...tunnel.routes], + address: { ipv4: tunnel.addressV4, ipv6: tunnel.addressV6 }, + network: tunnel.network, + created: tunnel.created, + rotated: tunnel.rotated, + }; +} + +function invalidDeviceFingerprint(err: unknown): Response { + return vmErrorResponse({ + error: "invalid_request", + status: 400, + message: err instanceof Error ? err.message : "Invalid Cloud VM tunnel request.", + action: "Send a stable per-installation deviceFingerprint of 1-128 URL-safe characters.", + phase: "network", + details: { field: "deviceFingerprint" }, + }); +} + +function missingDeviceFingerprint(): Response { + return vmErrorResponse({ + error: "invalid_request", + status: 400, + message: "deviceFingerprint is required.", + action: + "Send a stable per-installation deviceFingerprint so this computer keeps the same address " + + "on the network across launches.", + phase: "network", + details: { field: "deviceFingerprint" }, + }); +} diff --git a/web/db/migrations/20260901120000_remove_blaxel_vm_provider/migration.sql b/web/db/migrations/20260901120000_remove_blaxel_vm_provider/migration.sql index c9d18295e593..3878433f11b0 100644 --- a/web/db/migrations/20260901120000_remove_blaxel_vm_provider/migration.sql +++ b/web/db/migrations/20260901120000_remove_blaxel_vm_provider/migration.sql @@ -13,10 +13,10 @@ -- Blaxel machines BEFORE applying this migration — afterwards nothing in the -- control plane can address them. -UPDATE "cloud_vms" SET "provider" = 'e2b' WHERE "provider" = 'blaxel'; -UPDATE "cloud_vm_usage_events" SET "provider" = 'e2b' WHERE "provider" = 'blaxel'; -UPDATE "cloud_vm_bases" SET "active_provider" = 'e2b' WHERE "active_provider" = 'blaxel'; -UPDATE "cloud_vm_base_generations" SET "provider" = 'e2b' WHERE "provider" = 'blaxel'; +UPDATE "cloud_vms" SET "provider" = 'e2b' WHERE "provider"::text = 'blaxel'; +UPDATE "cloud_vm_usage_events" SET "provider" = 'e2b' WHERE "provider"::text = 'blaxel'; +UPDATE "cloud_vm_bases" SET "active_provider" = 'e2b' WHERE "active_provider"::text = 'blaxel'; +UPDATE "cloud_vm_base_generations" SET "provider" = 'e2b' WHERE "provider"::text = 'blaxel'; ALTER TYPE "vm_provider" RENAME TO "vm_provider_old"; CREATE TYPE "vm_provider" AS ENUM ('e2b', 'freestyle', 'daytona'); diff --git a/web/db/migrations/20260902090000_cloud_vm_private_networks/migration.sql b/web/db/migrations/20260902090000_cloud_vm_private_networks/migration.sql new file mode 100644 index 000000000000..30c36b92747e --- /dev/null +++ b/web/db/migrations/20260902090000_cloud_vm_private_networks/migration.sql @@ -0,0 +1,59 @@ +-- Per-user private networks and the WireGuard tunnels that reach into them. +-- +-- A Cloud VM used to be reachable only at its public IPv6 with the cmux-tui +-- daemon port open to the Internet. It now joins the one private network that +-- belongs to its owner, and the owner's Mac joins the same network over a +-- WireGuard tunnel, so the daemon port needs no public exposure at all. +-- +-- Both tables are control-plane bookkeeping over provider-side resources: the +-- network and tunnel themselves live at the provider, keyed by the ids stored +-- here. No secret is stored — the tunnel's private key is generated on, and +-- never leaves, the user's Mac. + +CREATE TABLE IF NOT EXISTS "cloud_vm_networks" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "provider" "vm_provider" NOT NULL, + "provider_network_id" text NOT NULL, + "slug" text, + "cidr" text, + "cidr_v6" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + +-- One network per owner per provider: create races resolve by reading back the +-- winner's row rather than provisioning a second network nobody is on. +CREATE UNIQUE INDEX IF NOT EXISTS "cloud_vm_networks_user_provider_unique" + ON "cloud_vm_networks" ("user_id", "provider"); +CREATE UNIQUE INDEX IF NOT EXISTS "cloud_vm_networks_provider_network_id_unique" + ON "cloud_vm_networks" ("provider", "provider_network_id"); + +CREATE TABLE IF NOT EXISTS "cloud_vm_tunnels" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "network_id" uuid NOT NULL, + "provider" "vm_provider" NOT NULL, + "provider_tunnel_id" text NOT NULL, + "device_fingerprint" text NOT NULL, + "device_name" text, + "client_public_key" text NOT NULL, + "address_v4" text, + "address_v6" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "last_config_issued_at" timestamp with time zone, + "revoked_at" timestamp with time zone, + CONSTRAINT "cloud_vm_tunnels_network_id_cloud_vm_networks_id_fk" + FOREIGN KEY ("network_id") REFERENCES "cloud_vm_networks"("id") ON DELETE cascade +); + +-- A device holds at most one live tunnel; revoked rows stay for audit, so the +-- uniqueness is partial rather than absolute. +CREATE UNIQUE INDEX IF NOT EXISTS "cloud_vm_tunnels_user_device_unique" + ON "cloud_vm_tunnels" ("user_id", "device_fingerprint") + WHERE "revoked_at" IS NULL; +CREATE UNIQUE INDEX IF NOT EXISTS "cloud_vm_tunnels_provider_tunnel_id_unique" + ON "cloud_vm_tunnels" ("provider", "provider_tunnel_id"); +CREATE INDEX IF NOT EXISTS "cloud_vm_tunnels_network_idx" + ON "cloud_vm_tunnels" ("network_id"); diff --git a/web/db/schema.ts b/web/db/schema.ts index e779c1ea55c3..1b16621f6e24 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -203,6 +203,82 @@ export const accountMutationLeases = pgTable( ], ); +/** + * The one private network that holds every Cloud VM belonging to a user. + * + * Machines are attached to it at create, and the user's own computer reaches + * them through a WireGuard tunnel attached to the same network — so the + * cmux-tui daemon needs no public inbound port at all. One row per + * (user, provider): the network is the user's, not a machine's, and it + * outlives every machine on it. + */ +export const cloudVmNetworks = pgTable( + "cloud_vm_networks", + { + id: uuid("id").defaultRandom().primaryKey(), + userId: text("user_id").notNull(), + provider: vmProvider("provider").notNull(), + /** The provider's id for the network (Freestyle `vpc-…`). */ + providerNetworkId: text("provider_network_id").notNull(), + /** The slug we asked the provider for, so an orphan is traceable to its owner. */ + slug: text("slug"), + cidr: text("cidr"), + cidrV6: text("cidr_v6"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + uniqueIndex("cloud_vm_networks_user_provider_unique").on(table.userId, table.provider), + uniqueIndex("cloud_vm_networks_provider_network_id_unique") + .on(table.provider, table.providerNetworkId), + ], +); + +/** + * One WireGuard tunnel per (user, device): the user's Mac as a member of their + * own private network. + * + * The client keypair is generated on the Mac and only its public half is ever + * sent here, so no row in this table can be used to impersonate a device — and + * a config re-issued to a reinstalled app is useless without the private key + * still in that Mac's Keychain. `revokedAt` is set when the device is + * unenrolled; the provider-side tunnel is deleted in the same workflow. + */ +export const cloudVmTunnels = pgTable( + "cloud_vm_tunnels", + { + id: uuid("id").defaultRandom().primaryKey(), + userId: text("user_id").notNull(), + networkId: uuid("network_id") + .notNull() + .references(() => cloudVmNetworks.id, { onDelete: "cascade" }), + provider: vmProvider("provider").notNull(), + /** The provider's id for the tunnel (Freestyle `tun-…`). */ + providerTunnelId: text("provider_tunnel_id").notNull(), + /** Stable per-installation device id minted by the Mac app. */ + deviceFingerprint: text("device_fingerprint").notNull(), + /** Human label for the device, shown when listing enrolled computers. */ + deviceName: text("device_name"), + /** Base64 Curve25519 public key. The private half never leaves the Mac. */ + clientPublicKey: text("client_public_key").notNull(), + /** The tunnel's address inside the network — what the user's VMs see as the Mac. */ + addressV4: text("address_v4"), + addressV6: text("address_v6"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + lastConfigIssuedAt: timestamp("last_config_issued_at", { withTimezone: true }), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + }, + (table) => [ + uniqueIndex("cloud_vm_tunnels_user_device_unique") + .on(table.userId, table.deviceFingerprint) + .where(sql`${table.revokedAt} is null`), + uniqueIndex("cloud_vm_tunnels_provider_tunnel_id_unique") + .on(table.provider, table.providerTunnelId), + index("cloud_vm_tunnels_network_idx").on(table.networkId), + ], +); + export const cloudVmLeases = pgTable( "cloud_vm_leases", { diff --git a/web/services/vms/README.md b/web/services/vms/README.md index 46b4c9412f3d..8f3b3d241f04 100644 --- a/web/services/vms/README.md +++ b/web/services/vms/README.md @@ -10,6 +10,7 @@ services/vms/ billingGateway.ts Stack Auth VM create-credit reservations entitlements.ts Team plan and active VM limit resolution drivers/ Provider SDK adapter for Freestyle + privateNetwork.ts Per-user private networks and WireGuard tunnel enrollment images/ Checked-in known-good provider image manifest errors.ts Typed Effect errors for VM workflows config.ts Runtime kill switches and deployment guards @@ -28,6 +29,9 @@ db/ - `/api/vm/:id`, authenticated `DELETE` destroy. - `/api/vm/:id/exec`, authenticated `POST` command execution. - `/api/vm/:id/attach-endpoint`, authenticated `POST` PTY/RPC attach lease minting. +- `/api/vm/tunnel`, authenticated `POST` WireGuard tunnel enrollment for the calling + computer, `GET` tunnel/list state, `DELETE` unenrollment. Not Pro-gated: a lapsed + subscription must still be able to reach machines it already owns. There is no raw actor or provider protocol endpoint. The old `/api/rivet/*` gateway has been removed. @@ -55,6 +59,10 @@ The auth regression tests live in `web/tests/vm-route-auth.test.ts`. They verify - `cloud_vms` owns VM lifecycle state, provider ids, image ids, billing team/plan ids, and per-user idempotency keys. - `cloud_vm_leases` stores hashed PTY/RPC/SSH lease tokens, provider identity handles, session ids, expiry, and revocation timestamps. - `cloud_vm_usage_events` records lifecycle, attach, SSH, and exec events with billing team/plan ids for billing and audit rollups. +- `cloud_vm_networks` records the one provider private network per (user, provider). +- `cloud_vm_tunnels` records each computer's WireGuard tunnel: provider tunnel id, device + fingerprint, the client's **public** key, and its address inside the network. No private + key is ever sent to or stored by the backend. Create idempotency is enforced by the partial unique index on `(user_id, idempotency_key)`. A retry with the same key returns the existing VM after provisioning succeeds. A concurrent retry while the first create is still provisioning returns `409` instead of starting a second paid provider VM. @@ -201,6 +209,12 @@ Set these Vercel environment variables per production/staging environment: `CMUX_VM_ALLOW_FREE_PROVISIONING` is absent; prefer the clearly named allow switch for new deployments. - `CMUX_VM_FREESTYLE_ENABLED`, per-provider Freestyle create kill switch. +- `CMUX_VM_PRIVATE_NETWORK_ENABLED`, private networking rollback switch. Unset/`1`: new + Freestyle machines join their owner's VPC, open no public inbound port, and are + attached at their private VPC address through the owner's WireGuard tunnel. `0`: later + creates revert to the public-IPv6 posture (inbound 1337 open). Machines keep working + across a flip either way, because reachability is resolved from the addresses each + machine actually holds. - `CMUX_VM_ALLOWED_ORIGINS`, optional comma-separated extra origins allowed for cookie mutations. - `FREESTYLE_API_KEY`, Freestyle provider key. - `FREESTYLE_SANDBOX_SNAPSHOT`, Freestyle snapshot id. @@ -340,10 +354,18 @@ commit's `https://files.cmux.com/cmux-tui//manifest.json` instead of the `latest`. There is no HTTP ingress proxy to arbitrary VM ports on the public platform (a TLS edge rule -needs a customer-verified domain), so the daemon is reached directly at the VM's stable -public IPv6: `ws://[]:1337/v1/link`. Creates state a mandatory `firewall` — -outbound open, inbound only 1337 — and the daemon binds dual-stack (`[::]:1337`), re-asserted -on every attach-time heal because a daemon bound `0.0.0.0` is unreachable at that address. +needs a customer-verified domain), so the daemon is reached directly at a VM address. + +**Private networking is the default.** Every Freestyle machine joins the one VPC that +belongs to its owner (provisioned on first create, slug `cmux-net-`); the owner's +computers join the same VPC over WireGuard tunnels (`/api/vm/tunnel`, `cmux vpn up`). The +route is then the VM's *private* address — `ws://[]:1337/v1/link` — and creates +state outbound-only firewall rules: no public inbound port at all. The VPC's single +members-reach-each-other rule is what admits the owner's other machines and tunnels to the +daemon port. Machines created before private networking (or while +`CMUX_VM_PRIVATE_NETWORK_ENABLED=0`) keep the older posture: inbound 1337 open and the +route at the stable public IPv6. The daemon binds dual-stack (`[::]:1337`), re-asserted on +every attach-time heal, which is also what makes the VPC address reachable. The Noise handshake encrypts and authenticates the session end to end, so carrier TLS is not required; the route token exists only for the lease ledger. Creates take no ports field and no create-time env, so the coderouter model-plane vars are delivered by writing the persisted diff --git a/web/services/vms/config.ts b/web/services/vms/config.ts index 8d8c5d73cdde..828c31bf5830 100644 --- a/web/services/vms/config.ts +++ b/web/services/vms/config.ts @@ -40,6 +40,25 @@ export function providerEnabledEnvKey(provider: ProviderId): string { } } +/** + * Whether new machines join their owner's private network — the default. + * + * This is one switch rather than two because the network and the closed + * inbound port are the same decision: a machine placed on the VPC is reached + * at its private address and opens no public port, and a machine kept off it + * is reached at its public IPv6 and must open one. Splitting them would let a + * deployment configure a machine that is on the network but still publicly + * exposed, or on the network but addressed publicly and therefore unreachable. + * + * Setting `CMUX_VM_PRIVATE_NETWORK_ENABLED=0` is the complete rollback: later + * creates go back to the public-IPv6 posture, and machines already on a network + * keep working, because reachability is resolved per machine from the addresses + * it actually holds. + */ +export function vmPrivateNetworkEnabled(env: VmRuntimeEnv = process.env): boolean { + return !isFalseFlag(env.CMUX_VM_PRIVATE_NETWORK_ENABLED); +} + export function isDeployedRuntime(env: VmRuntimeEnv = process.env): boolean { return env.VERCEL === "1" || env.VERCEL_ENV === "production" || diff --git a/web/services/vms/drivers/freestyle.ts b/web/services/vms/drivers/freestyle.ts index 70ff4dbf1fc4..bc04bcb5617b 100644 --- a/web/services/vms/drivers/freestyle.ts +++ b/web/services/vms/drivers/freestyle.ts @@ -1,4 +1,4 @@ -import { Freestyle, FreestyleApiError, type VmData, type Vm } from "freestyle"; +import { Freestyle, FreestyleApiError, type TunnelData, type VmData, type Vm, type VpcData } from "freestyle"; import { randomBytes } from "node:crypto"; import { ProviderError, @@ -10,11 +10,16 @@ import { type CmuxRemoteAttachOptions, type CmuxRemoteEndpoint, type CreateOptions, + type CreateProviderTunnelOptions, type ExecOptions, type ExecResult, + type ProviderNetwork, + type ProviderTunnel, + type RestoreOptions, type SSHEndpoint, type SnapshotRef, type VMHandle, + type VMPrivateNetworking, type VMProvider, type VMStatus, } from "./types"; @@ -44,13 +49,25 @@ import { // Machines attach through the cmux-tui remote daemon (transport `cmux-remote`, // docs/cloud-cmux-tui-daemon.md). The API has // no HTTP ingress proxy to arbitrary VM ports (TLS edge rules need a -// customer-verified domain), so the route is the VM's stable public IPv6 -// straight to the daemon: `ws://[]:1337/v1/link`. The daemon's -// Noise handshake encrypts and authenticates the session end to end (carrier -// TLS is not required and the route token only feeds the lease ledger, exactly -// like E2B's public proxy). The daemon must therefore bind dual-stack: the -// baked systemd unit sets CMUX_TUI_REMOTE_WS_BIND=[::]:1337 and the driver -// re-asserts it on heal. +// customer-verified domain), so the route addresses the daemon directly. +// +// Every machine joins the one VPC that belongs to its owner, and the owner's +// Mac joins the same VPC over a WireGuard tunnel, so the route is the VM's +// *private* address: `ws://[]:1337/v1/link`. Nothing on that path is +// public — the machine opens no inbound port at all, and a caller with no +// tunnel up simply cannot reach it. Machines created before private networking +// (and any created while CMUX_VM_PRIVATE_NETWORK_ENABLED=0 rolls it back) keep +// the older posture: inbound 1337 open to the Internet and the route pointed at +// the stable public IPv6. Which posture a machine has is read from the machine +// itself, never from the flag, so a rollback cannot strand a machine that is +// already on a network. +// +// The daemon's Noise handshake encrypts and authenticates the session end to +// end either way (carrier TLS is not required and the route token only feeds +// the lease ledger). The daemon must bind dual-stack: the baked systemd unit +// sets CMUX_TUI_REMOTE_WS_BIND=[::]:1337 and the driver re-asserts it on heal — +// which is also what makes a VPC address reachable, since it is neither +// loopback nor the public NIC. // // Creates take NO ports field, NO create-time env, and NO systemd injection; // `firewall` is mandatory. Model-plane env is delivered by writing the @@ -102,35 +119,128 @@ function freestyleClient(timeoutMs = DEFAULT_TIMEOUT_MS): Freestyle { } /** - * Outbound open (package installs, files.cmux.com, agents); inbound only the - * cmux-tui daemon port. The mandatory `firewall` field defaults to NOTHING — - * no outbound, no inbound — so both rules are stated. Session auth on 1337 is - * the daemon's Noise device enrollment; the platform edge closing every other - * port is the same posture the e2b driver builds by hand with iptables. + * The machine's own rules. The mandatory `firewall` field defaults to NOTHING — + * no outbound, no inbound — so every rule is stated here. + * + * Outbound is always open (package installs, files.cmux.com, agents). Inbound + * is the interesting half: + * + * - On a machine attached to its owner's VPC, **no inbound rule is written at + * all**. Reaching the daemon is admitted by the VPC's own members-reach-each- + * other rule (see `FREESTYLE_NETWORK_FIREWALL_RULES`), which covers the + * owner's other machines and the owner's attached tunnel and nothing else. + * Opening 1337 to the Internet as well would hand back exactly the exposure + * the VPC exists to remove. + * - Without a VPC — a machine created before private networking, or one + * created while CMUX_VM_PRIVATE_NETWORK_ENABLED=0 rolls the feature back — + * inbound 1337 is opened publicly, because that is the only way such a + * machine is reachable at all. Session auth is the daemon's Noise device + * enrollment, the same posture the e2b driver builds by hand with iptables. */ -export function freestyleFirewallRules() { - return [ - { action: "allow" as const, source: {}, destination: { public: true as const } }, - { - action: "allow" as const, - source: { public: true as const }, - destination: { port: CMUX_TUI_PORT, protocol: "tcp" as const }, - }, - ]; +export function freestyleFirewallRules(options?: { publicDaemonIngress?: boolean }) { + const rules: Array<{ + action: "allow"; + source: { public?: true }; + destination: { public?: true; port?: number; protocol?: "tcp" }; + }> = [{ action: "allow", source: {}, destination: { public: true } }]; + if (options?.publicDaemonIngress) { + rules.push({ + action: "allow", + source: { public: true }, + destination: { port: CMUX_TUI_PORT, protocol: "tcp" }, + }); + } + return rules; } -/** `ws://[]:1337/v1/link` — see the ingress note at the top of this file. */ -export function freestyleCmuxRemoteRoute(publicIpv6: string | null | undefined, vmId: string): string { - const ipv6 = publicIpv6?.trim(); +/** + * The rules created with an owner's VPC and deleted with it: members reach each + * other, and nothing else is implied. An attached tunnel counts as a member, so + * this one rule is what admits the owner's Mac to their machines' daemon port. + * + * Machines still state their own outbound rule; a network rule cannot grant it, + * because a bare endpoint inside a network's `firewall` block means the network + * being created rather than the Internet. + */ +export const FREESTYLE_NETWORK_FIREWALL_RULES = [ + { action: "allow" as const, source: {}, destination: {} }, +]; + +/** The addresses a route can be built from — the shape `vm.data()` returns. */ +export type FreestyleRouteAddresses = { + readonly publicIpv6?: string | null; + readonly vpcs?: readonly FreestyleNetworkAddress[] | null; + /** The deprecated alias for `vpcs`; read when an older response omits it. */ + readonly networks?: readonly FreestyleNetworkAddress[] | null; +}; + +type FreestyleNetworkAddress = { + readonly ipv4?: string | null; + readonly ipv6?: string | null; +}; + +/** + * Where to dial the machine's daemon: its private VPC address when it has one, + * otherwise its stable public IPv6. + * + * Private wins unconditionally, and deliberately never falls back: a machine on + * a VPC has no public inbound rule, so a public route for it would not be a + * degraded path but a guaranteed timeout with a misleading address in the + * error. IPv6 is preferred within the network only because the daemon binds + * `[::]` — the IPv4 address is the honest second choice on a network whose v6 + * allocation was declined, not a fallback for an unreachable v6. + */ +export function freestyleCmuxRemoteRoute(addresses: FreestyleRouteAddresses, vmId: string): string { + const networks = addresses.vpcs ?? addresses.networks ?? []; + for (const network of networks) { + const ipv6 = network.ipv6?.trim(); + if (ipv6) return `ws://[${ipv6}]:${CMUX_TUI_PORT}/v1/link`; + } + for (const network of networks) { + const ipv4 = network.ipv4?.trim(); + if (ipv4) return `ws://${ipv4}:${CMUX_TUI_PORT}/v1/link`; + } + if (networks.length > 0) { + throw new ProviderError( + "freestyle", + `VM ${vmId} is attached to a private network but holds no address on it, so its cmux-tui daemon is unreachable`, + ); + } + const ipv6 = addresses.publicIpv6?.trim(); if (!ipv6) { throw new ProviderError( "freestyle", - `VM ${vmId} has no public IPv6 address, so its cmux-tui daemon is unreachable (the platform has no HTTP ingress to arbitrary ports)`, + `VM ${vmId} has no private network address and no public IPv6 address, so its cmux-tui daemon is unreachable (the platform has no HTTP ingress to arbitrary ports)`, ); } return `ws://[${ipv6}]:${CMUX_TUI_PORT}/v1/link`; } +/** The Freestyle VPC/tunnel records mapped onto the driver-neutral shapes. */ +export function mapFreestyleNetwork(data: VpcData): ProviderNetwork { + return { + id: data.id, + slug: data.slug ?? null, + cidr: data.cidr ?? null, + cidrV6: data.cidrV6, + }; +} + +export function mapFreestyleTunnel(data: TunnelData, networkId: string): ProviderTunnel { + const attachment = data.attachments.find((entry) => entry.vpcId === networkId); + return { + id: data.tunnelId ?? data.id, + clientConfig: data.clientConfig, + clientPublicKey: data.clientPublicKey, + serverPublicKey: data.serverPublicKey, + endpointHost: data.endpointHost ?? null, + endpointPort: data.endpointPort, + routes: data.routes ?? [], + addressV4: attachment?.ipv4 ?? null, + addressV6: attachment?.ipv6 ?? null, + }; +} + /** * The persisted model-plane env file, byte-compatible with what * /etc/cmux/agent-config.sh itself writes from a boot env: shells that see no @@ -215,6 +325,156 @@ function isNotFound(err: unknown): boolean { return err instanceof FreestyleApiError && (err.status === 404 || err.code === "NOT_FOUND"); } +/** + * The Freestyle-side half of cmux private networking: one VPC per owner, and + * one WireGuard tunnel per owner's computer attached to it. + * + * These are account-scoped resources, not machine-scoped ones — they outlive + * every machine on them — so nothing here takes a VM id. The control plane + * owns the mapping from cmux user to slug; this class only knows how to make + * the provider match what it is asked for. + */ +class FreestylePrivateNetworking implements VMPrivateNetworking { + async ensureNetwork(options: { slug: string; displayName?: string }): Promise { + const slug = options.slug.trim(); + if (!slug) throw new ProviderError("freestyle", "ensureNetwork requires a slug"); + return withVmSpan( + "cmux.vm.provider.ensure_network", + { "cmux.vm.provider": "freestyle", "cmux.vm.operation": "ensure_network", "cmux.vm.network.slug": slug }, + async (span) => { + const fs = freestyleClient(); + const existing = await this.readNetworkBySlug(fs, slug); + if (existing) { + setSpanAttributes(span, { "cmux.vm.network.id": existing.id, "cmux.vm.network.created": false }); + return existing; + } + try { + // The CIDRs are deliberately left to the platform: a derived /24 out + // of 10.0.0.0/8 and a unique-local /64 both sit inside a tunnel's + // default routes, so no cmux code has to allocate address space or + // keep two accounts from colliding. + const { data } = await fs.vpc.create({ + slug, + displayName: options.displayName, + firewall: { rules: FREESTYLE_NETWORK_FIREWALL_RULES }, + }); + setSpanAttributes(span, { "cmux.vm.network.id": data.id, "cmux.vm.network.created": true }); + return mapFreestyleNetwork(data); + } catch (err) { + // Two machines created at once both miss the read and both create. + // The slug is unique per account, so the loser is told the name is + // taken — and the winner's network is the right answer for both. + const raced = await this.readNetworkBySlug(fs, slug); + if (raced) { + setSpanAttributes(span, { "cmux.vm.network.id": raced.id, "cmux.vm.network.created": false }); + return raced; + } + throw new ProviderError("freestyle", `ensureNetwork(${slug})`, err); + } + }, + ); + } + + async getNetwork(networkId: string): Promise { + try { + return mapFreestyleNetwork(await freestyleClient().vpc.get(networkId)); + } catch (err) { + if (isNotFound(err)) return null; + throw new ProviderError("freestyle", `getNetwork(${networkId})`, err); + } + } + + async deleteNetwork(networkId: string): Promise { + try { + await freestyleClient().vpc.delete(networkId); + } catch (err) { + if (isNotFound(err)) return; // already gone; delete is idempotent + throw new ProviderError("freestyle", `deleteNetwork(${networkId})`, err); + } + } + + async createTunnel(options: CreateProviderTunnelOptions): Promise { + const clientPublicKey = options.clientPublicKey.trim(); + if (!clientPublicKey) { + throw new ProviderError("freestyle", "createTunnel requires the client's public key"); + } + return withVmSpan( + "cmux.vm.provider.create_tunnel", + { + "cmux.vm.provider": "freestyle", + "cmux.vm.operation": "create_tunnel", + "cmux.vm.network.id": options.networkId, + }, + async (span) => { + try { + // clientPublicKey is always supplied, so the platform never mints or + // holds a private key: the config comes back with a blank PrivateKey + // for the Mac to fill in from its own Keychain. + const data = await freestyleClient().tunnels.create({ + slug: options.slug, + displayName: options.displayName, + clientPublicKey, + vpcs: [{ vpcId: options.networkId }], + }); + const tunnel = mapFreestyleTunnel(data, options.networkId); + setSpanAttributes(span, { "cmux.vm.tunnel.id": tunnel.id }); + return tunnel; + } catch (err) { + throw new ProviderError("freestyle", `createTunnel(${options.slug})`, err); + } + }, + ); + } + + async getTunnel(tunnelId: string, networkId: string): Promise { + try { + const fs = freestyleClient(); + let data = await fs.tunnels.get(tunnelId); + if (!data.attachments.some((entry) => entry.vpcId === networkId)) { + // The tunnel outlives its attachments, so a network detached by hand + // (or replaced after a network delete) leaves a live tunnel that routes + // nowhere. Re-attaching is what makes the config the caller already + // holds start working again, with nothing to re-download. + data = await fs.tunnels.attachVpc(tunnelId, networkId); + } + return mapFreestyleTunnel(data, networkId); + } catch (err) { + if (isNotFound(err)) return null; + throw new ProviderError("freestyle", `getTunnel(${tunnelId})`, err); + } + } + + async rotateTunnelKey(tunnelId: string, clientPublicKey: string, networkId: string): Promise { + const key = clientPublicKey.trim(); + if (!key) throw new ProviderError("freestyle", "rotateTunnelKey requires the client's public key"); + try { + const data = await freestyleClient().tunnels.rotateKey(tunnelId, { clientPublicKey: key }); + return mapFreestyleTunnel(data, networkId); + } catch (err) { + throw new ProviderError("freestyle", `rotateTunnelKey(${tunnelId})`, err); + } + } + + async deleteTunnel(tunnelId: string): Promise { + try { + await freestyleClient().tunnels.delete(tunnelId); + } catch (err) { + if (isNotFound(err)) return; // already gone; delete is idempotent + throw new ProviderError("freestyle", `deleteTunnel(${tunnelId})`, err); + } + } + + /** A network by slug, or null when the account has none under that name. */ + private async readNetworkBySlug(fs: Freestyle, slug: string): Promise { + try { + return mapFreestyleNetwork(await fs.vpc.get(slug)); + } catch (err) { + if (isNotFound(err)) return null; + throw new ProviderError("freestyle", `getNetwork(${slug})`, err); + } + } +} + function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } @@ -234,6 +494,8 @@ export class FreestyleProvider implements VMProvider { /** The only session transport: the cmux-tui remote daemon (`openCmuxRemote`). */ readonly attachTransports: readonly AttachTransport[] = ["cmux-remote"]; + readonly privateNetworking: VMPrivateNetworking = new FreestylePrivateNetworking(); + async create(options: CreateOptions): Promise { const image = options.image.trim(); if (!image) { @@ -250,13 +512,18 @@ export class FreestyleProvider implements VMProvider { async (span) => { try { const fs = freestyleClient(CREATE_TIMEOUT_MS); + const networkId = options.network?.id; const { vm, vmId } = await fs.vms.create({ snapshotId: image, displayName: "cmux Cloud VM", metadata: { cmux: "cloud" }, - firewall: { rules: freestyleFirewallRules() }, + firewall: { rules: freestyleFirewallRules({ publicDaemonIngress: !networkId }) }, + ...(networkId ? { vpcs: [{ vpcId: networkId, ipv4: true, ipv6: true }] } : {}), + }); + setSpanAttributes(span, { + "cmux.vm.id": vmId, + "cmux.vm.network.private": !!networkId, }); - setSpanAttributes(span, { "cmux.vm.id": vmId }); try { await this.bootstrapCmuxTui(vm, vmId, options.envs); } catch (err) { @@ -272,7 +539,14 @@ export class FreestyleProvider implements VMProvider { status: "running" as const, image, createdAt: Date.now(), - providerMetadata: { ...(options.providerMetadata ?? {}) }, + // The network id is persisted so an operator can trace a machine to + // its owner's VPC without a provider round trip. Attach still reads + // the live address from vm.data(): this records which network the + // machine was placed on, never where to dial it. + providerMetadata: { + ...(options.providerMetadata ?? {}), + ...(networkId ? { networkId } : {}), + }, }; } catch (err) { throw err instanceof ProviderError ? err : new ProviderError("freestyle", `create(${image}) failed`, err); @@ -409,7 +683,7 @@ export class FreestyleProvider implements VMProvider { ); } - async restore(snapshotId: string): Promise { + async restore(snapshotId: string, options?: RestoreOptions): Promise { return withVmSpan( "cmux.vm.provider.restore", { @@ -421,13 +695,18 @@ export class FreestyleProvider implements VMProvider { async (span) => { try { const fs = freestyleClient(CREATE_TIMEOUT_MS); + const networkId = options?.network?.id; const { vm, vmId } = await fs.vms.create({ snapshotId, displayName: "cmux Cloud VM", metadata: { cmux: "cloud" }, - firewall: { rules: freestyleFirewallRules() }, + firewall: { rules: freestyleFirewallRules({ publicDaemonIngress: !networkId }) }, + ...(networkId ? { vpcs: [{ vpcId: networkId, ipv4: true, ipv6: true }] } : {}), + }); + setSpanAttributes(span, { + "cmux.vm.id": vmId, + "cmux.vm.network.private": !!networkId, }); - setSpanAttributes(span, { "cmux.vm.id": vmId }); // The snapshot carries the installed binary and the persisted // model-plane file; heal best-effort so the machine is attach-ready // without failing restore on a transient error. @@ -438,6 +717,7 @@ export class FreestyleProvider implements VMProvider { status: "running" as const, image: snapshotId, createdAt: Date.now(), + ...(networkId ? { providerMetadata: { networkId } } : {}), }; } catch (err) { throw new ProviderError("freestyle", `restore(${snapshotId})`, err); @@ -455,7 +735,8 @@ export class FreestyleProvider implements VMProvider { const fs = freestyleClient(CMUX_TUI_INSTALL_TIMEOUT_MS + EXEC_OVERHEAD_TIMEOUT_MS); const vm = fs.vms.ref(vmId); const data = await vm.data(); - const route = freestyleCmuxRemoteRoute(data.publicIpv6, vmId); + const route = freestyleCmuxRemoteRoute(data, vmId); + span.setAttribute("cmux.vm.network.private", (data.vpcs ?? data.networks ?? []).length > 0); await this.ensureCmuxTuiRunning(vm, vmId); const invoke = this.cmuxTuiInvoke(vm); // Direct-IPv6 carries no URL token; this one exists only for the diff --git a/web/services/vms/drivers/types.ts b/web/services/vms/drivers/types.ts index bc00866ca45f..45776288968c 100644 --- a/web/services/vms/drivers/types.ts +++ b/web/services/vms/drivers/types.ts @@ -88,6 +88,24 @@ export type CreateOptions = { * Providers without machine-level env support ignore it. */ envs?: Readonly>; + /** + * The owner's private network to attach the machine to. When present the + * machine takes an address on it and its session daemon is reachable only + * from other members — the owner's other machines, and the owner's computer + * over its WireGuard tunnel — so the driver opens no public inbound port. + * Providers without `privateNetworking` ignore it. + */ + network?: ProviderNetworkRef; +}; + +/** Enough of a provider network to attach a machine or a tunnel to it. */ +export type ProviderNetworkRef = { + readonly id: string; +}; + +export type RestoreOptions = { + /** The owner's private network; see {@link CreateOptions.network}. */ + network?: ProviderNetworkRef; }; export type SSHEndpoint = { @@ -249,8 +267,91 @@ export interface VmCapabilities { readonly fork: boolean; } +/** A private network that every machine belonging to one user shares. */ +export type ProviderNetwork = { + readonly id: string; + readonly slug: string | null; + /** IPv4 CIDR, when the network has one. */ + readonly cidr: string | null; + /** IPv6 CIDR. Always present: provider networks are dual-stack. */ + readonly cidrV6: string; +}; + +/** + * A WireGuard tunnel: one of the owner's computers as a member of their + * private network. + * + * `clientConfig` is a complete `wg-quick` config whose `PrivateKey` is blank — + * cmux always supplies its own public key, so the provider never mints (or + * sees) a private key, and the client fills its own in from its keystore. + */ +export type ProviderTunnel = { + readonly id: string; + readonly clientConfig: string; + readonly clientPublicKey: string; + readonly serverPublicKey: string; + readonly endpointHost: string | null; + readonly endpointPort: number; + /** The client's `AllowedIPs` — every range it routes through the tunnel. */ + readonly routes: readonly string[]; + /** The tunnel's address inside the attached network, i.e. what the VMs see. */ + readonly addressV4: string | null; + readonly addressV6: string | null; +}; + +export type CreateProviderTunnelOptions = { + readonly slug: string; + readonly displayName?: string; + /** Base64 Curve25519 public key. Required: cmux never lets the provider mint a private key. */ + readonly clientPublicKey: string; + readonly networkId: string; +}; + +/** + * Private networking, as a whole-account capability rather than a per-machine + * one: the network and the tunnels into it outlive every machine on them, so + * they are addressed by owner, not by VM id. + * + * A driver that does not implement this leaves `privateNetworking` undefined, + * and its machines keep whatever reachability the driver gives them directly. + */ +export interface VMPrivateNetworking { + /** + * The owner's network, created if it does not exist yet. Must be idempotent + * under concurrent calls with the same slug: two machines created at once + * must land on one network, not two. + */ + ensureNetwork(options: { slug: string; displayName?: string }): Promise; + /** Read a network back, or null when it no longer exists at the provider. */ + getNetwork(networkId: string): Promise; + /** Delete a network. Must succeed when it is already gone. */ + deleteNetwork(networkId: string): Promise; + /** Create a tunnel with the network already attached. */ + createTunnel(options: CreateProviderTunnelOptions): Promise; + /** + * Read a tunnel back with its address inside `networkId`, re-attaching the + * network if the attachment is missing. Null when the tunnel is gone at the + * provider, which is how a stale control-plane row is detected. + */ + getTunnel(tunnelId: string, networkId: string): Promise; + /** + * Replace the tunnel's keys, keeping its id and every attachment. Used when a + * client presents a public key that does not match the recorded one — a + * reinstalled app that lost its private key. + */ + rotateTunnelKey(tunnelId: string, clientPublicKey: string, networkId: string): Promise; + /** Delete a tunnel. Must succeed when it is already gone. */ + deleteTunnel(tunnelId: string): Promise; +} + export interface VMProvider { readonly id: ProviderId; + + /** + * Per-owner private networks and the WireGuard tunnels into them. Undefined + * on drivers whose machines are only reachable at a public address. + */ + readonly privateNetworking?: VMPrivateNetworking; /** * Optional-operation support. A driver that implements `snapshot`/`restore` only to * throw NotImplementedError declares that here; `fork` defaults to whether the method @@ -289,7 +390,13 @@ export interface VMProvider { openPort?(vmId: string, port: number): Promise<{ url: string; token: string; openUrl: string; expiresAtMs?: number }>; snapshot(vmId: string, name?: string): Promise; - restore(snapshotId: string): Promise; + /** + * Boot a new machine from a snapshot. `options.network` places it on the + * owner's private network exactly as `create` does — a restored machine is a + * machine like any other, and one restored outside the network would be the + * only unreachable one in the account. + */ + restore(snapshotId: string, options?: RestoreOptions): Promise; fork?(vmId: string): Promise; // Session transports this driver supports. Undefined means the legacy set (`websocket` diff --git a/web/services/vms/errors.ts b/web/services/vms/errors.ts index fd841d459377..beb644a3796e 100644 --- a/web/services/vms/errors.ts +++ b/web/services/vms/errors.ts @@ -25,6 +25,25 @@ export class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ readonly vmId: string; }> {} +/** + * A private-network or tunnel operation on a deployment that does not serve + * one — the provider has no `privateNetworking`, or + * `CMUX_VM_PRIVATE_NETWORK_ENABLED=0` has rolled the feature back. + * + * Distinct from {@link VmOperationUnsupportedError} because the caller's next + * move is different: this is a deployment that will not give *any* caller a + * tunnel, so a client should stop offering to set one up rather than retry. + */ +export class VmPrivateNetworkUnavailableError extends Data.TaggedError("VmPrivateNetworkUnavailableError")<{ + readonly provider: ProviderId; + readonly reason: string; +}> {} + +/** The caller asked about a tunnel this account has never enrolled, or revoked. */ +export class VmTunnelNotFoundError extends Data.TaggedError("VmTunnelNotFoundError")<{ + readonly deviceFingerprint: string; +}> {} + export class VmSnapshotNotFoundError extends Data.TaggedError("VmSnapshotNotFoundError")<{ readonly snapshotId: string; }> {} @@ -125,8 +144,20 @@ export type VmWorkflowError = | VmCreateCreditsInsufficientError | VmBillingError | VmAttachTransportUnsupportedError + | VmPrivateNetworkUnavailableError + | VmTunnelNotFoundError | VmAccountDeletionIdentityRevocationError; +export function isVmPrivateNetworkUnavailableError( + err: unknown, +): err is VmPrivateNetworkUnavailableError { + return (err as { _tag?: string } | null)?._tag === "VmPrivateNetworkUnavailableError"; +} + +export function isVmTunnelNotFoundError(err: unknown): err is VmTunnelNotFoundError { + return (err as { _tag?: string } | null)?._tag === "VmTunnelNotFoundError"; +} + export function isVmNotFoundError(err: unknown): err is VmNotFoundError { return (err as { _tag?: string } | null)?._tag === "VmNotFoundError"; } @@ -216,6 +247,8 @@ const vmWorkflowErrorTagRecord = { VmCreateCreditsInsufficientError: true, VmBillingError: true, VmAttachTransportUnsupportedError: true, + VmPrivateNetworkUnavailableError: true, + VmTunnelNotFoundError: true, VmAccountDeletionIdentityRevocationError: true, } as const satisfies Record; diff --git a/web/services/vms/privateNetwork.ts b/web/services/vms/privateNetwork.ts new file mode 100644 index 000000000000..f17d868da7b5 Binary files /dev/null and b/web/services/vms/privateNetwork.ts differ diff --git a/web/services/vms/providerGateway.ts b/web/services/vms/providerGateway.ts index fe3f07ab6b3f..afc462e7b6d6 100644 --- a/web/services/vms/providerGateway.ts +++ b/web/services/vms/providerGateway.ts @@ -9,7 +9,11 @@ import { type CreateOptions, type ExecOptions, type ExecResult, + type CreateProviderTunnelOptions, type ProviderId, + type ProviderNetwork, + type ProviderTunnel, + type RestoreOptions, type SnapshotRef, type SSHEndpoint, type VMHandle, @@ -45,7 +49,11 @@ export type VmProviderGatewayShape = { vmId: string, name?: string, ) => Effect.Effect; - readonly restore?: (provider: ProviderId, snapshotId: string) => Effect.Effect; + readonly restore?: ( + provider: ProviderId, + snapshotId: string, + options?: RestoreOptions, + ) => Effect.Effect; readonly fork?: (provider: ProviderId, vmId: string) => Effect.Effect; readonly exec: ( provider: ProviderId, @@ -89,6 +97,39 @@ export type VmProviderGatewayShape = { provider: ProviderId, vmId: string, ) => Effect.Effect; + /** + * Per-owner private networks and WireGuard tunnels. Optional as a group — + * test doubles built before the feature simply omit them — and workflows + * treat an absent member the same as a provider without the capability. + */ + readonly supportsPrivateNetworking?: (provider: ProviderId) => boolean; + readonly ensureNetwork?: ( + provider: ProviderId, + options: { slug: string; displayName?: string }, + ) => Effect.Effect; + readonly deleteNetwork?: ( + provider: ProviderId, + networkId: string, + ) => Effect.Effect; + readonly createTunnel?: ( + provider: ProviderId, + options: CreateProviderTunnelOptions, + ) => Effect.Effect; + readonly getTunnel?: ( + provider: ProviderId, + tunnelId: string, + networkId: string, + ) => Effect.Effect; + readonly rotateTunnelKey?: ( + provider: ProviderId, + tunnelId: string, + clientPublicKey: string, + networkId: string, + ) => Effect.Effect; + readonly deleteTunnel?: ( + provider: ProviderId, + tunnelId: string, + ) => Effect.Effect; }; export class VmProviderGateway extends Context.Tag("cmux/VmProviderGateway")< @@ -96,6 +137,20 @@ export class VmProviderGateway extends Context.Tag("cmux/VmProviderGateway")< VmProviderGatewayShape >() {} +/** + * The driver's private-networking half, or a typed unsupported failure. Callers + * that can proceed without a network check `supportsPrivateNetworking` first; + * reaching here on a provider without it is a programming error, not a + * configuration one. + */ +function privateNetworking(provider: ProviderId) { + const impl = getProvider(provider).privateNetworking; + if (!impl) { + throw new VmOperationUnsupportedError({ provider, operation: "privateNetworking" }); + } + return impl; +} + function providerEffect( provider: ProviderId, operation: string, @@ -138,8 +193,8 @@ export const VmProviderGatewayLive = Layer.succeed(VmProviderGateway, { providerEffect(provider, "pause", () => getProvider(provider).pause(vmId)), snapshot: (provider, vmId, name) => providerEffect(provider, "snapshot", () => getProvider(provider).snapshot(vmId, name)), - restore: (provider, snapshotId) => - providerEffect(provider, "restore", () => getProvider(provider).restore(snapshotId)), + restore: (provider, snapshotId, options) => + providerEffect(provider, "restore", () => getProvider(provider).restore(snapshotId, options)), fork: (provider, vmId) => providerEffect(provider, "fork", async () => { const driver = getProvider(provider); @@ -162,7 +217,9 @@ export const VmProviderGatewayLive = Layer.succeed(VmProviderGateway, { providerEffect(provider, "getStats", () => { const impl = getProvider(provider); if (!impl.getStats) { - throw new Error(`provider ${provider} does not report machine stats`); + // Typed, so the route answers "unsupported" (non-retryable) instead of + // a retryable 502 the activity panel would poll forever. + throw new VmOperationUnsupportedError({ provider, operation: "getStats" }); } return impl.getStats(vmId); }), @@ -196,4 +253,29 @@ export const VmProviderGatewayLive = Layer.succeed(VmProviderGateway, { if (!driver.revokeEndpointLeases) return Effect.void; return providerEffect(provider, "revokeEndpointLeases", () => driver.revokeEndpointLeases!(vmId)); }, + supportsPrivateNetworking: (provider) => !!getProvider(provider).privateNetworking, + ensureNetwork: (provider, options) => + providerEffect(provider, "ensureNetwork", () => + privateNetworking(provider).ensureNetwork(options) + ), + deleteNetwork: (provider, networkId) => + providerEffect(provider, "deleteNetwork", () => + privateNetworking(provider).deleteNetwork(networkId) + ), + createTunnel: (provider, options) => + providerEffect(provider, "createTunnel", () => + privateNetworking(provider).createTunnel(options) + ), + getTunnel: (provider, tunnelId, networkId) => + providerEffect(provider, "getTunnel", () => + privateNetworking(provider).getTunnel(tunnelId, networkId) + ), + rotateTunnelKey: (provider, tunnelId, clientPublicKey, networkId) => + providerEffect(provider, "rotateTunnelKey", () => + privateNetworking(provider).rotateTunnelKey(tunnelId, clientPublicKey, networkId) + ), + deleteTunnel: (provider, tunnelId) => + providerEffect(provider, "deleteTunnel", () => + privateNetworking(provider).deleteTunnel(tunnelId) + ), }); diff --git a/web/services/vms/repository.ts b/web/services/vms/repository.ts index df1399e4be74..e93246dedbe3 100644 --- a/web/services/vms/repository.ts +++ b/web/services/vms/repository.ts @@ -10,7 +10,9 @@ import { cloudVmBases, cloudVmBillingGrants, cloudVmLeases, + cloudVmNetworks, cloudVmSessions, + cloudVmTunnels, cloudVms, cloudVmUsageEvents, } from "../../db/schema"; @@ -44,6 +46,8 @@ export type CloudVmAccessLeaseRow = CloudVmLeaseRow & { readonly providerVmId: string; }; export type CloudVmSessionRow = typeof cloudVmSessions.$inferSelect; +export type CloudVmNetworkRow = typeof cloudVmNetworks.$inferSelect; +export type CloudVmTunnelRow = typeof cloudVmTunnels.$inferSelect; export type CloudVmLeaseKind = typeof cloudVmLeases.$inferInsert.kind; export type CloudVmStatus = CloudVmRow["status"]; export type CloudVmSessionStatus = CloudVmSessionRow["status"]; @@ -77,6 +81,58 @@ export type BillingGrantClaim = export type VmRepositoryShape = { readonly listUserVms: (userId: string, billingTeamId?: string | null) => Effect.Effect; + /** + * Private-network and tunnel bookkeeping. Optional as a group so test + * doubles built before the feature keep compiling; the live layer always + * provides them and workflows treat absence as "no networking". + */ + /** The owner's private-network row for one provider, or null when they have none yet. */ + readonly findNetwork?: ( + userId: string, + provider: ProviderId, + ) => Effect.Effect; + /** + * Record the owner's network. Idempotent by (user, provider): concurrent + * creates resolve to one row, and a re-provisioned network overwrites the + * provider id rather than adding a second row nobody reads. + */ + readonly upsertNetwork?: (input: { + readonly userId: string; + readonly provider: ProviderId; + readonly providerNetworkId: string; + readonly slug?: string | null; + readonly cidr?: string | null; + readonly cidrV6?: string | null; + }) => Effect.Effect; + readonly deleteNetwork?: (id: string) => Effect.Effect; + /** The live (unrevoked) tunnel row for one of the owner's devices. */ + readonly findTunnel?: (input: { + readonly userId: string; + readonly deviceFingerprint: string; + }) => Effect.Effect; + readonly listUserTunnels?: (userId: string) => Effect.Effect; + readonly insertTunnel?: (input: { + readonly userId: string; + readonly networkId: string; + readonly provider: ProviderId; + readonly providerTunnelId: string; + readonly deviceFingerprint: string; + readonly deviceName?: string | null; + readonly clientPublicKey: string; + readonly addressV4?: string | null; + readonly addressV6?: string | null; + }) => Effect.Effect; + /** Refresh a tunnel row after the provider handed back its current state. */ + readonly updateTunnel?: (input: { + readonly id: string; + readonly clientPublicKey?: string; + readonly deviceName?: string | null; + readonly addressV4?: string | null; + readonly addressV6?: string | null; + readonly configIssued?: boolean; + }) => Effect.Effect; + /** Mark a tunnel revoked, keeping the row for audit. Returns false when already revoked. */ + readonly revokeTunnel?: (id: string) => Effect.Effect; readonly claimBillingGrant: (input: { readonly billingCustomerType: string; readonly billingCustomerId: string; @@ -432,6 +488,128 @@ function boundedReaperKeys(keys: readonly string[]): string[] { } export const VmRepositoryLive = Layer.succeed(VmRepository, { + findNetwork: (userId, provider) => + dbEffect("findNetwork", async () => { + const db = cloudDb(); + const [row] = await db + .select() + .from(cloudVmNetworks) + .where(and(eq(cloudVmNetworks.userId, userId), eq(cloudVmNetworks.provider, provider))) + .limit(1); + return row ?? null; + }), + + upsertNetwork: (input) => + dbEffect("upsertNetwork", async () => { + const db = cloudDb(); + const [row] = await db + .insert(cloudVmNetworks) + .values({ + userId: input.userId, + provider: input.provider, + providerNetworkId: input.providerNetworkId, + slug: input.slug ?? null, + cidr: input.cidr ?? null, + cidrV6: input.cidrV6 ?? null, + }) + .onConflictDoUpdate({ + target: [cloudVmNetworks.userId, cloudVmNetworks.provider], + set: { + providerNetworkId: input.providerNetworkId, + slug: input.slug ?? null, + cidr: input.cidr ?? null, + cidrV6: input.cidrV6 ?? null, + updatedAt: new Date(), + }, + }) + .returning(); + if (!row) throw new Error("upsertNetwork returned no row"); + return row; + }), + + deleteNetwork: (id) => + dbEffect("deleteNetwork", async () => { + const db = cloudDb(); + await db.delete(cloudVmNetworks).where(eq(cloudVmNetworks.id, id)); + }), + + findTunnel: (input) => + dbEffect("findTunnel", async () => { + const db = cloudDb(); + const [row] = await db + .select() + .from(cloudVmTunnels) + .where(and( + eq(cloudVmTunnels.userId, input.userId), + eq(cloudVmTunnels.deviceFingerprint, input.deviceFingerprint), + isNull(cloudVmTunnels.revokedAt), + )) + .limit(1); + return row ?? null; + }), + + listUserTunnels: (userId) => + dbEffect("listUserTunnels", async () => { + const db = cloudDb(); + return await db + .select() + .from(cloudVmTunnels) + .where(and(eq(cloudVmTunnels.userId, userId), isNull(cloudVmTunnels.revokedAt))) + .orderBy(desc(cloudVmTunnels.createdAt)); + }), + + insertTunnel: (input) => + dbEffect("insertTunnel", async () => { + const db = cloudDb(); + const [row] = await db + .insert(cloudVmTunnels) + .values({ + userId: input.userId, + networkId: input.networkId, + provider: input.provider, + providerTunnelId: input.providerTunnelId, + deviceFingerprint: input.deviceFingerprint, + deviceName: input.deviceName ?? null, + clientPublicKey: input.clientPublicKey, + addressV4: input.addressV4 ?? null, + addressV6: input.addressV6 ?? null, + lastConfigIssuedAt: new Date(), + }) + .returning(); + if (!row) throw new Error("insertTunnel returned no row"); + return row; + }), + + updateTunnel: (input) => + dbEffect("updateTunnel", async () => { + const db = cloudDb(); + const [row] = await db + .update(cloudVmTunnels) + .set({ + ...(input.clientPublicKey ? { clientPublicKey: input.clientPublicKey } : {}), + ...(input.deviceName === undefined ? {} : { deviceName: input.deviceName }), + ...(input.addressV4 === undefined ? {} : { addressV4: input.addressV4 }), + ...(input.addressV6 === undefined ? {} : { addressV6: input.addressV6 }), + ...(input.configIssued ? { lastConfigIssuedAt: new Date() } : {}), + updatedAt: new Date(), + }) + .where(eq(cloudVmTunnels.id, input.id)) + .returning(); + if (!row) throw new Error(`updateTunnel found no tunnel ${input.id}`); + return row; + }), + + revokeTunnel: (id) => + dbEffect("revokeTunnel", async () => { + const db = cloudDb(); + const rows = await db + .update(cloudVmTunnels) + .set({ revokedAt: new Date(), updatedAt: new Date() }) + .where(and(eq(cloudVmTunnels.id, id), isNull(cloudVmTunnels.revokedAt))) + .returning({ id: cloudVmTunnels.id }); + return rows.length > 0; + }), + listUserVms: (userId, billingTeamId) => dbEffect("listUserVms", async () => { const db = cloudDb(); diff --git a/web/services/vms/routeHelpers.ts b/web/services/vms/routeHelpers.ts index ca4094c54611..3dded9c07702 100644 --- a/web/services/vms/routeHelpers.ts +++ b/web/services/vms/routeHelpers.ts @@ -26,8 +26,10 @@ import { isVmLimitExceededError, isVmNotFoundError, isVmOperationUnsupportedError, + isVmPrivateNetworkUnavailableError, isVmProviderOperationError, isVmSnapshotNotFoundError, + isVmTunnelNotFoundError, vmWorkflowErrorCause, type VmOperationUnsupportedError, } from "./errors"; @@ -170,6 +172,7 @@ export type VmLifecyclePhase = | "resume" | "attach" | "ssh" + | "network" | "exec" | "destroy" | "status" @@ -518,6 +521,35 @@ export async function vmWorkflowErrorResponse( }); } + if (isVmPrivateNetworkUnavailableError(workflowError)) { + return vmErrorResponse({ + error: "vm_private_network_unavailable", + status: 409, + message: "Cloud VM private networking is not available in this environment.", + action: + "Machines in this environment are reached at their public address, so no tunnel is needed. " + + "Stop offering to set one up; retrying will not change this.", + reason: workflowError.reason, + phase: "network", + // Not retryable on purpose: this is how the deployment is configured, so + // a client that backs off and retries would loop forever. + retryable: false, + details: { provider: workflowError.provider }, + }); + } + + if (isVmTunnelNotFoundError(workflowError)) { + return vmErrorResponse({ + error: "vm_tunnel_not_found", + status: 404, + message: "This computer is not enrolled on your Cloud VM network.", + action: "Enroll it with POST /api/vm/tunnel, then bring the WireGuard tunnel up.", + phase: "network", + retryable: false, + details: { deviceFingerprint: workflowError.deviceFingerprint }, + }); + } + if (isVmCreateDisabledError(workflowError)) { return vmErrorResponse({ error: "vm_create_disabled", @@ -739,6 +771,10 @@ function normalizedRetryAfterSeconds(value: number | undefined): number | undefi function vmPhaseForOperation(operation: string): VmLifecyclePhase { if (operation.includes("openAttach")) return "attach"; if (operation.includes("openSSH")) return "ssh"; + // Before the "create" check: createTunnel/createNetwork are network setup, + // not machine creation, and a client that read them as "create" would show + // machine-provisioning errors for a tunnel problem. + if (operation.includes("Network") || operation.includes("Tunnel")) return "network"; if (operation.includes("create")) return "create"; if (operation.includes("restore")) return "restore"; if (operation.includes("fork")) return "fork"; diff --git a/web/services/vms/timings.ts b/web/services/vms/timings.ts index 003b5e09311d..35f03fd21e0c 100644 --- a/web/services/vms/timings.ts +++ b/web/services/vms/timings.ts @@ -11,6 +11,7 @@ export type VmTimingStage = | "limit_reconcile" | "billing" | "billing_reconcile" + | "resolve_network" | "model_plane_env" | "provider_create" | "mark_running" diff --git a/web/services/vms/workflows.ts b/web/services/vms/workflows.ts index c18249c01602..52aef23a7301 100644 --- a/web/services/vms/workflows.ts +++ b/web/services/vms/workflows.ts @@ -38,6 +38,7 @@ import { type VmWorkflowError, } from "./errors"; import { isVmFreeAccessExpired, maxActiveVmsForPlan, vmFreeAccessWindowDays } from "./entitlements"; +import { resolveOwnerNetwork } from "./privateNetwork"; import { isProviderIdentityNotFoundError, isProviderNotFoundError } from "./providerErrors"; import { VmProviderGateway, VmProviderGatewayLive, type VmProviderGatewayShape } from "./providerGateway"; import { @@ -62,6 +63,18 @@ export { homeVolumeTemplateForUser, isMachineOwnedHomeVolumeName, } from "./volumeNaming"; +export { + deletePrivateNetworkingForAccountDeletion, + enrollVmTunnel, + isWireGuardPublicKey, + listVmTunnels, + networkSlugForUser, + readVmTunnel, + resolveOwnerNetwork, + revokeVmTunnel, + tunnelSlugForDevice, +} from "./privateNetwork"; +export type { VmTunnelDescriptor } from "./privateNetwork"; export { reapVmResources } from "./reaper"; export type { VmReaperOptions, @@ -326,6 +339,20 @@ export function createVm(input: { const repo = yield* VmRepository; const providers = yield* VmProviderGateway; const billing = yield* VmBillingGateway; + + // Resolved before the row is inserted and the credit reserved, so a + // provisioning failure here costs nothing to unwind. The network is an + // account-level resource — free, idempotent, and reused by every later + // machine — so resolving it for a create that then hits the active-VM + // limit is not waste, it is setup the next create no longer has to do. + // Null means the deployment or provider has no private networking, and the + // machine falls back to being reachable at its public address. + const network = yield* measureVmEffect( + input.timing, + "resolve_network", + resolveOwnerNetwork({ userId: input.userId, provider: input.provider }), + ); + const create = yield* beginCreateWithLazyProviderRefresh(repo, providers, input); if (!create.inserted) { @@ -363,6 +390,7 @@ export function createVm(input: { : undefined, memoryMb: input.memoryMb, envs: input.envs, + ...(network ? { network: { id: network.providerNetworkId } } : {}), }), ).pipe( Effect.tapError((err) => @@ -539,12 +567,28 @@ function finishBaseCreate( idempotencyKey, }, create.vm, creditReservation); + // Base machines join the owner's private network exactly as ad-hoc + // machines do — Base is the machine most users touch first, so leaving it + // publicly exposed would make the default machine the least private one. + // finishBaseCreate receives its services as parameters (it predates the + // context-based composition), so hand them to the context-reading resolver + // explicitly instead of widening this function's environment. + const network = yield* measureVmEffect( + input.timing, + "resolve_network", + resolveOwnerNetwork({ userId: input.userId, provider: input.provider }).pipe( + Effect.provideService(VmRepository, repo), + Effect.provideService(VmProviderGateway, providers), + ), + ); + const handle = yield* measureVmEffect( input.timing, "provider_create", providers.create(input.provider, { image: input.image, providerMetadata: create.vm.providerMetadata, + ...(network ? { network: { id: network.providerNetworkId } } : {}), }), ).pipe( Effect.tapError((err) => diff --git a/web/tests/account-route.test.ts b/web/tests/account-route.test.ts index 3ab3e4ae627c..db5cc66f95cf 100644 --- a/web/tests/account-route.test.ts +++ b/web/tests/account-route.test.ts @@ -64,6 +64,8 @@ const realWithVaultUserQuotaLock = vaultUsageModule.withVaultUserQuotaLock; const vmErrorsModule = await import("../services/vms/errors"); const workflowsModule = await import("../services/vms/workflows"); const realDestroyVm = workflowsModule.destroyVm; +const realDeletePrivateNetworkingForAccountDeletion = + workflowsModule.deletePrivateNetworkingForAccountDeletion; const realListUserVms = workflowsModule.listUserVms; const realRevokeUserIdentityLeasesForAccountDeletion = workflowsModule.revokeUserIdentityLeasesForAccountDeletion; const realRunVmWorkflow = workflowsModule.runVmWorkflow as (...args: unknown[]) => unknown; @@ -210,6 +212,10 @@ const revokeUserIdentityLeasesForAccountDeletion = mock((...args: unknown[]) => afterBatch: input?.afterBatch, }; }); +const deletePrivateNetworkingForAccountDeletion = mock((...args: unknown[]) => { + const [userId] = args as [string]; + return { kind: "deletePrivateNetworking" as const, userId }; +}); const destroyVm = mock((...args: unknown[]) => { const [input] = args as [{ readonly userId: string; @@ -239,6 +245,10 @@ const runVmWorkflow = mock(async (...args: unknown[]) => { if (revokeIdentityLeasesError) throw revokeIdentityLeasesError; return revokedIdentityLeaseCount; } + if (program.kind === "deletePrivateNetworking") { + routeEvents.push("delete-private-networking"); + return { tunnels: 0, networks: 0 }; + } routeEvents.push("destroy-vm"); const destroyVmFailure = destroyVmFailureErrorsByProviderId.get(program.input.providerVmId); if (destroyVmFailure) throw destroyVmFailure; @@ -394,6 +404,7 @@ type WorkflowProgram = readonly userId: string; readonly afterBatch?: () => unknown; } + | { readonly kind: "deletePrivateNetworking"; readonly userId: string } | { readonly kind: "destroyVm"; readonly input: { @@ -596,6 +607,11 @@ mock.module("../services/vms/workflows", () => ({ if (input.userId === ACCOUNT_USER_ID) return destroyVm(...args); return realDestroyVm(...args); }) as typeof realDestroyVm, + deletePrivateNetworkingForAccountDeletion: ((...args: Parameters) => { + const [userId] = args; + if (userId === ACCOUNT_USER_ID) return deletePrivateNetworkingForAccountDeletion(...args); + return realDeletePrivateNetworkingForAccountDeletion(...args); + }) as typeof realDeletePrivateNetworkingForAccountDeletion, revokeUserIdentityLeasesForAccountDeletion: ((...args: Parameters) => { const [userId] = args; if (userId === ACCOUNT_USER_ID) return revokeUserIdentityLeasesForAccountDeletion(...args); @@ -884,6 +900,7 @@ describe("account deletion route", () => { "list-vms", "destroy-vm", "destroy-vm", + "delete-private-networking", "vault-delete", "vault-delete", "vault-delete", @@ -2344,6 +2361,7 @@ describe("account deletion route", () => { "list-vms", "destroy-vm", "destroy-vm", + "delete-private-networking", "transaction", "transaction-lock", "stack-delete", @@ -2568,6 +2586,7 @@ function isAccountDeletionWorkflowProgram(program: unknown): boolean { if (candidate.kind === "revokeUserIdentityLeasesForAccountDeletion") { return candidate.userId === ACCOUNT_USER_ID; } + if (candidate.kind === "deletePrivateNetworking") return candidate.userId === ACCOUNT_USER_ID; if (candidate.kind === "destroyVm") return candidate.input?.userId === ACCOUNT_USER_ID; return false; } diff --git a/web/tests/vm-freestyle-provider.test.ts b/web/tests/vm-freestyle-provider.test.ts index f30812f6b852..87fac3351e55 100644 --- a/web/tests/vm-freestyle-provider.test.ts +++ b/web/tests/vm-freestyle-provider.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { describe, expect, test } from "bun:test"; import { + FREESTYLE_NETWORK_FIREWALL_RULES, FreestyleProvider, freestyleCmuxRemoteRoute, freestyleDaemonHealthyCommand, @@ -49,19 +50,69 @@ describe("FreestyleProvider transport contract", () => { }); describe("Freestyle platform contract", () => { - test("firewall: outbound open, inbound only the daemon port", () => { + test("firewall on a private-network machine: outbound only, no inbound at all", () => { + // The VPC's members-reach-each-other rule is what admits the daemon port; + // an inbound rule here would re-expose 1337 to the Internet. expect(freestyleFirewallRules()).toEqual([ { action: "allow", source: {}, destination: { public: true } }, + ]); + }); + + test("firewall without a network: inbound 1337 opens publicly, as before", () => { + expect(freestyleFirewallRules({ publicDaemonIngress: true })).toEqual([ + { action: "allow", source: {}, destination: { public: true } }, { action: "allow", source: { public: true }, destination: { port: 1337, protocol: "tcp" } }, ]); }); - test("cmux-remote route is the public IPv6 straight to the daemon", () => { - expect(freestyleCmuxRemoteRoute("2602:f75c:0:1::2a", VM_ID)).toBe( + test("network firewall: one members-reach-each-other rule, nothing else", () => { + expect(FREESTYLE_NETWORK_FIREWALL_RULES).toEqual([ + { action: "allow", source: {}, destination: {} }, + ]); + }); + + test("cmux-remote route prefers the private VPC address and never falls back from it", () => { + // On a VPC: the private IPv6 wins even when a public address exists, + // because a VPC machine has no public inbound rule. + expect( + freestyleCmuxRemoteRoute( + { + publicIpv6: "2602:f75c:0:1::2a", + vpcs: [{ ipv4: "10.40.0.10", ipv6: "fd7a:115c:a1e0::a" }], + }, + VM_ID, + ), + ).toBe("ws://[fd7a:115c:a1e0::a]:1337/v1/link"); + // v4-only membership is the honest second choice, not a public fallback. + expect( + freestyleCmuxRemoteRoute( + { publicIpv6: "2602:f75c:0:1::2a", vpcs: [{ ipv4: "10.40.0.10", ipv6: null }] }, + VM_ID, + ), + ).toBe("ws://10.40.0.10:1337/v1/link"); + // A membership with no address is unreachable and must say so, not + // silently dial a public address the firewall will drop. + expect(() => + freestyleCmuxRemoteRoute( + { publicIpv6: "2602:f75c:0:1::2a", vpcs: [{ ipv4: null, ipv6: null }] }, + VM_ID, + ), + ).toThrow("no address"); + }); + + test("cmux-remote route without a network is the public IPv6, as before", () => { + expect(freestyleCmuxRemoteRoute({ publicIpv6: "2602:f75c:0:1::2a" }, VM_ID)).toBe( "ws://[2602:f75c:0:1::2a]:1337/v1/link", ); - expect(() => freestyleCmuxRemoteRoute(null, VM_ID)).toThrow("public IPv6"); - expect(() => freestyleCmuxRemoteRoute(" ", VM_ID)).toThrow("public IPv6"); + // The deprecated `networks` alias still resolves for older responses. + expect( + freestyleCmuxRemoteRoute( + { networks: [{ ipv6: "fd7a:115c:a1e0::b" }] }, + VM_ID, + ), + ).toBe("ws://[fd7a:115c:a1e0::b]:1337/v1/link"); + expect(() => freestyleCmuxRemoteRoute({ publicIpv6: null }, VM_ID)).toThrow("public IPv6"); + expect(() => freestyleCmuxRemoteRoute({ publicIpv6: " " }, VM_ID)).toThrow("public IPv6"); }); test("daemon health requires a v6-table listener; start installs the dual-stack override", () => { diff --git a/web/tests/vm-private-network.test.ts b/web/tests/vm-private-network.test.ts new file mode 100644 index 000000000000..11892fcea1d9 --- /dev/null +++ b/web/tests/vm-private-network.test.ts @@ -0,0 +1,433 @@ +import { describe, expect, test } from "bun:test"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { + enrollVmTunnel, + isWireGuardPublicKey, + networkSlugForUser, + readVmTunnel, + resolveOwnerNetwork, + revokeVmTunnel, + tunnelSlugForDevice, +} from "../services/vms/privateNetwork"; +import { + VmPrivateNetworkUnavailableError, + VmTunnelNotFoundError, +} from "../services/vms/errors"; +import type { + CreateProviderTunnelOptions, + ProviderNetwork, + ProviderTunnel, +} from "../services/vms/drivers"; +import { VmProviderGateway, type VmProviderGatewayShape } from "../services/vms/providerGateway"; +import { + VmRepository, + type CloudVmNetworkRow, + type CloudVmTunnelRow, + type VmRepositoryShape, +} from "../services/vms/repository"; + +// A valid base64 32-byte key (all zero bytes) for shape-level tests. +const CLIENT_KEY = Buffer.alloc(32, 1).toString("base64"); +const OTHER_KEY = Buffer.alloc(32, 2).toString("base64"); + +const NETWORK: ProviderNetwork = { + id: "vpc-test-1", + slug: "cmux-net-abc", + cidr: "10.40.0.0/24", + cidrV6: "fd00:40::/64", +}; + +function providerTunnel(overrides: Partial = {}): ProviderTunnel { + return { + id: "tun-test-1", + clientConfig: "[Interface]\nPrivateKey =\n[Peer]\n", + clientPublicKey: CLIENT_KEY, + serverPublicKey: "server-key", + endpointHost: "vpn.freestyle.sh", + endpointPort: 51820, + routes: ["10.0.0.0/8", "fd00::/8"], + addressV4: "10.40.0.2", + addressV6: "fd00:40::2", + ...overrides, + }; +} + +function networkRow(overrides: Partial = {}): CloudVmNetworkRow { + return { + id: "00000000-0000-4000-8000-0000000000aa", + userId: "user-1", + provider: "freestyle", + providerNetworkId: NETWORK.id, + slug: NETWORK.slug, + cidr: NETWORK.cidr, + cidrV6: NETWORK.cidrV6, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function tunnelRow(overrides: Partial = {}): CloudVmTunnelRow { + return { + id: "00000000-0000-4000-8000-0000000000bb", + userId: "user-1", + networkId: "00000000-0000-4000-8000-0000000000aa", + provider: "freestyle", + providerTunnelId: "tun-test-1", + deviceFingerprint: "device-1", + deviceName: "Test Mac", + clientPublicKey: CLIENT_KEY, + addressV4: "10.40.0.2", + addressV6: "fd00:40::2", + createdAt: new Date(), + updatedAt: new Date(), + lastConfigIssuedAt: new Date(), + revokedAt: null, + ...overrides, + }; +} + +type GatewayCalls = { + ensureNetwork: number; + createTunnel: CreateProviderTunnelOptions[]; + rotateTunnelKey: string[]; + deleteTunnel: string[]; +}; + +function testGateway(options: { + calls?: GatewayCalls; + getTunnel?: ProviderTunnel | null; + supports?: boolean; +} = {}): VmProviderGatewayShape { + const calls = options.calls; + return { + create: () => Effect.die("unused"), + destroy: () => Effect.void, + exec: () => Effect.die("unused"), + openAttach: () => Effect.die("unused"), + openSSH: () => Effect.die("unused"), + revokeSSHIdentity: () => Effect.void, + supportsPrivateNetworking: () => options.supports ?? true, + ensureNetwork: () => + Effect.sync(() => { + if (calls) calls.ensureNetwork += 1; + return NETWORK; + }), + deleteNetwork: () => Effect.void, + createTunnel: (_provider, createOptions) => + Effect.sync(() => { + calls?.createTunnel.push(createOptions); + return providerTunnel({ clientPublicKey: createOptions.clientPublicKey }); + }), + getTunnel: () => Effect.succeed(options.getTunnel === undefined ? providerTunnel() : options.getTunnel), + rotateTunnelKey: (_provider, tunnelId, clientPublicKey) => + Effect.sync(() => { + calls?.rotateTunnelKey.push(clientPublicKey); + return providerTunnel({ clientPublicKey }); + }), + deleteTunnel: (_provider, tunnelId) => + Effect.sync(() => { + calls?.deleteTunnel.push(tunnelId); + }), + }; +} + +type RepoCalls = { + upserts: number; + inserts: number; + updates: number; + revoked: string[]; +}; + +function testRepo(options: { + calls?: RepoCalls; + network?: CloudVmNetworkRow | null; + tunnel?: CloudVmTunnelRow | null; +} = {}): VmRepositoryShape { + const calls = options.calls; + const base: Partial = { + findNetwork: () => Effect.succeed(options.network ?? null), + upsertNetwork: (input) => + Effect.sync(() => { + if (calls) calls.upserts += 1; + return networkRow({ + userId: input.userId, + providerNetworkId: input.providerNetworkId, + }); + }), + deleteNetwork: () => Effect.void, + findTunnel: () => Effect.succeed(options.tunnel ?? null), + listUserTunnels: () => Effect.succeed(options.tunnel ? [options.tunnel] : []), + insertTunnel: (input) => + Effect.sync(() => { + if (calls) calls.inserts += 1; + return tunnelRow({ + providerTunnelId: input.providerTunnelId, + deviceFingerprint: input.deviceFingerprint, + clientPublicKey: input.clientPublicKey, + }); + }), + updateTunnel: (input) => + Effect.sync(() => { + if (calls) calls.updates += 1; + return tunnelRow({ + ...(input.clientPublicKey ? { clientPublicKey: input.clientPublicKey } : {}), + }); + }), + revokeTunnel: (id) => + Effect.sync(() => { + calls?.revoked.push(id); + return true; + }), + }; + return base as VmRepositoryShape; +} + +function layerFor(repo: VmRepositoryShape, gateway: VmProviderGatewayShape) { + return Layer.mergeAll( + Layer.succeed(VmRepository, repo), + Layer.succeed(VmProviderGateway, gateway), + ); +} + +function newGatewayCalls(): GatewayCalls { + return { ensureNetwork: 0, createTunnel: [], rotateTunnelKey: [], deleteTunnel: [] }; +} + +function newRepoCalls(): RepoCalls { + return { upserts: 0, inserts: 0, updates: 0, revoked: [] }; +} + +describe("WireGuard public key validation", () => { + test("accepts a base64 32-byte key and rejects everything else", () => { + expect(isWireGuardPublicKey(CLIENT_KEY)).toBe(true); + expect(isWireGuardPublicKey(` ${CLIENT_KEY} `)).toBe(true); + expect(isWireGuardPublicKey("")).toBe(false); + expect(isWireGuardPublicKey("not-a-key")).toBe(false); + // 31 bytes: right shape class, wrong length. + expect(isWireGuardPublicKey(Buffer.alloc(31, 1).toString("base64"))).toBe(false); + // A private key would be the same shape; the regex can't tell, but a + // URL/path can never pass. + expect(isWireGuardPublicKey("https://example.com/AAAA")).toBe(false); + expect(isWireGuardPublicKey(42)).toBe(false); + }); +}); + +describe("account slugs", () => { + test("are stable, hashed, and do not embed the raw user id", () => { + const slug = networkSlugForUser("user-abcdef"); + expect(slug).toBe(networkSlugForUser("user-abcdef")); + expect(slug.startsWith("cmux-net-")).toBe(true); + expect(slug).not.toContain("user-abcdef"); + const device = tunnelSlugForDevice("user-abcdef", "device-1"); + expect(device).toBe(tunnelSlugForDevice("user-abcdef", "device-1")); + expect(device).not.toBe(tunnelSlugForDevice("user-abcdef", "device-2")); + expect(device).not.toContain("user-abcdef"); + }); +}); + +describe("resolveOwnerNetwork", () => { + test("provisions on first use and records the provider network", async () => { + const gatewayCalls = newGatewayCalls(); + const repoCalls = newRepoCalls(); + const network = await Effect.runPromise( + resolveOwnerNetwork({ userId: "user-1", provider: "freestyle" }).pipe( + Effect.provide(layerFor(testRepo({ calls: repoCalls }), testGateway({ calls: gatewayCalls }))), + ), + ); + expect(network?.providerNetworkId).toBe(NETWORK.id); + expect(gatewayCalls.ensureNetwork).toBe(1); + expect(repoCalls.upserts).toBe(1); + }); + + test("reuses the recorded network without a provider call", async () => { + const gatewayCalls = newGatewayCalls(); + const network = await Effect.runPromise( + resolveOwnerNetwork({ userId: "user-1", provider: "freestyle" }).pipe( + Effect.provide(layerFor( + testRepo({ network: networkRow() }), + testGateway({ calls: gatewayCalls }), + )), + ), + ); + expect(network?.providerNetworkId).toBe(NETWORK.id); + expect(gatewayCalls.ensureNetwork).toBe(0); + }); + + test("returns null (does not fail) when the provider has no private networking", async () => { + const network = await Effect.runPromise( + resolveOwnerNetwork({ userId: "user-1", provider: "freestyle" }).pipe( + Effect.provide(layerFor(testRepo(), testGateway({ supports: false }))), + ), + ); + expect(network).toBeNull(); + }); + + test("returns null when the rollback flag disables private networking", async () => { + const prior = process.env.CMUX_VM_PRIVATE_NETWORK_ENABLED; + process.env.CMUX_VM_PRIVATE_NETWORK_ENABLED = "0"; + try { + const network = await Effect.runPromise( + resolveOwnerNetwork({ userId: "user-1", provider: "freestyle" }).pipe( + Effect.provide(layerFor(testRepo(), testGateway())), + ), + ); + expect(network).toBeNull(); + } finally { + if (prior === undefined) delete process.env.CMUX_VM_PRIVATE_NETWORK_ENABLED; + else process.env.CMUX_VM_PRIVATE_NETWORK_ENABLED = prior; + } + }); +}); + +describe("enrollVmTunnel", () => { + test("first enrollment creates a provider tunnel keyed to the device", async () => { + const gatewayCalls = newGatewayCalls(); + const repoCalls = newRepoCalls(); + const tunnel = await Effect.runPromise( + enrollVmTunnel({ + userId: "user-1", + provider: "freestyle", + deviceFingerprint: "device-1", + deviceName: "Test Mac", + clientPublicKey: CLIENT_KEY, + }).pipe( + Effect.provide(layerFor(testRepo({ calls: repoCalls }), testGateway({ calls: gatewayCalls }))), + ), + ); + expect(tunnel.created).toBe(true); + expect(tunnel.rotated).toBe(false); + expect(tunnel.clientPublicKey).toBe(CLIENT_KEY); + expect(gatewayCalls.createTunnel).toHaveLength(1); + expect(gatewayCalls.createTunnel[0]?.networkId).toBe(NETWORK.id); + expect(repoCalls.inserts).toBe(1); + }); + + test("re-enrolling with the same key is idempotent: no create, no rotate", async () => { + const gatewayCalls = newGatewayCalls(); + const repoCalls = newRepoCalls(); + const tunnel = await Effect.runPromise( + enrollVmTunnel({ + userId: "user-1", + provider: "freestyle", + deviceFingerprint: "device-1", + clientPublicKey: CLIENT_KEY, + }).pipe( + Effect.provide(layerFor( + testRepo({ calls: repoCalls, network: networkRow(), tunnel: tunnelRow() }), + testGateway({ calls: gatewayCalls }), + )), + ), + ); + expect(tunnel.created).toBe(false); + expect(tunnel.rotated).toBe(false); + expect(gatewayCalls.createTunnel).toHaveLength(0); + expect(gatewayCalls.rotateTunnelKey).toHaveLength(0); + expect(repoCalls.updates).toBe(1); // config re-issue timestamp + }); + + test("a different key rotates the existing tunnel in place, keeping its address", async () => { + const gatewayCalls = newGatewayCalls(); + const tunnel = await Effect.runPromise( + enrollVmTunnel({ + userId: "user-1", + provider: "freestyle", + deviceFingerprint: "device-1", + clientPublicKey: OTHER_KEY, + }).pipe( + Effect.provide(layerFor( + testRepo({ network: networkRow(), tunnel: tunnelRow() }), + testGateway({ calls: gatewayCalls }), + )), + ), + ); + expect(tunnel.rotated).toBe(true); + expect(tunnel.created).toBe(false); + expect(gatewayCalls.rotateTunnelKey).toEqual([OTHER_KEY]); + expect(gatewayCalls.createTunnel).toHaveLength(0); + }); + + test("a control-plane row whose provider tunnel is gone re-enrolls fresh", async () => { + const gatewayCalls = newGatewayCalls(); + const repoCalls = newRepoCalls(); + const tunnel = await Effect.runPromise( + enrollVmTunnel({ + userId: "user-1", + provider: "freestyle", + deviceFingerprint: "device-1", + clientPublicKey: CLIENT_KEY, + }).pipe( + Effect.provide(layerFor( + testRepo({ calls: repoCalls, network: networkRow(), tunnel: tunnelRow() }), + testGateway({ calls: gatewayCalls, getTunnel: null }), + )), + ), + ); + expect(tunnel.created).toBe(true); + expect(repoCalls.revoked).toHaveLength(1); + expect(gatewayCalls.createTunnel).toHaveLength(1); + }); + + test("fails typed when the provider has no private networking", async () => { + const error = await Effect.runPromise( + enrollVmTunnel({ + userId: "user-1", + provider: "freestyle", + deviceFingerprint: "device-1", + clientPublicKey: CLIENT_KEY, + }).pipe( + Effect.provide(layerFor(testRepo(), testGateway({ supports: false }))), + Effect.flip, + ), + ); + expect(error).toBeInstanceOf(VmPrivateNetworkUnavailableError); + }); +}); + +describe("readVmTunnel / revokeVmTunnel", () => { + test("read fails typed for a device that never enrolled", async () => { + const error = await Effect.runPromise( + readVmTunnel({ + userId: "user-1", + provider: "freestyle", + deviceFingerprint: "device-unknown", + }).pipe( + Effect.provide(layerFor(testRepo({ network: networkRow() }), testGateway())), + Effect.flip, + ), + ); + expect(error).toBeInstanceOf(VmTunnelNotFoundError); + }); + + test("revoke deletes the provider tunnel before marking the row revoked", async () => { + const gatewayCalls = newGatewayCalls(); + const repoCalls = newRepoCalls(); + const result = await Effect.runPromise( + revokeVmTunnel({ + userId: "user-1", + provider: "freestyle", + deviceFingerprint: "device-1", + }).pipe( + Effect.provide(layerFor( + testRepo({ calls: repoCalls, tunnel: tunnelRow() }), + testGateway({ calls: gatewayCalls }), + )), + ), + ); + expect(result.revoked).toBe(true); + expect(gatewayCalls.deleteTunnel).toEqual(["tun-test-1"]); + expect(repoCalls.revoked).toHaveLength(1); + }); + + test("revoking a device that never enrolled is a no-op, not an error", async () => { + const result = await Effect.runPromise( + revokeVmTunnel({ + userId: "user-1", + provider: "freestyle", + deviceFingerprint: "device-unknown", + }).pipe(Effect.provide(layerFor(testRepo(), testGateway()))), + ); + expect(result.revoked).toBe(false); + }); +});