import Foundation /// Proxy and VPN routes fail closed: the VM's firewall lets nothing else out. public enum NetworkRoute: Equatable, Sendable { case direct case proxy(ProxySettings) case wireGuard(WireGuardConfig) /// Sessions fail to start rather than quietly browsing directly. case unusable(reason: String) } public struct ProxySettings: Equatable, Sendable { public enum Kind: String, CaseIterable, Sendable { case http case socks5 } public var kind: Kind public var host: String public var port: Int public var username: String public var password: String public init(kind: Kind, host: String, port: Int, username: String = "", password: String = "") { self.kind = kind self.host = host self.port = port self.username = username self.password = password } public func url(address: String) -> String { var userinfo = "" if !username.isEmpty { userinfo = Self.encode(username) + (password.isEmpty ? "" : ":" + Self.encode(password)) + "@" } return "\(kind.rawValue)://\(userinfo)\(address):\(port)" } /// Must match the VM firewall's list. static let localRanges = ["0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12", "192.168.0.0/16", "198.18.0.0/15"] /// A local proxy is refused: pages could reach the Mac and its network through it. public static func isLocal(_ address: String, hostNetworks: [String]) -> Bool { guard let ip = ipv4Value(address) else { return true } return (localRanges + hostNetworks).contains { cidr in let parts = cidr.split(separator: "/") guard parts.count == 2, let network = ipv4Value(String(parts[0])), let bits = Int(parts[1]), (0...32).contains(bits) else { return false } let mask: UInt32 = bits == 0 ? 0 : ~UInt32(0) << (32 - bits) return ip & mask == network & mask } } static func ipv4Value(_ text: String) -> UInt32? { guard WireGuardConfig.isIPv4(text) else { return nil } return text.split(separator: ".").reduce(UInt32(0)) { $0 << 8 | UInt32($1)! } } static func encode(_ text: String) -> String { let unreserved = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") return text.addingPercentEncoding(withAllowedCharacters: unreserved) ?? "" } } /// IPv4 only (the VM has IPv6 off). All traffic uses the tunnel, whatever AllowedIPs says. public struct WireGuardConfig: Equatable, Sendable { public var privateKey: String public var addresses: [String] /// Empty means 1.1.1.1 through the tunnel. public var dns: [String] public var mtu: Int? public var peerPublicKey: String public var presharedKey: String? public var endpointHost: String public var endpointPort: Int public var persistentKeepalive: Int? public var name: String? public enum ParseError: Error, Equatable, Sendable { case missing(String) case invalid(String) case multiplePeers } public static func parse(_ text: String) throws(ParseError) -> WireGuardConfig { var section = "" var interface: [String: String] = [:] var peers: [[String: String]] = [] for rawLine in text.split(whereSeparator: \.isNewline) { let line = rawLine.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)[0] .trimmingCharacters(in: .whitespaces) if line.isEmpty { continue } if line.hasPrefix("[") { section = line.lowercased() if section == "[peer]" { peers.append([:]) } continue } guard let equals = line.firstIndex(of: "=") else { throw .invalid(line) } let key = line[.. Int in guard let mtu = Int(text), (576...9000).contains(mtu) else { throw .invalid("MTU") } return mtu } let keepalive = try peer["persistentkeepalive"].flatMap { (text: String) throws(ParseError) -> Int? in if text.lowercased() == "off" { return nil } guard let seconds = Int(text), (0...65535).contains(seconds) else { throw .invalid("PersistentKeepalive") } return seconds == 0 ? nil : seconds } guard let endpoint = peer["endpoint"] else { throw .missing("Endpoint") } guard !endpoint.hasPrefix("["), let colon = endpoint.lastIndex(of: ":"), let port = Int(endpoint[endpoint.index(after: colon)...]), (1...65535).contains(port) else { throw .invalid("Endpoint (an IPv4 address or host name, and a port)") } let host = String(endpoint[.. String { var lines = ["[Interface]", "PrivateKey = \(privateKey)", "", "[Peer]", "PublicKey = \(peerPublicKey)"] if let presharedKey { lines.append("PresharedKey = \(presharedKey)") } lines.append("Endpoint = \(address):\(endpointPort)") lines.append("AllowedIPs = 0.0.0.0/0") lines.append("PersistentKeepalive = \(persistentKeepalive ?? 25)") return lines.joined(separator: "\n") + "\n" } // MARK: Validation /// Curve25519: 32 bytes in base64. static func isKey(_ text: String) -> Bool { text.count == 44 && Data(base64Encoded: text)?.count == 32 } static func list(_ text: String?) -> [String] { (text ?? "").split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } } static func isIPv4(_ text: String) -> Bool { let parts = text.split(separator: ".", omittingEmptySubsequences: false) return parts.count == 4 && parts.allSatisfy { part in !part.isEmpty && part.count <= 3 && part.allSatisfy(\.isASCII) && part.allSatisfy(\.isNumber) && Int(part).map { $0 <= 255 } == true } } static func ipv4CIDR(_ text: String) -> String? { let parts = text.split(separator: "/", omittingEmptySubsequences: false) guard parts.count <= 2, isIPv4(String(parts[0])) else { return nil } guard parts.count == 2 else { return text + "/32" } guard let prefix = Int(parts[1]), (0...32).contains(prefix), parts[1].allSatisfy(\.isNumber) else { return nil } return text } } extension NetworkRoute { public var label: String? { switch self { case .direct, .unusable: nil case .proxy(let proxy): proxy.kind == .socks5 ? "SOCKS5 proxy" : "HTTP proxy" case .wireGuard(let config): config.name ?? "VPN" } } } extension WireGuardConfig.ParseError { /// Finishes "The WireGuard config …". public var message: String { switch self { case .missing(let what): "has no \(what)" case .invalid(let what): "has an invalid \(what)" case .multiplePeers: "has more than one peer; Knotchy needs one server per config" } } } public struct RouteLaunch: Equatable, Sendable { /// Always includes KNOTCHY_ROUTE, so a VM missing its proxy or VPN settings stops. public var environment: [String] /// Never passed with `-e`: `container` saves the environment to disk and shows it in `inspect`. public var secrets: [String] } extension NetworkRoute { public func launch(resolve: (String) throws -> String, hostNetworks: [String] = [], allowLocalProxy: Bool = false) throws -> RouteLaunch { switch self { case .direct: return RouteLaunch(environment: ["KNOTCHY_ROUTE=direct"], secrets: []) case .unusable(let reason): throw VMBackendError.failed(reason) case .proxy(let proxy): let address = try resolve(proxy.host) if !allowLocalProxy, ProxySettings.isLocal(address, hostNetworks: hostNetworks) { let which = proxy.host == address ? address : "\(proxy.host) is \(address)" throw VMBackendError.failed( "Knotchy won't use a proxy on this Mac or your local network (\(which)): pages could reach your network through it.") } return RouteLaunch(environment: ["KNOTCHY_ROUTE=proxy", "KNOTCHY_PROXY_ADDR=\(address):\(proxy.port)"], secrets: ["KNOTCHY_PROXY_URL=\(proxy.url(address: address))"]) case .wireGuard(let config): let address = try resolve(config.endpointHost) return RouteLaunch( environment: [ "KNOTCHY_ROUTE=wireguard", "KNOTCHY_WG_ENDPOINT=\(address):\(config.endpointPort)", "KNOTCHY_WG_ADDRESS=\(config.addresses.joined(separator: " "))", "KNOTCHY_WG_DNS=\(config.dns.joined(separator: " "))", "KNOTCHY_WG_MTU=\(config.mtu ?? 1420)", ], secrets: ["KNOTCHY_WG_SETCONF=\(Data(config.setconf(endpointAddress: address).utf8).base64EncodedString())"] ) } } }