diff --git a/Coder-Desktop/Coder-DesktopHelper/Manager.swift b/Coder-Desktop/Coder-DesktopHelper/Manager.swift index df79d8a1..a83b3d51 100644 --- a/Coder-Desktop/Coder-DesktopHelper/Manager.swift +++ b/Coder-Desktop/Coder-DesktopHelper/Manager.swift @@ -155,7 +155,7 @@ actor Manager { Task { try? await appXPCServerDelegate.onPeerUpdate(update: msg.peerUpdate) } case let .log(logMsg): writeVpnLog(logMsg) - case .networkSettings, .start, .stop: + case .networkSettings, .start, .stop, .wake: logger.critical("received unexpected message: `\(String(describing: msgType))`") } } @@ -182,7 +182,7 @@ actor Manager { } }) } - case .log, .peerUpdate, .start, .stop: + case .log, .peerUpdate, .start, .stop, .wake: logger.critical("received unexpected rpc: `\(String(describing: msgType))`") } } @@ -222,6 +222,16 @@ actor Manager { logger.info("startVPN done") } + // Notifies the tunnel that the system has woken from sleep. + func wake() async { + logger.info("sending wake rpc") + do { + try await speaker.wake() + } catch { + logger.error("wake rpc failed: \(error.localizedDescription, privacy: .public)") + } + } + func stopVPN() async throws(ManagerError) { logger.info("sending stop rpc") let resp: Vpn_TunnelMessage diff --git a/Coder-Desktop/Coder-DesktopHelper/SystemWakeObserver.swift b/Coder-Desktop/Coder-DesktopHelper/SystemWakeObserver.swift new file mode 100644 index 00000000..fa2746ae --- /dev/null +++ b/Coder-Desktop/Coder-DesktopHelper/SystemWakeObserver.swift @@ -0,0 +1,54 @@ +import Foundation +import IOKit.pwr_mgt +import os + +// From ; the iokit_common_msg macros aren't imported into Swift. +private let kIOMessageCanSystemSleep: UInt32 = 0xE000_0270 +private let kIOMessageSystemWillSleep: UInt32 = 0xE000_0280 +private let kIOMessageSystemHasPoweredOn: UInt32 = 0xE000_0300 + +/// Calls `onWake` when the system fully wakes from sleep (never on dark wake). +/// Unlike `NSWorkspace.didWakeNotification`, IOKit power events are delivered +/// to daemons running outside a GUI login session. +final class SystemWakeObserver { + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "system-wake-observer") + private var rootPort: io_connect_t = 0 + private let onWake: @Sendable () -> Void + + init(onWake: @escaping @Sendable () -> Void) { + self.onWake = onWake + var notifyPort: IONotificationPortRef? + var notifier: io_object_t = 0 + rootPort = IORegisterForSystemPower( + Unmanaged.passUnretained(self).toOpaque(), + ¬ifyPort, + { refcon, _, messageType, messageArgument in + let observer = Unmanaged.fromOpaque(refcon!).takeUnretainedValue() + observer.handle(messageType: messageType, messageArgument: messageArgument) + }, + ¬ifier + ) + guard rootPort != 0, let notifyPort else { + logger.error("failed to register for system power notifications") + return + } + CFRunLoopAddSource( + CFRunLoopGetMain(), + IONotificationPortGetRunLoopSource(notifyPort).takeUnretainedValue(), + .defaultMode + ) + } + + private func handle(messageType: natural_t, messageArgument: UnsafeMutableRawPointer?) { + switch messageType { + case kIOMessageSystemHasPoweredOn: + logger.info("system woke from sleep") + onWake() + case kIOMessageCanSystemSleep, kIOMessageSystemWillSleep: + // Sleep is delayed until acknowledged. + IOAllowPowerChange(rootPort, Int(bitPattern: messageArgument)) + default: + break + } + } +} diff --git a/Coder-Desktop/Coder-DesktopHelper/main.swift b/Coder-Desktop/Coder-DesktopHelper/main.swift index da777746..aacdaf4b 100644 --- a/Coder-Desktop/Coder-DesktopHelper/main.swift +++ b/Coder-Desktop/Coder-DesktopHelper/main.swift @@ -15,4 +15,11 @@ let appXPCServer = NSXPCListener(machServiceName: helperAppMachServiceName) appXPCServer.delegate = appXPCServerDelegate appXPCServer.resume() +// Nudge the tunnel to rediscover network paths on system wake. +let wakeObserver = SystemWakeObserver { + Task { @MainActor in + await globalManager?.wake() + } +} + RunLoop.main.run() diff --git a/Coder-Desktop/VPNLib/Speaker+Wake.swift b/Coder-Desktop/VPNLib/Speaker+Wake.swift new file mode 100644 index 00000000..19f28d99 --- /dev/null +++ b/Coder-Desktop/VPNLib/Speaker+Wake.swift @@ -0,0 +1,13 @@ +import SwiftProtobuf + +public extension Speaker where SendMsg == Vpn_ManagerMessage, RecvMsg == Vpn_TunnelMessage { + /// Asks the tunnel to rediscover network paths after system wake. + /// No-op if the negotiated protocol version predates WakeRequest (1.3). + func wake() async throws { + guard let negotiatedVersion, negotiatedVersion >= ProtoVersion(1, 3) else { return } + _ = try await unaryRPC( + .with { msg in + msg.wake = .init() + }) + } +} diff --git a/Coder-Desktop/VPNLib/Speaker.swift b/Coder-Desktop/VPNLib/Speaker.swift index 74597b1c..e2f60f55 100644 --- a/Coder-Desktop/VPNLib/Speaker.swift +++ b/Coder-Desktop/VPNLib/Speaker.swift @@ -22,17 +22,21 @@ enum ProtoRole: String { } /// A version of the VPN protocol that can be negotiated. -public struct ProtoVersion: CustomStringConvertible, Equatable, Codable, Sendable { +public struct ProtoVersion: CustomStringConvertible, Equatable, Comparable, Codable, Sendable { let major: Int let minor: Int public var description: String { "\(major).\(minor)" } - init(_ major: Int, _ minor: Int) { + public init(_ major: Int, _ minor: Int) { self.major = major self.minor = minor } + public static func < (lhs: ProtoVersion, rhs: ProtoVersion) -> Bool { + (lhs.major, lhs.minor) < (rhs.major, rhs.minor) + } + init(parse str: String) throws(HandshakeError) { let parts = str.split(separator: ".").map { Int($0) } if parts.count != 2 { @@ -60,6 +64,8 @@ public actor Speaker private let secretary = RPCSecretary() let role: ProtoRole + /// The version negotiated during the handshake, or nil before it completes. + public private(set) var negotiatedVersion: ProtoVersion? /// Creates an instance that communicates over the provided file handles. public init(writeFD: FileHandle, readFD: FileHandle) { @@ -91,11 +97,10 @@ public actor Speaker(decoder: inout D) throws { @@ -1033,6 +1076,19 @@ extension Vpn_ManagerMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImple self.msg = .stop(v) } }() + case 6: try { + var v: Vpn_WakeRequest? + var hadOneofValue = false + if let current = self.msg { + hadOneofValue = true + if case .wake(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.msg = .wake(v) + } + }() default: break } } @@ -1063,6 +1119,10 @@ extension Vpn_ManagerMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImple guard case .stop(let v)? = self.msg else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 5) }() + case .wake?: try { + guard case .wake(let v)? = self.msg else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 6) + }() case nil: break } try unknownFields.traverse(visitor: &visitor) @@ -1085,6 +1145,7 @@ extension Vpn_TunnelMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem 4: .standard(proto: "network_settings"), 5: .same(proto: "start"), 6: .same(proto: "stop"), + 7: .same(proto: "wake"), ] public mutating func decodeMessage(decoder: inout D) throws { @@ -1159,6 +1220,19 @@ extension Vpn_TunnelMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem self.msg = .stop(v) } }() + case 7: try { + var v: Vpn_WakeResponse? + var hadOneofValue = false + if let current = self.msg { + hadOneofValue = true + if case .wake(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.msg = .wake(v) + } + }() default: break } } @@ -1193,6 +1267,10 @@ extension Vpn_TunnelMessage: SwiftProtobuf.Message, SwiftProtobuf._MessageImplem guard case .stop(let v)? = self.msg else { preconditionFailure() } try visitor.visitSingularMessageField(value: v, fieldNumber: 6) }() + case .wake?: try { + guard case .wake(let v)? = self.msg else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 7) + }() case nil: break } try unknownFields.traverse(visitor: &visitor) @@ -2429,3 +2507,54 @@ extension Vpn_Status.Lifecycle: SwiftProtobuf._ProtoNameProviding { 4: .same(proto: "STOPPED"), ] } + +extension Vpn_WakeRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".WakeRequest" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Vpn_WakeRequest, rhs: Vpn_WakeRequest) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Vpn_WakeResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".WakeResponse" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "success"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularBoolField(value: &self.success) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.success != false { + try visitor.visitSingularBoolField(value: self.success, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Vpn_WakeResponse, rhs: Vpn_WakeResponse) -> Bool { + if lhs.success != rhs.success {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} diff --git a/Coder-Desktop/VPNLib/vpn.proto b/Coder-Desktop/VPNLib/vpn.proto index 59ea1933..c180a883 100644 --- a/Coder-Desktop/VPNLib/vpn.proto +++ b/Coder-Desktop/VPNLib/vpn.proto @@ -30,6 +30,7 @@ message ManagerMessage { NetworkSettingsResponse network_settings = 3; StartRequest start = 4; StopRequest stop = 5; + WakeRequest wake = 6; } } @@ -42,6 +43,7 @@ message TunnelMessage { NetworkSettingsRequest network_settings = 4; StartResponse start = 5; StopResponse stop = 6; + WakeResponse wake = 7; } } @@ -267,3 +269,11 @@ message Status { // be populated. PeerUpdate peer_update = 3; } + +// WakeRequest is sent by the manager after the system wakes from sleep. The +// tunnel uses this as a hint to rediscover network paths. +message WakeRequest {} + +message WakeResponse { + bool success = 1; +} diff --git a/Coder-Desktop/VPNLibTests/ProtoTests.swift b/Coder-Desktop/VPNLibTests/ProtoTests.swift index 6a837940..42b5f0aa 100644 --- a/Coder-Desktop/VPNLibTests/ProtoTests.swift +++ b/Coder-Desktop/VPNLibTests/ProtoTests.swift @@ -55,6 +55,16 @@ struct SenderReceiverTests { } } +@Suite(.timeLimit(.minutes(1))) +struct ProtoVersionTests { + @Test func comparable() { + #expect(ProtoVersion(1, 1) < ProtoVersion(1, 3)) + #expect(ProtoVersion(1, 3) >= ProtoVersion(1, 3)) + #expect(ProtoVersion(2, 0) > ProtoVersion(1, 9)) + #expect(!(ProtoVersion(1, 2) >= ProtoVersion(1, 3))) + } +} + @Suite(.timeLimit(.minutes(1))) struct HandshakerTests { let pipeMT = Pipe() diff --git a/Coder-Desktop/VPNLibTests/SpeakerTests.swift b/Coder-Desktop/VPNLibTests/SpeakerTests.swift index dd837d70..9fd430f8 100644 --- a/Coder-Desktop/VPNLibTests/SpeakerTests.swift +++ b/Coder-Desktop/VPNLibTests/SpeakerTests.swift @@ -38,6 +38,7 @@ struct SpeakerTests: Sendable { async let v = handshaker.handshake() try await uut.handshake() #expect(try await v == ProtoVersion(1, 1)) + #expect(await uut.negotiatedVersion == ProtoVersion(1, 1)) } @Test func handleSingleMessage() async throws { @@ -118,3 +119,81 @@ struct SpeakerTests: Sendable { try await readDone.value } } + +@Suite(.timeLimit(.minutes(1))) +struct ManagerSpeakerTests: Sendable { + let pipeMT = Pipe() + let pipeTM = Pipe() + let queue: DispatchQueue = .global(qos: .utility) + let uut: Speaker + let sender: Sender + let dispatch: DispatchIO + let receiver: Receiver + let handshaker: Handshaker + + init() { + uut = Speaker( + writeFD: pipeMT.fileHandleForWriting, + readFD: pipeTM.fileHandleForReading + ) + dispatch = DispatchIO( + type: .stream, + fileDescriptor: pipeMT.fileHandleForReading.fileDescriptor, + queue: queue, + cleanupHandler: { error in print("cleanupHandler: \(error)") } + ) + sender = Sender(writeFD: pipeTM.fileHandleForWriting) + receiver = Receiver(dispatch: dispatch, queue: queue) + handshaker = Handshaker( + writeFD: pipeTM.fileHandleForWriting, + dispatch: dispatch, queue: queue, + role: .tunnel, + versions: [ProtoVersion(1, 3)] + ) + } + + @Test func wake() async throws { + async let v = handshaker.handshake() + try await uut.handshake() + #expect(try await v == ProtoVersion(1, 3)) + #expect(await uut.negotiatedVersion == ProtoVersion(1, 3)) + // Speaker must be reading from the receiver for `unaryRPC` to return + let readDone = Task { + for try await _ in uut {} + } + async let tunnelDone = Task { + for try await req in try await receiver.messages() { + #expect(req.msg == .wake(Vpn_WakeRequest())) + var reply = Vpn_TunnelMessage() + reply.wake = Vpn_WakeResponse() + reply.rpc.responseTo = req.rpc.msgID + try await sender.send(reply) + } + } + try await uut.wake() + await uut.closeWrite() + _ = await tunnelDone + try await sender.close() + try await readDone.value + } + + @Test func wakeUnsupportedByPeer() async throws { + let oldHandshaker = Handshaker( + writeFD: pipeTM.fileHandleForWriting, + dispatch: dispatch, queue: queue, + role: .tunnel, + versions: [ProtoVersion(1, 1)] + ) + async let v = oldHandshaker.handshake() + try await uut.handshake() + #expect(try await v == ProtoVersion(1, 1)) + try await uut.wake() + await uut.closeWrite() + var count = 0 + for try await _ in try await receiver.messages() { + count += 1 + } + #expect(count == 0) + try await sender.close() + } +}