diff --git a/StosVPN.xcodeproj/project.xcworkspace/xcuserdata/stossy11.xcuserdatad/UserInterfaceState.xcuserstate b/StosVPN.xcodeproj/project.xcworkspace/xcuserdata/stossy11.xcuserdatad/UserInterfaceState.xcuserstate index cad3d93..c3edd96 100644 Binary files a/StosVPN.xcodeproj/project.xcworkspace/xcuserdata/stossy11.xcuserdatad/UserInterfaceState.xcuserstate and b/StosVPN.xcodeproj/project.xcworkspace/xcuserdata/stossy11.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/StosVPN/ContentView.swift b/StosVPN/ContentView.swift index 5992b9e..914e9cb 100644 --- a/StosVPN/ContentView.swift +++ b/StosVPN/ContentView.swift @@ -41,7 +41,8 @@ class TunnelManager: ObservableObject { static var shared = TunnelManager() - private var vpnManager: NETunnelProviderManager? + @Published var waitingOnSettings: Bool = false + @Published var vpnManager: NETunnelProviderManager? private var vpnObserver: NSObjectProtocol? private var tunnelDeviceIp: String { @@ -56,6 +57,10 @@ class TunnelManager: ObservableObject { UserDefaults.standard.string(forKey: "TunnelSubnetMask") ?? "255.255.255.0" } + private var tunnelBundleId: String { + Bundle.main.bundleIdentifier!.appending(".TunnelProv") + } + enum TunnelStatus { case disconnected case connecting @@ -83,22 +88,21 @@ class TunnelManager: ObservableObject { } } - var localizedTitle: String { + var localizedTitle: LocalizedStringKey { switch self { case .disconnected: - return NSLocalizedString("disconnected", comment: "") + return "disconnected" case .connecting: - return NSLocalizedString("connecting", comment: "") + return "connecting" case .connected: - return NSLocalizedString("connected", comment: "") + return "connected" case .disconnecting: - return NSLocalizedString("disconnecting", comment: "") + return "disconnecting" case .error: - return NSLocalizedString("error", comment: "") + return "error" } } } - private init() { loadTunnelPreferences() @@ -117,21 +121,61 @@ class TunnelManager: ObservableObject { return } + defer { + self.waitingOnSettings = true + } + self.hasLocalDeviceSupport = true if let managers = managers, !managers.isEmpty { - // Look specifically for StosVPN manager + var stosManagers = [NETunnelProviderManager]() + for manager in managers { if let proto = manager.protocolConfiguration as? NETunnelProviderProtocol, - proto.providerBundleIdentifier == Bundle.main.tunnelBundleID { - self.vpnManager = manager - self.updateTunnelStatus(from: manager.connection.status) - VPNLogger.shared.log("Loaded existing StosVPN tunnel configuration") - break + proto.providerBundleIdentifier == self.tunnelBundleId { + stosManagers.append(manager) } } + + if !stosManagers.isEmpty { + if stosManagers.count > 1 { + self.cleanupDuplicateManagers(stosManagers) + } else { + self.vpnManager = stosManagers.first + self.updateTunnelStatus(from: stosManagers.first!.connection.status) + VPNLogger.shared.log("Loaded existing StosVPN tunnel configuration") + } + } else { + VPNLogger.shared.log("No StosVPN tunnel configuration found") + } } else { - VPNLogger.shared.log("No existing tunnel configuration found") + VPNLogger.shared.log("No existing tunnel configurations found") + } + } + } + } + + private func cleanupDuplicateManagers(_ managers: [NETunnelProviderManager]) { + VPNLogger.shared.log("Found \(managers.count) StosVPN configurations. Cleaning up duplicates...") + + let activeManager = managers.first { + $0.connection.status == .connected || $0.connection.status == .connecting + } + + let managerToKeep = activeManager ?? managers.first! + self.vpnManager = managerToKeep + self.updateTunnelStatus(from: managerToKeep.connection.status) + + var removedCount = 0 + for manager in managers { + if manager != managerToKeep { + manager.removeFromPreferences { error in + if let error = error { + VPNLogger.shared.log("Error removing duplicate VPN: \(error.localizedDescription)") + } else { + removedCount += 1 + VPNLogger.shared.log("Successfully removed duplicate VPN configuration") + } } } } @@ -148,11 +192,7 @@ class TunnelManager: ObservableObject { return } - // Only update status if it's our VPN connection - if let manager = self.vpnManager, - connection == manager.connection { - self.updateTunnelStatus(from: connection.status) - } + self.handleVPNStatusChange(notification: notification) } } @@ -178,35 +218,61 @@ class TunnelManager: ObservableObject { } private func createStosVPNConfiguration(completion: @escaping (NETunnelProviderManager?) -> Void) { - let manager = NETunnelProviderManager() - manager.localizedDescription = "StosVPN" - - let proto = NETunnelProviderProtocol() - proto.providerBundleIdentifier = Bundle.main.tunnelBundleID - proto.serverAddress = NSLocalizedString("server_address_name", comment: "") - manager.protocolConfiguration = proto - - let onDemandRule = NEOnDemandRuleEvaluateConnection() - onDemandRule.interfaceTypeMatch = .any - onDemandRule.connectionRules = [NEEvaluateConnectionRule( - matchDomains: ["10.7.0.0", "10.7.0.1"], - andAction: .connectIfNeeded - )] - - manager.onDemandRules = [onDemandRule] - manager.isOnDemandEnabled = true - manager.isEnabled = true - - manager.saveToPreferences { error in - DispatchQueue.main.async { - if let error = error { - VPNLogger.shared.log("Error creating StosVPN configuration: \(error.localizedDescription)") - completion(nil) - return + NETunnelProviderManager.loadAllFromPreferences { [weak self] (managers, error) in + guard let self = self else { return } + + if let error = error { + VPNLogger.shared.log("Error checking existing VPN configurations: \(error.localizedDescription)") + completion(nil) + return + } + + if let managers = managers { + let stosManagers = managers.filter { manager in + if let proto = manager.protocolConfiguration as? NETunnelProviderProtocol { + return proto.providerBundleIdentifier == self.tunnelBundleId + } + return false } - VPNLogger.shared.log("StosVPN configuration created successfully") - completion(manager) + if !stosManagers.isEmpty { + let manager = stosManagers.first! + VPNLogger.shared.log("Found existing StosVPN configuration, using it instead of creating new one") + completion(manager) + return + } + } + + let manager = NETunnelProviderManager() + manager.localizedDescription = "StosVPN" + + let proto = NETunnelProviderProtocol() + proto.providerBundleIdentifier = self.tunnelBundleId + proto.serverAddress = "StosVPN's Local Network Tunnel" + manager.protocolConfiguration = proto + + let onDemandRule = NEOnDemandRuleEvaluateConnection() + onDemandRule.interfaceTypeMatch = .any + onDemandRule.connectionRules = [NEEvaluateConnectionRule( + matchDomains: ["10.7.0.0", "10.7.0.1"], + andAction: .connectIfNeeded + )] + + manager.onDemandRules = [onDemandRule] + manager.isOnDemandEnabled = true + manager.isEnabled = true + + manager.saveToPreferences { error in + DispatchQueue.main.async { + if let error = error { + VPNLogger.shared.log("Error creating StosVPN configuration: \(error.localizedDescription)") + completion(nil) + return + } + + VPNLogger.shared.log("StosVPN configuration created successfully") + completion(manager) + } } } } @@ -233,6 +299,7 @@ class TunnelManager: ObservableObject { } } + // MARK: - Public Methods func toggleVPNConnection() { @@ -248,16 +315,14 @@ class TunnelManager: ObservableObject { guard let self = self else { return } if let activeManager = activeManager, - (activeManager.protocolConfiguration as? NETunnelProviderProtocol)?.providerBundleIdentifier != Bundle.main.tunnelBundleID { + (activeManager.protocolConfiguration as? NETunnelProviderProtocol)?.providerBundleIdentifier != self.tunnelBundleId { VPNLogger.shared.log("Disconnecting existing VPN connection before starting StosVPN") - // Set a flag to start StosVPN after disconnection UserDefaults.standard.set(true, forKey: "ShouldStartStosVPNAfterDisconnect") activeManager.connection.stopVPNTunnel() return } - self.initializeAndStartStosVPN() } } @@ -266,11 +331,44 @@ class TunnelManager: ObservableObject { if let manager = vpnManager { startExistingVPN(manager: manager) } else { - createStosVPNConfiguration { [weak self] manager in - guard let self = self, let manager = manager else { return } + NETunnelProviderManager.loadAllFromPreferences { [weak self] managers, error in + guard let self = self else { return } - self.vpnManager = manager - self.startExistingVPN(manager: manager) + if let error = error { + VPNLogger.shared.log("Error reloading VPN configurations: \(error.localizedDescription)") + self.createStosVPNConfiguration { manager in + guard let manager = manager else { return } + self.vpnManager = manager + self.startExistingVPN(manager: manager) + } + return + } + + if let managers = managers { + let stosManagers = managers.filter { manager in + if let proto = manager.protocolConfiguration as? NETunnelProviderProtocol { + return proto.providerBundleIdentifier == self.tunnelBundleId + } + return false + } + + if !stosManagers.isEmpty { + self.vpnManager = stosManagers.first + + if stosManagers.count > 1 { + self.cleanupDuplicateManagers(stosManagers) + } + + self.startExistingVPN(manager: stosManagers.first!) + return + } + } + + self.createStosVPNConfiguration { manager in + guard let manager = manager else { return } + self.vpnManager = manager + self.startExistingVPN(manager: manager) + } } } } @@ -340,6 +438,62 @@ class TunnelManager: ObservableObject { self?.initializeAndStartStosVPN() } } + + // Check if this is a different StosVPN manager (perhaps a duplicate) + // This helps discover duplicates created by other means + NETunnelProviderManager.loadAllFromPreferences { [weak self] managers, error in + guard let self = self else { return } + + if let managers = managers, !managers.isEmpty { + let stosManagers = managers.filter { manager in + if let proto = manager.protocolConfiguration as? NETunnelProviderProtocol { + return proto.providerBundleIdentifier == self.tunnelBundleId + } + return false + } + + if stosManagers.count > 1 { + self.cleanupDuplicateManagers(stosManagers) + } + } + } + } + + // MARK: - Cleanup Utilities + + func cleanupAllVPNConfigurations() { + NETunnelProviderManager.loadAllFromPreferences { managers, error in + if let error = error { + VPNLogger.shared.log("Error loading VPN configurations for cleanup: \(error.localizedDescription)") + return + } + + guard let managers = managers else { return } + + for manager in managers { + if let proto = manager.protocolConfiguration as? NETunnelProviderProtocol, + proto.providerBundleIdentifier == self.tunnelBundleId { + + // If connected, disconnect first + if manager.connection.status == .connected || + manager.connection.status == .connecting { + manager.connection.stopVPNTunnel() + } + + manager.removeFromPreferences { error in + if let error = error { + VPNLogger.shared.log("Error removing VPN configuration: \(error.localizedDescription)") + } else { + VPNLogger.shared.log("Successfully removed VPN configuration") + } + } + } + } + + + self.vpnManager = nil + self.tunnelStatus = .disconnected + } } deinit { @@ -390,8 +544,8 @@ struct ContentView: View { } } } - .onAppear() { - if tunnelManager.tunnelStatus != .connected && autoConnect { + .onChange(of: tunnelManager.waitingOnSettings) { finished in + if tunnelManager.tunnelStatus != .connected && autoConnect && finished { tunnelManager.startVPN() } } @@ -541,24 +695,24 @@ struct ConnectionStatsView: View { .foregroundColor(.primary) HStack(spacing: 30) { StatItemView( - title: NSLocalizedString("time_connected", comment: ""), + title: "time_connected", value: formattedTime, icon: "clock.fill" ) StatItemView( - title: NSLocalizedString("status", comment: ""), - value: NSLocalizedString("active", comment: ""), + title: "status", + value: "active", icon: "checkmark.circle.fill" ) } HStack(spacing: 30) { StatItemView( - title: NSLocalizedString("network_interface", comment: ""), - value: NSLocalizedString("local", comment: ""), + title: "network_interface", + value: "local", icon: "network" ) StatItemView( - title: NSLocalizedString("assigned_ip", comment: ""), + title: "assigned_ip", value: "10.7.0.1", icon: "number" ) @@ -589,7 +743,7 @@ struct ConnectionStatsView: View { } struct StatItemView: View { - let title: String + let title: LocalizedStringKey let value: String let icon: String @@ -620,7 +774,11 @@ struct SettingsView: View { @AppStorage("TunnelFakeIP") private var fakeIP = "10.7.0.1" @AppStorage("TunnelSubnetMask") private var subnetMask = "255.255.255.0" @AppStorage("autoConnect") private var autoConnect = false + @AppStorage("shownTunnelAlert") private var shownTunnelAlert = false + @StateObject private var tunnelManager = TunnelManager.shared + @State private var showNetworkWarning = false + var body: some View { NBNavigationStack { List { @@ -632,29 +790,10 @@ struct SettingsView: View { } Section(header: Text("network_configuration")) { - HStack { - Text("device_ip") - Spacer() - TextField("device_ip", text: $deviceIP) - .multilineTextAlignment(.trailing) - .foregroundColor(.secondary) - .keyboardType(.numbersAndPunctuation) - } - HStack { - Text("tunnel_ip") - Spacer() - TextField("tunnel_ip", text: $fakeIP) - .multilineTextAlignment(.trailing) - .foregroundColor(.secondary) - .keyboardType(.numbersAndPunctuation) - } - HStack { - Text("subnet_mask") - Spacer() - TextField("subnet_mask", text: $subnetMask) - .multilineTextAlignment(.trailing) - .foregroundColor(.secondary) - .keyboardType(.numbersAndPunctuation) + Group { + networkConfigRow(label: "tunnel_ip", text: $deviceIP) + networkConfigRow(label: "device_ip", text: $fakeIP) + networkConfigRow(label: "subnet_mask", text: $subnetMask) } } @@ -690,6 +829,19 @@ struct SettingsView: View { } } } + .alert(isPresented: $showNetworkWarning) { + Alert( + title: Text("warning_alert"), + message: Text("warning_message"), + dismissButton: .cancel(Text("understand_button")) { + shownTunnelAlert = true + + deviceIP = "10.7.0.0" + fakeIP = "10.7.0.1" + subnetMask = "255.255.255.0" + } + ) + } .navigationTitle(Text("settings")) .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -704,7 +856,29 @@ struct SettingsView: View { private func dismiss() { presentationMode.wrappedValue.dismiss() - } + } + + private func networkConfigRow(label: LocalizedStringKey, text: Binding) -> some View { + HStack { + Text(label) + Spacer() + TextField(label, text: text) + .multilineTextAlignment(.trailing) + .foregroundColor(.secondary) + .keyboardType(.numbersAndPunctuation) + .onChange(of: text.wrappedValue) { newValue in + if !shownTunnelAlert { + showNetworkWarning = true + } + + tunnelManager.vpnManager?.saveToPreferences { error in + if let error = error { + VPNLogger.shared.log(error.localizedDescription) + } + } + } + } + } } @@ -939,10 +1113,10 @@ struct SetupView: View { } struct SetupPage { - let title: String - let description: String + let title: LocalizedStringKey + let description: LocalizedStringKey let imageName: String - let details: String + let details: LocalizedStringKey } struct SetupPageView: View { @@ -1000,4 +1174,4 @@ class LanguageManager: ObservableObject { #Preview { ContentView() -} \ No newline at end of file +} diff --git a/StosVPN/Localization/en.lproj/Localizable.strings b/StosVPN/Localization/en.lproj/Localizable.strings index d959e2d..0d92c2d 100644 --- a/StosVPN/Localization/en.lproj/Localizable.strings +++ b/StosVPN/Localization/en.lproj/Localizable.strings @@ -43,6 +43,9 @@ "italian" = "Italian"; "settings" = "Settings"; "done" = "Done"; +"warning_alert" = "Warning"; +"warning_message" = "Changing tunnel IP settings can disrupt your network connection. Proceed only if you are sure of what you are doing."; +"understand_button" = "I Understand"; "data_collection_policy_title" = "Data Collection Policy"; "no_data_collection" = "No Data Collection"; diff --git a/StosVPN/Localization/es.lproj/Localizable.strings b/StosVPN/Localization/es.lproj/Localizable.strings index a115886..b5a6f44 100644 --- a/StosVPN/Localization/es.lproj/Localizable.strings +++ b/StosVPN/Localization/es.lproj/Localizable.strings @@ -43,6 +43,9 @@ "italian" = "Italiano"; "settings" = "Configuración"; "done" = "Hecho"; +"warning_alert" = "Advertencia"; +"warning_message" = "Cambiar la configuración de la IP del túnel puede interrumpir tu conexión de red. Procede solo si estás seguro de lo que haces."; +"understand_button" = "Entiendo"; "data_collection_policy_title" = "Política de Recopilación de Datos"; "no_data_collection" = "No Recopilación de Datos"; diff --git a/StosVPN/Localization/it.lproj/Localizable.strings b/StosVPN/Localization/it.lproj/Localizable.strings index 5ecdb67..36fb7b9 100644 --- a/StosVPN/Localization/it.lproj/Localizable.strings +++ b/StosVPN/Localization/it.lproj/Localizable.strings @@ -43,6 +43,9 @@ "italian" = "Italiano"; "settings" = "Impostazioni"; "done" = "Fine"; +"warning_alert" = "Avviso"; +"warning_message" = "La modifica delle impostazioni IP del tunnel può interrompere la connessione di rete. Procedi solo se sei sicuro di quello che stai facendo."; +"understand_button" = "Ho capito"; "data_collection_policy_title" = "Politica di Raccolta Dati"; "no_data_collection" = "Nessuna Raccolta Dati"; diff --git a/TunnelProv/PacketTunnelProvider.swift b/TunnelProv/PacketTunnelProvider.swift index 45feab3..7b3f533 100644 --- a/TunnelProv/PacketTunnelProvider.swift +++ b/TunnelProv/PacketTunnelProvider.swift @@ -12,6 +12,9 @@ class PacketTunnelProvider: NEPacketTunnelProvider { var tunnelFakeIp: String = "10.7.0.1" var tunnelSubnetMask: String = "255.255.255.0" + private var deviceIpValue: UInt32 = 0 + private var fakeIpValue: UInt32 = 0 + override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) { if let deviceIp = options?["TunnelDeviceIP"] as? String { tunnelDeviceIp = deviceIp @@ -20,11 +23,15 @@ class PacketTunnelProvider: NEPacketTunnelProvider { tunnelFakeIp = fakeIp } + deviceIpValue = ipToUInt32(tunnelDeviceIp) + fakeIpValue = ipToUInt32(tunnelFakeIp) + let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: tunnelDeviceIp) let ipv4 = NEIPv4Settings(addresses: [tunnelDeviceIp], subnetMasks: [tunnelSubnetMask]) ipv4.includedRoutes = [NEIPv4Route(destinationAddress: tunnelDeviceIp, subnetMask: tunnelSubnetMask)] ipv4.excludedRoutes = [.default()] settings.ipv4Settings = ipv4 + setTunnelNetworkSettings(settings) { error in if error == nil { self.readPackets() @@ -34,120 +41,69 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { - // Add code here to start the process of stopping the tunnel. completionHandler() } override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?) { - // Add code here to handle the message. - if let handler = completionHandler { - handler(messageData) - } + completionHandler?(messageData) } override func sleep(completionHandler: @escaping () -> Void) { completionHandler() } - override func wake() { - // Add code here to wake up. - } + override func wake() {} private func readPackets() { packetFlow.readPackets { packets, protocols in - var output: [Data] = [] + var output = [Data](repeating: Data(), count: packets.count) + for (i, packet) in packets.enumerated() { - var modifiedPacket = packet - if protocols[i].int32Value == AF_INET { - modifiedPacket = self.packetReplaceIp(packet, self.tunnelDeviceIp, self.tunnelFakeIp, self.tunnelFakeIp, self.tunnelDeviceIp) + guard protocols[i].int32Value == AF_INET, packet.count >= 20 else { + output[i] = packet + continue } - - if modifiedPacket.count >= 20 { - var mutableBytes = [UInt8](modifiedPacket) - - // Swap bytes - (mutableBytes[12], mutableBytes[16]) = (mutableBytes[16], mutableBytes[12]) - (mutableBytes[13], mutableBytes[17]) = (mutableBytes[17], mutableBytes[13]) - (mutableBytes[14], mutableBytes[18]) = (mutableBytes[18], mutableBytes[14]) - (mutableBytes[15], mutableBytes[19]) = (mutableBytes[19], mutableBytes[15]) - - modifiedPacket = Data(mutableBytes) - } - - output.append(modifiedPacket) + + output[i] = self.processPacket(packet) } + self.packetFlow.writePackets(output, withProtocols: protocols) self.readPackets() } } - - private func packetReplaceIp(_ data: Data, _ sourceSearch: String, _ sourceReplace: String, _ destSearch: String, _ destReplace: String) -> Data { - // Check if packet is too small for IPv4 header - if data.count < 20 { - return data + private func processPacket(_ packet: Data) -> Data { + var bytes = [UInt8](packet) + + let srcIP = UInt32(bigEndian: bytes.withUnsafeBytes { $0.load(fromByteOffset: 12, as: UInt32.self) }) + let dstIP = UInt32(bigEndian: bytes.withUnsafeBytes { $0.load(fromByteOffset: 16, as: UInt32.self) }) + + if srcIP == deviceIpValue { + let replacement = fakeIpValue.bigEndian + withUnsafeBytes(of: replacement) { bytes.replaceSubrange(12..<16, with: $0) } + } + if dstIP == fakeIpValue { + let replacement = deviceIpValue.bigEndian + withUnsafeBytes(of: replacement) { bytes.replaceSubrange(16..<20, with: $0) } } - // Convert IP strings to Data with network byte order (big-endian) - func ipToUInt32(_ ipString: String) -> UInt32 { - let components = ipString.split(separator: ".") - var result: UInt32 = 0 - - if components.count == 4, - let byte1 = UInt32(components[0]), - let byte2 = UInt32(components[1]), - let byte3 = UInt32(components[2]), - let byte4 = UInt32(components[3]) { - result = (byte1 << 24) | (byte2 << 16) | (byte3 << 8) | byte4 - } - - return result - } + bytes.swapAt(12, 16) + bytes.swapAt(13, 17) + bytes.swapAt(14, 18) + bytes.swapAt(15, 19) - // Convert IP strings to UInt32 - let sourceSearchIP = ipToUInt32(sourceSearch) - let sourceReplaceIP = ipToUInt32(sourceReplace) - let destSearchIP = ipToUInt32(destSearch) - let destReplaceIP = ipToUInt32(destReplace) - - // Extract source and destination IPs from packet - var sourcePacketIP: UInt32 = 0 - var destPacketIP: UInt32 = 0 - - (data as NSData).getBytes(&sourcePacketIP, range: NSRange(location: 12, length: 4)) - (data as NSData).getBytes(&destPacketIP, range: NSRange(location: 16, length: 4)) - - if sourceSearchIP != sourcePacketIP && destSearchIP != destPacketIP { - return data - } - - let mutableData = NSMutableData(data: data) - - if sourceSearchIP == sourcePacketIP { - var sourceIP = sourceReplaceIP - mutableData.replaceBytes(in: NSRange(location: 12, length: 4), withBytes: &sourceIP) - } - - if destSearchIP == destPacketIP { - var destIP = destReplaceIP - mutableData.replaceBytes(in: NSRange(location: 16, length: 4), withBytes: &destIP) - } - - return mutableData as Data + return Data(bytes) } - // Helper function to convert IP string to Data - private func ipToData(_ ip: String) -> Data { - let components = ip.split(separator: ".") - var data = Data(capacity: 4) - - for component in components { - if let byte = UInt8(component) { - data.append(byte) - } + private func ipToUInt32(_ ipString: String) -> UInt32 { + let components = ipString.split(separator: ".") + guard components.count == 4, + let b1 = UInt32(components[0]), + let b2 = UInt32(components[1]), + let b3 = UInt32(components[2]), + let b4 = UInt32(components[3]) else { + return 0 } - - return data + return (b1 << 24) | (b2 << 16) | (b3 << 8) | b4 } } -