From ece4952eac90aae363261996f20e88578aae5772 Mon Sep 17 00:00:00 2001 From: mahee96 <47920326+mahee96@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:56:01 +0530 Subject: [PATCH 1/8] multiple fixes for UI and removed hardcoded constants inline - also made tun iface ip /32 --- LocalDevVPN/Constants.swift | 15 +++ LocalDevVPN/ContentView.swift | 127 ++++++++++++++++++++------ TunnelProv/Constants.swift | 15 +++ TunnelProv/PacketTunnelProvider.swift | 64 +++++++++++-- 4 files changed, 186 insertions(+), 35 deletions(-) create mode 100644 LocalDevVPN/Constants.swift create mode 100644 TunnelProv/Constants.swift diff --git a/LocalDevVPN/Constants.swift b/LocalDevVPN/Constants.swift new file mode 100644 index 0000000..e0ca1b3 --- /dev/null +++ b/LocalDevVPN/Constants.swift @@ -0,0 +1,15 @@ +// +// Constants.swift +// LocalDevVPN +// +// Created by Magesh K on 11/07/26. +// Copyright © 2026 SideStore. All rights reserved. +// + +import Foundation + +struct TunnelConstants { + static let defaultDeviceIP = "10.7.0.0" + static let defaultFakeIP = "10.7.0.1" + static let defaultSubnetMask = "255.255.255.255" +} diff --git a/LocalDevVPN/ContentView.swift b/LocalDevVPN/ContentView.swift index e8dd72a..a04679f 100644 --- a/LocalDevVPN/ContentView.swift +++ b/LocalDevVPN/ContentView.swift @@ -56,15 +56,15 @@ class TunnelManager: ObservableObject { }() private var tunnelDeviceIp: String { - UserDefaults.standard.string(forKey: "TunnelDeviceIP") ?? "10.7.0.0" + UserDefaults.standard.string(forKey: "TunnelDeviceIP") ?? TunnelConstants.defaultDeviceIP } private var tunnelFakeIp: String { - UserDefaults.standard.string(forKey: "TunnelFakeIP") ?? "10.7.0.1" + UserDefaults.standard.string(forKey: "TunnelFakeIP") ?? TunnelConstants.defaultFakeIP } private var tunnelSubnetMask: String { - UserDefaults.standard.string(forKey: "TunnelSubnetMask") ?? "255.255.255.0" + UserDefaults.standard.string(forKey: "TunnelSubnetMask") ?? TunnelConstants.defaultSubnetMask } private var tunnelBundleId: String { @@ -281,7 +281,7 @@ class TunnelManager: ObservableObject { let onDemandRule = NEOnDemandRuleEvaluateConnection() onDemandRule.interfaceTypeMatch = .any onDemandRule.connectionRules = [NEEvaluateConnectionRule( - matchDomains: ["10.7.0.0", "10.7.0.1"], + matchDomains: [self.tunnelDeviceIp, self.tunnelFakeIp], andAction: .connectIfNeeded )] @@ -539,6 +539,46 @@ class TunnelManager: ObservableObject { UserDefaults.standard.removeObject(forKey: "ShouldStartLocalDevVPNAfterDisconnect") } + func updateConfigAndRestart(shouldRestart: Bool) { + if isSimulator { return } + + guard let manager = vpnManager else { return } + + manager.loadFromPreferences { [weak self] error in + guard let self = self else { return } + if let error = error { + VPNLogger.shared.log("Error loading preferences for update: \(error.localizedDescription)") + return + } + + let onDemandRule = NEOnDemandRuleEvaluateConnection() + onDemandRule.interfaceTypeMatch = .any + onDemandRule.connectionRules = [NEEvaluateConnectionRule( + matchDomains: [self.tunnelDeviceIp, self.tunnelFakeIp], + andAction: .connectIfNeeded + )] + manager.onDemandRules = [onDemandRule] + manager.isOnDemandEnabled = true + manager.isEnabled = true + + manager.saveToPreferences { [weak self] error in + guard let self = self else { return } + if let error = error { + VPNLogger.shared.log("Error saving updated preferences: \(error.localizedDescription)") + return + } + + VPNLogger.shared.log("LocalDevVPN configuration updated successfully in preferences") + + if shouldRestart { + VPNLogger.shared.log("Restarting LocalDevVPN tunnel to apply new configuration...") + UserDefaults.standard.set(true, forKey: "ShouldStartLocalDevVPNAfterDisconnect") + manager.connection.stopVPNTunnel() + } + } + } + } + func handleVPNStatusChange(notification: Notification) { guard let connection = notification.object as? NEVPNConnection else { return } @@ -786,7 +826,7 @@ extension View { struct StatusOverviewCard: View { @StateObject private var tunnelManager = TunnelManager.shared - @AppStorage("TunnelDeviceIP") private var deviceIP = "10.7.0.0" + @AppStorage("TunnelDeviceIP") private var deviceIP = TunnelConstants.defaultDeviceIP var body: some View { DashboardCard { @@ -972,9 +1012,9 @@ struct ConnectionButton: View { struct ConnectionStatsView: View { @StateObject private var tunnelManager = TunnelManager.shared - @AppStorage("TunnelDeviceIP") private var deviceIP = "10.7.0.0" - @AppStorage("TunnelFakeIP") private var fakeIP = "10.7.0.1" - @AppStorage("TunnelSubnetMask") private var subnetMask = "255.255.255.0" + @AppStorage("TunnelDeviceIP") private var deviceIP = TunnelConstants.defaultDeviceIP + @AppStorage("TunnelFakeIP") private var fakeIP = TunnelConstants.defaultFakeIP + @AppStorage("TunnelSubnetMask") private var subnetMask = TunnelConstants.defaultSubnetMask var body: some View { DashboardCard { @@ -994,15 +1034,15 @@ struct ConnectionStatsView: View { .foregroundColor(.secondary) ConnectionInfoRow( - title: "local_device_ip", + title: "tunnel_ip", value: deviceIP, - icon: "desktopcomputer" + icon: "point.3.filled.connected.trianglepath.dotted" ) ConnectionInfoRow( - title: "tunnel_ip", + title: "device_ip", value: fakeIP, - icon: "point.3.filled.connected.trianglepath.dotted" + icon: "desktopcomputer" ) ConnectionInfoRow( @@ -1085,9 +1125,9 @@ struct DashboardCard: View { struct SettingsView: View { @Environment(\.presentationMode) var presentationMode @AppStorage("selectedLanguage") private var selectedLanguage = Locale.current.languageCode ?? "en" - @AppStorage("TunnelDeviceIP") private var deviceIP = "10.7.0.0" - @AppStorage("TunnelFakeIP") private var fakeIP = "10.7.0.1" - @AppStorage("TunnelSubnetMask") private var subnetMask = "255.255.255.0" + @AppStorage("TunnelDeviceIP") private var deviceIP = TunnelConstants.defaultDeviceIP + @AppStorage("TunnelFakeIP") private var fakeIP = TunnelConstants.defaultFakeIP + @AppStorage("TunnelSubnetMask") private var subnetMask = TunnelConstants.defaultSubnetMask @AppStorage("autoConnect") private var autoConnect = false @AppStorage("shownTunnelAlert") private var shownTunnelAlert = false @StateObject private var tunnelManager = TunnelManager.shared @@ -1095,6 +1135,11 @@ struct SettingsView: View { @State private var showNetworkWarning = false @State private var showRestartPopUp = false + @State private var isDirty = false + @State private var showConfirmAlert = false + @State private var initialDeviceIP = "" + @State private var initialFakeIP = "" + @State private var initialSubnetMask = "" var body: some View { NBNavigationStack { @@ -1167,18 +1212,51 @@ struct SettingsView: View { dismissButton: .cancel(Text("understand_button")) { shownTunnelAlert = true - deviceIP = "10.7.0.0" - fakeIP = "10.7.0.1" - subnetMask = "255.255.255.0" + deviceIP = TunnelConstants.defaultDeviceIP + fakeIP = TunnelConstants.defaultFakeIP + subnetMask = TunnelConstants.defaultSubnetMask } ) } + .alert(isPresented: $showConfirmAlert) { + Alert( + title: Text("Settings Saved"), + message: Text("VPN configuration has been updated successfully."), + dismissButton: .default(Text("OK")) { + dismiss() + } + ) + } + .onAppear { + initialDeviceIP = deviceIP + initialFakeIP = fakeIP + initialSubnetMask = subnetMask + isDirty = false + } .navigationTitle(Text("settings")) .tvOSNavigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("done") { - dismiss() + if isDirty { + ToolbarItem(placement: .topBarLeading) { + Button("cancel") { + deviceIP = initialDeviceIP + fakeIP = initialFakeIP + subnetMask = initialSubnetMask + dismiss() + } + } + ToolbarItem(placement: .topBarTrailing) { + Button("confirm") { + let isRunning = tunnelManager.tunnelStatus == .connected || tunnelManager.tunnelStatus == .connecting + tunnelManager.updateConfigAndRestart(shouldRestart: isRunning) + showConfirmAlert = true + } + } + } else { + ToolbarItem(placement: .topBarTrailing) { + Button("done") { + dismiss() + } } } } @@ -1198,15 +1276,10 @@ struct SettingsView: View { .foregroundColor(.secondary) .keyboardType(.numbersAndPunctuation) .onChange(of: text.wrappedValue) { _ in + isDirty = true if !shownTunnelAlert { showNetworkWarning = true } - - tunnelManager.vpnManager?.saveToPreferences { error in - if let error = error { - VPNLogger.shared.log(error.localizedDescription) - } - } } } } diff --git a/TunnelProv/Constants.swift b/TunnelProv/Constants.swift new file mode 100644 index 0000000..8a935eb --- /dev/null +++ b/TunnelProv/Constants.swift @@ -0,0 +1,15 @@ +// +// Constants.swift +// TunnelProv +// +// Created by Magesh K on 11/07/26. +// Copyright © 2026 SideStore. All rights reserved. +// + +import Foundation + +struct TunnelConstants { + static let defaultDeviceIP = "10.7.0.0" + static let defaultFakeIP = "10.7.0.1" + static let defaultSubnetMask = "255.255.255.255" +} diff --git a/TunnelProv/PacketTunnelProvider.swift b/TunnelProv/PacketTunnelProvider.swift index 8d7c352..f93527d 100644 --- a/TunnelProv/PacketTunnelProvider.swift +++ b/TunnelProv/PacketTunnelProvider.swift @@ -6,34 +6,67 @@ // import NetworkExtension +#if DEBUG +import os.log +#endif + +@inline(__always) +private func tunnelLog(_ message: @autoclosure () -> String) { +#if DEBUG + os_log("[TunnelProv] %{public}@", type: .error, message()) +#endif +} class PacketTunnelProvider: NEPacketTunnelProvider { - var tunnelDeviceIp: String = "10.7.0.0" - var tunnelFakeIp: String = "10.7.0.1" - var tunnelSubnetMask: String = "255.255.255.0" + var tunnelDeviceIp: String = TunnelConstants.defaultDeviceIP + var tunnelFakeIp: String = TunnelConstants.defaultFakeIP + var tunnelSubnetMask: String = TunnelConstants.defaultSubnetMask private var deviceIpValue: UInt32 = 0 private var fakeIpValue: UInt32 = 0 override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) { + if let options = options { + for (key, val) in options { + tunnelLog("startTunnel option \(key) = \(String(describing: val))") + } + } else { + tunnelLog("startTunnel: options is nil") + } + if let deviceIp = options?["TunnelDeviceIP"] as? String { + tunnelLog("Option TunnelDeviceIP overridden to: \(deviceIp)") tunnelDeviceIp = deviceIp } if let fakeIp = options?["TunnelFakeIP"] as? String { + tunnelLog("Option TunnelFakeIP overridden to: \(fakeIp)") tunnelFakeIp = fakeIp } + if let subnetMask = options?["TunnelSubnetMask"] as? String { + tunnelLog("Option TunnelSubnetMask overridden to: \(subnetMask)") + tunnelSubnetMask = subnetMask + } deviceIpValue = ipToUInt32(tunnelDeviceIp) fakeIpValue = ipToUInt32(tunnelFakeIp) - let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: tunnelDeviceIp) + tunnelLog("Configuring P2P settings: remoteAddress=\(tunnelFakeIp), localAddress=\(tunnelDeviceIp), subnetMask=\(tunnelSubnetMask)") + let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: tunnelFakeIp) let ipv4 = NEIPv4Settings(addresses: [tunnelDeviceIp], subnetMasks: [tunnelSubnetMask]) - ipv4.includedRoutes = [NEIPv4Route(destinationAddress: tunnelDeviceIp, subnetMask: tunnelSubnetMask)] + ipv4.includedRoutes = [ + NEIPv4Route(destinationAddress: tunnelDeviceIp, subnetMask: tunnelSubnetMask), + NEIPv4Route(destinationAddress: tunnelFakeIp, subnetMask: tunnelSubnetMask) + ] ipv4.excludedRoutes = [.default()] settings.ipv4Settings = ipv4 + tunnelLog("Calling setTunnelNetworkSettings...") setTunnelNetworkSettings(settings) { error in - guard error == nil else { return completionHandler(error) } + if let error = error { + tunnelLog("Failed to set settings: \(error.localizedDescription)") + return completionHandler(error) + } + tunnelLog("Tunnel network settings set successfully. Starting packet loops.") self.setPackets() completionHandler(nil) } @@ -44,15 +77,22 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let fakeip = self.fakeIpValue let deviceip = self.deviceIpValue var modified = packets + for i in modified.indices where protocols[i].int32Value == AF_INET && modified[i].count >= 20 { modified[i].withUnsafeMutableBytes { bytes in guard let ptr = bytes.baseAddress?.assumingMemoryBound(to: UInt32.self) else { return } let src = UInt32(bigEndian: ptr[3]) let dst = UInt32(bigEndian: ptr[4]) - if src == deviceip { ptr[3] = fakeip.bigEndian } - if dst == fakeip { ptr[4] = deviceip.bigEndian } + + if src == deviceip { + ptr[3] = fakeip.bigEndian + } + if dst == fakeip { + ptr[4] = deviceip.bigEndian + } } } + self.packetFlow.writePackets(modified, withProtocols: protocols) setPackets() } @@ -69,4 +109,12 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } return (b1 << 24) | (b2 << 16) | (b3 << 8) | b4 } + + private func ipToString(_ ip: UInt32) -> String { + let b1 = (ip >> 24) & 0xFF + let b2 = (ip >> 16) & 0xFF + let b3 = (ip >> 8) & 0xFF + let b4 = ip & 0xFF + return "\(b1).\(b2).\(b3).\(b4)" + } } From cfc1037631e30eae60d36e1dcf0c0267841db5e3 Mon Sep 17 00:00:00 2001 From: mahee96 <47920326+mahee96@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:15:23 +0530 Subject: [PATCH 2/8] rework: fix incorrect tunnel configuration causing confusion + switch to explicit CIDR format coz subnet mask allows invalids like 255.255.255.251 which are preventable using CIDR prefixes /32, /24 etc. --- LocalDevVPN.xcodeproj/project.pbxproj | 10 +- LocalDevVPN/Constants.swift | 8 +- LocalDevVPN/ContentView.swift | 309 +++++++++----- .../Localization/en.lproj/Localizable.strings | 2 + TunnelProv/CIDRValidator.swift | 387 ++++++++++++++++++ TunnelProv/Constants.swift | 15 - TunnelProv/PacketTunnelProvider.swift | 88 ++-- 7 files changed, 646 insertions(+), 173 deletions(-) create mode 100644 TunnelProv/CIDRValidator.swift delete mode 100644 TunnelProv/Constants.swift diff --git a/LocalDevVPN.xcodeproj/project.pbxproj b/LocalDevVPN.xcodeproj/project.pbxproj index d8b7294..b4b17f3 100644 --- a/LocalDevVPN.xcodeproj/project.pbxproj +++ b/LocalDevVPN.xcodeproj/project.pbxproj @@ -62,11 +62,19 @@ ); target = 4EB3C76D2D96715400C1B22C /* TunnelProv */; }; + A809C3653031D3E6005DF922 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + CIDRValidator.swift, + Constants.swift, + ); + target = 4EB3C7572D96631A00C1B22C /* LocalDevVPN */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ 4EB3C75A2D96631A00C1B22C /* LocalDevVPN */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (190996C92ED0FC91006411F1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = LocalDevVPN; sourceTree = ""; }; - 4EB3C7722D96715400C1B22C /* TunnelProv */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4EB3C77A2D96715400C1B22C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = TunnelProv; sourceTree = ""; }; + 4EB3C7722D96715400C1B22C /* TunnelProv */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (A809C3653031D3E6005DF922 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 4EB3C77A2D96715400C1B22C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = TunnelProv; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ diff --git a/LocalDevVPN/Constants.swift b/LocalDevVPN/Constants.swift index e0ca1b3..535422d 100644 --- a/LocalDevVPN/Constants.swift +++ b/LocalDevVPN/Constants.swift @@ -3,13 +3,13 @@ // LocalDevVPN // // Created by Magesh K on 11/07/26. -// Copyright © 2026 SideStore. All rights reserved. +// Copyright © 2026 LocalDevVPN. All rights reserved. // import Foundation struct TunnelConstants { - static let defaultDeviceIP = "10.7.0.0" - static let defaultFakeIP = "10.7.0.1" - static let defaultSubnetMask = "255.255.255.255" + static let defaultIfaceIP = "10.7.0.0/24" + static let defaultPeerIP = "11.7.0.0/24" + static let defaultAllowIntermediateAddresses = false } diff --git a/LocalDevVPN/ContentView.swift b/LocalDevVPN/ContentView.swift index a04679f..1c45642 100644 --- a/LocalDevVPN/ContentView.swift +++ b/LocalDevVPN/ContentView.swift @@ -55,16 +55,12 @@ class TunnelManager: ObservableObject { #endif }() - private var tunnelDeviceIp: String { - UserDefaults.standard.string(forKey: "TunnelDeviceIP") ?? TunnelConstants.defaultDeviceIP + private var tunnelIfaceIP: String { + UserDefaults.standard.string(forKey: "TunnelIfaceIP") ?? TunnelConstants.defaultIfaceIP } - private var tunnelFakeIp: String { - UserDefaults.standard.string(forKey: "TunnelFakeIP") ?? TunnelConstants.defaultFakeIP - } - - private var tunnelSubnetMask: String { - UserDefaults.standard.string(forKey: "TunnelSubnetMask") ?? TunnelConstants.defaultSubnetMask + private var tunnelPeerIP: String { + UserDefaults.standard.string(forKey: "TunnelPeerIP") ?? TunnelConstants.defaultPeerIP } private var tunnelBundleId: String { @@ -281,7 +277,7 @@ class TunnelManager: ObservableObject { let onDemandRule = NEOnDemandRuleEvaluateConnection() onDemandRule.interfaceTypeMatch = .any onDemandRule.connectionRules = [NEEvaluateConnectionRule( - matchDomains: [self.tunnelDeviceIp, self.tunnelFakeIp], + matchDomains: [self.tunnelIfaceIP, self.tunnelPeerIP], andAction: .connectIfNeeded )] @@ -500,9 +496,8 @@ class TunnelManager: ObservableObject { } let options: [String: NSObject] = [ - "TunnelDeviceIP": self.tunnelDeviceIp as NSObject, - "TunnelFakeIP": self.tunnelFakeIp as NSObject, - "TunnelSubnetMask": self.tunnelSubnetMask as NSObject, + "TunnelIfaceIP": self.tunnelIfaceIP as NSObject, + "TunnelPeerIP": self.tunnelPeerIP as NSObject, ] do { @@ -554,7 +549,7 @@ class TunnelManager: ObservableObject { let onDemandRule = NEOnDemandRuleEvaluateConnection() onDemandRule.interfaceTypeMatch = .any onDemandRule.connectionRules = [NEEvaluateConnectionRule( - matchDomains: [self.tunnelDeviceIp, self.tunnelFakeIp], + matchDomains: [self.tunnelIfaceIP, self.tunnelPeerIP], andAction: .connectIfNeeded )] manager.onDemandRules = [onDemandRule] @@ -562,7 +557,7 @@ class TunnelManager: ObservableObject { manager.isEnabled = true manager.saveToPreferences { [weak self] error in - guard let self = self else { return } + guard self != nil else { return } if let error = error { VPNLogger.shared.log("Error saving updated preferences: \(error.localizedDescription)") return @@ -826,7 +821,7 @@ extension View { struct StatusOverviewCard: View { @StateObject private var tunnelManager = TunnelManager.shared - @AppStorage("TunnelDeviceIP") private var deviceIP = TunnelConstants.defaultDeviceIP + @AppStorage("TunnelIfaceIP") private var tunnelIfaceIP = TunnelConstants.defaultIfaceIP var body: some View { DashboardCard { @@ -860,7 +855,7 @@ struct StatusOverviewCard: View { private var statusTip: String { switch tunnelManager.tunnelStatus { case .connected: - return String(format: NSLocalizedString("connected_to_ip", comment: ""), deviceIP) + return String(format: NSLocalizedString("connected_to_ip", comment: ""), tunnelIfaceIP) case .connecting: return NSLocalizedString("ios_might_ask_you_to_allow_the_vpn", comment: "") case .disconnecting: @@ -1012,9 +1007,8 @@ struct ConnectionButton: View { struct ConnectionStatsView: View { @StateObject private var tunnelManager = TunnelManager.shared - @AppStorage("TunnelDeviceIP") private var deviceIP = TunnelConstants.defaultDeviceIP - @AppStorage("TunnelFakeIP") private var fakeIP = TunnelConstants.defaultFakeIP - @AppStorage("TunnelSubnetMask") private var subnetMask = TunnelConstants.defaultSubnetMask + @AppStorage("TunnelIfaceIP") private var tunnelIfaceIP = TunnelConstants.defaultIfaceIP + @AppStorage("TunnelPeerIP") private var tunnelPeerIP = TunnelConstants.defaultPeerIP var body: some View { DashboardCard { @@ -1035,25 +1029,18 @@ struct ConnectionStatsView: View { ConnectionInfoRow( title: "tunnel_ip", - value: deviceIP, + value: tunnelIfaceIP, icon: "point.3.filled.connected.trianglepath.dotted" ) ConnectionInfoRow( title: "device_ip", - value: fakeIP, + value: tunnelPeerIP, icon: "desktopcomputer" ) - - ConnectionInfoRow( - title: "subnet_mask", - value: subnetMask, - icon: "network" - ) } } } - } struct StatItemView: View { @@ -1122,24 +1109,51 @@ struct DashboardCard: View { // MARK: - Updated SettingsView +enum SettingsAlert: Identifiable { + case savePrompt + case discardPrompt + case fixErrors + case networkWarning + case restartApp + + var id: Int { hashValue } +} + struct SettingsView: View { @Environment(\.presentationMode) var presentationMode @AppStorage("selectedLanguage") private var selectedLanguage = Locale.current.languageCode ?? "en" - @AppStorage("TunnelDeviceIP") private var deviceIP = TunnelConstants.defaultDeviceIP - @AppStorage("TunnelFakeIP") private var fakeIP = TunnelConstants.defaultFakeIP - @AppStorage("TunnelSubnetMask") private var subnetMask = TunnelConstants.defaultSubnetMask + @AppStorage("TunnelIfaceIP") private var savedIfaceIP = TunnelConstants.defaultIfaceIP + @AppStorage("TunnelPeerIP") private var savedPeerIP = TunnelConstants.defaultPeerIP + @AppStorage("allowIntermediateAddresses") private var savedAllowIntermediate = TunnelConstants.defaultAllowIntermediateAddresses @AppStorage("autoConnect") private var autoConnect = false @AppStorage("shownTunnelAlert") private var shownTunnelAlert = false @StateObject private var tunnelManager = TunnelManager.shared @AppStorage("hasNotCompletedSetup") private var hasNotCompletedSetup = true - @State private var showNetworkWarning = false - @State private var showRestartPopUp = false - @State private var isDirty = false - @State private var showConfirmAlert = false - @State private var initialDeviceIP = "" - @State private var initialFakeIP = "" - @State private var initialSubnetMask = "" + // Draft State (Edited in UI, only committed upon confirmation) + @State private var draftIfaceIP = "" + @State private var draftPeerIP = "" + @State private var draftAllowIntermediate = TunnelConstants.defaultAllowIntermediateAddresses + + // Validation State + @State private var ifaceError: String? = nil + @State private var peerError: String? = nil + @State private var pairError: String? = nil + @State private var ifaceWarnings: [String] = [] + @State private var peerWarnings: [String] = [] + + // Alert State (Single active alert to prevent SwiftUI collisions) + @State private var activeAlert: SettingsAlert? = nil + + private var isDirty: Bool { + draftIfaceIP != savedIfaceIP || + draftPeerIP != savedPeerIP || + draftAllowIntermediate != savedAllowIntermediate + } + + private var isValid: Bool { + ifaceError == nil && peerError == nil && pairError == nil + } var body: some View { NBNavigationStack { @@ -1151,12 +1165,48 @@ struct SettingsView: View { } } - Section(header: Text("network_configuration")) { - Group { - networkConfigRow(label: "tunnel_ip", text: $deviceIP) - networkConfigRow(label: "device_ip", text: $fakeIP) - networkConfigRow(label: "subnet_mask", text: $subnetMask) + Section( + header: Text("network_configuration"), + footer: Text("allow_intermediate_addresses_desc") + ) { + VStack(alignment: .leading, spacing: 6) { + networkConfigRow(label: "tunnel_ip", text: $draftIfaceIP) + if let error = ifaceError { + Text(error) + .font(.caption2) + .foregroundColor(.red) + } + ForEach(ifaceWarnings, id: \.self) { warning in + Text(warning) + .font(.caption2) + .foregroundColor(.orange) + } + } + + VStack(alignment: .leading, spacing: 6) { + networkConfigRow(label: "device_ip", text: $draftPeerIP) + if let error = peerError { + Text(error) + .font(.caption2) + .foregroundColor(.red) + } + ForEach(peerWarnings, id: \.self) { warning in + Text(warning) + .font(.caption2) + .foregroundColor(.orange) + } + } + + if let pairErr = pairError { + Text(pairErr) + .font(.caption2) + .foregroundColor(.red) } + + Toggle("allow_intermediate_addresses", isOn: $draftAllowIntermediate) + .onChange(of: draftAllowIntermediate) { _ in + validateAll() + } } Section(header: Text("app_information")) { @@ -1192,72 +1242,85 @@ struct SettingsView: View { .onChange(of: selectedLanguage) { newValue in let languageCode = newValue LanguageManager.shared.updateLanguage(to: languageCode) - showRestartPopUp = true - } - .alert(isPresented: $showRestartPopUp) { - Alert( - title: Text("restart_title"), - message: Text("restart_message"), - dismissButton: .cancel(Text("understand_button")) { - showRestartPopUp = true - } - ) + activeAlert = .restartApp } } } - .alert(isPresented: $showNetworkWarning) { - Alert( - title: Text("warning_alert"), - message: Text("warning_message"), - dismissButton: .cancel(Text("understand_button")) { - shownTunnelAlert = true - - deviceIP = TunnelConstants.defaultDeviceIP - fakeIP = TunnelConstants.defaultFakeIP - subnetMask = TunnelConstants.defaultSubnetMask - } - ) - } - .alert(isPresented: $showConfirmAlert) { - Alert( - title: Text("Settings Saved"), - message: Text("VPN configuration has been updated successfully."), - dismissButton: .default(Text("OK")) { - dismiss() - } - ) - } .onAppear { - initialDeviceIP = deviceIP - initialFakeIP = fakeIP - initialSubnetMask = subnetMask - isDirty = false + draftIfaceIP = savedIfaceIP + draftPeerIP = savedPeerIP + draftAllowIntermediate = savedAllowIntermediate + validateAll() } .navigationTitle(Text("settings")) .tvOSNavigationBarTitleDisplayMode(.inline) .toolbar { - if isDirty { - ToolbarItem(placement: .topBarLeading) { - Button("cancel") { - deviceIP = initialDeviceIP - fakeIP = initialFakeIP - subnetMask = initialSubnetMask + ToolbarItem(placement: .topBarTrailing) { + Button("done") { + if isDirty { + if isValid { + activeAlert = .savePrompt + } else { + activeAlert = .fixErrors + } + } else { dismiss() } } - ToolbarItem(placement: .topBarTrailing) { - Button("confirm") { - let isRunning = tunnelManager.tunnelStatus == .connected || tunnelManager.tunnelStatus == .connecting - tunnelManager.updateConfigAndRestart(shouldRestart: isRunning) - showConfirmAlert = true + } + ToolbarItem(placement: .topBarLeading) { + Button("cancel") { + if isDirty { + activeAlert = .discardPrompt + } else { + dismiss() } } - } else { - ToolbarItem(placement: .topBarTrailing) { - Button("done") { + } + } + .alert(item: $activeAlert) { alertType in + switch alertType { + case .savePrompt: + return Alert( + title: Text("Save Configuration Changes?"), + message: Text("Do you want to save and apply the new network configuration?"), + primaryButton: .default(Text("Save & Apply")) { + saveAndApply() + }, + secondaryButton: .cancel(Text("cancel")) + ) + case .discardPrompt: + return Alert( + title: Text("Discard Changes?"), + message: Text("You have unsaved changes. Are you sure you want to discard them?"), + primaryButton: .destructive(Text("Discard")) { dismiss() + }, + secondaryButton: .cancel(Text("cancel")) + ) + case .fixErrors: + return Alert( + title: Text("Invalid Configuration"), + message: Text("Please resolve all configuration errors before saving."), + dismissButton: .default(Text("OK")) + ) + case .networkWarning: + return Alert( + title: Text("warning_alert"), + message: Text("warning_message"), + dismissButton: .cancel(Text("understand_button")) { + shownTunnelAlert = true + draftIfaceIP = TunnelConstants.defaultIfaceIP + draftPeerIP = TunnelConstants.defaultPeerIP + validateAll() } - } + ) + case .restartApp: + return Alert( + title: Text("restart_title"), + message: Text("restart_message"), + dismissButton: .cancel(Text("understand_button")) + ) } } } @@ -1267,6 +1330,60 @@ struct SettingsView: View { presentationMode.wrappedValue.dismiss() } + private func saveAndApply() { + savedIfaceIP = draftIfaceIP + savedPeerIP = draftPeerIP + savedAllowIntermediate = draftAllowIntermediate + + let isRunning = tunnelManager.tunnelStatus == .connected || tunnelManager.tunnelStatus == .connecting + tunnelManager.updateConfigAndRestart(shouldRestart: isRunning) + dismiss() + } + + private func validateAll() { + ifaceError = nil + peerError = nil + pairError = nil + ifaceWarnings = [] + peerWarnings = [] + + do { + let res = try CIDRValidator.shared.validateCIDR( + draftIfaceIP, + isRouteDestination: false, + allowIntermediateAddresses: true, + defaultPrefix: 24 + ) + ifaceWarnings = res.warnings + } catch { + ifaceError = error.localizedDescription + } + + do { + let res = try CIDRValidator.shared.validateCIDR( + draftPeerIP, + isRouteDestination: true, + allowIntermediateAddresses: draftAllowIntermediate, + defaultPrefix: 24 + ) + peerWarnings = res.warnings + } catch { + peerError = error.localizedDescription + } + + if ifaceError == nil && peerError == nil { + do { + _ = try CIDRValidator.shared.validatePair( + tunnelIfaceInput: draftIfaceIP, + tunnelPeerInput: draftPeerIP, + allowIntermediateAddresses: draftAllowIntermediate + ) + } catch { + pairError = error.localizedDescription + } + } + } + private func networkConfigRow(label: LocalizedStringKey, text: Binding) -> some View { HStack { Text(label) @@ -1276,9 +1393,9 @@ struct SettingsView: View { .foregroundColor(.secondary) .keyboardType(.numbersAndPunctuation) .onChange(of: text.wrappedValue) { _ in - isDirty = true + validateAll() if !shownTunnelAlert { - showNetworkWarning = true + activeAlert = .networkWarning } } } diff --git a/LocalDevVPN/Localization/en.lproj/Localizable.strings b/LocalDevVPN/Localization/en.lproj/Localizable.strings index b08c6c0..c84e8b0 100644 --- a/LocalDevVPN/Localization/en.lproj/Localizable.strings +++ b/LocalDevVPN/Localization/en.lproj/Localizable.strings @@ -60,6 +60,8 @@ "device_ip" = "Device IP"; "tunnel_ip" = "Tunnel IP"; "subnet_mask" = "Subnet Mask"; +"allow_intermediate_addresses" = "Allow Intermediate Addresses"; +"allow_intermediate_addresses_desc" = "When intermediate addresses are allowed, non-base CIDR addresses (e.g. 11.7.0.2/30) are accepted and normalized to their network base route (11.7.0.0/30)."; "app_information" = "App Information"; "privacy_policy" = "Privacy Policy"; "data_collection_policy" = "Data Collection Policy"; diff --git a/TunnelProv/CIDRValidator.swift b/TunnelProv/CIDRValidator.swift new file mode 100644 index 0000000..4eee4c0 --- /dev/null +++ b/TunnelProv/CIDRValidator.swift @@ -0,0 +1,387 @@ +// +// CIDRValidator.swift +// LocalDevVPN +// +// Created by Magesh K on 16/08/26. +// Copyright © 2026 LocalDevVPN. All rights reserved. +// + +import Foundation + +public enum IPCategory: String, Sendable, Equatable { + case unicast + case defaultRoute + case loopback + case multicast + case broadcast + case unspecified + case linkLocal +} + +public struct CIDREndpoint: Equatable, Sendable { + public let raw: String + public let ip: String + public let prefix: Int + public let subnetMask: String + + public var formattedCIDR: String { + "\(ip)/\(prefix)" + } + + public init(_ input: String, defaultPrefix: Int = 32) { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + self.raw = trimmed + let parts = trimmed.split(separator: "/", omittingEmptySubsequences: false) + + let ipPart = String(parts.first ?? "").trimmingCharacters(in: .whitespaces) + self.ip = ipPart.isEmpty ? "0.0.0.0" : ipPart + + if parts.count == 2, let parsedPrefix = Int(parts[1].trimmingCharacters(in: .whitespaces)), (0...32).contains(parsedPrefix) { + self.prefix = parsedPrefix + } else { + self.prefix = defaultPrefix + } + + self.subnetMask = Self.prefixToSubnetMask(self.prefix) + } + + public init(ip: String, prefix: Int, subnetMask: String? = nil, raw: String? = nil) { + self.ip = ip + self.prefix = prefix + self.subnetMask = subnetMask ?? Self.prefixToSubnetMask(prefix) + self.raw = raw ?? "\(ip)/\(prefix)" + } + + public static func prefixToMaskRaw(_ prefix: Int) -> UInt32 { + guard prefix > 0 else { return 0 } + guard prefix < 32 else { return 0xFFFFFFFF } + return ~((1 << (32 - prefix)) - 1) + } + + public static func prefixToSubnetMask(_ prefix: Int) -> String { + guard (0...32).contains(prefix) else { return "255.255.255.255" } + let mask = prefixToMaskRaw(prefix) + let b1 = (mask >> 24) & 0xFF + let b2 = (mask >> 16) & 0xFF + let b3 = (mask >> 8) & 0xFF + let b4 = mask & 0xFF + return "\(b1).\(b2).\(b3).\(b4)" + } +} + +public struct CIDRParseResult: Equatable, Sendable { + public let endpoint: CIDREndpoint + public let networkBaseIP: String + public let broadcastIP: String + public let isCanonicalBase: Bool + public let totalAddresses: UInt64 + public let category: IPCategory + public let warnings: [String] + + public var raw: String { endpoint.raw } + public var ip: String { endpoint.ip } + public var prefix: Int { endpoint.prefix } + public var subnetMask: String { endpoint.subnetMask } + public var formattedCIDR: String { endpoint.formattedCIDR } + + public var canonicalCIDR: String { + "\(networkBaseIP)/\(prefix)" + } + + public var rangeSummary: String { + if prefix == 32 { + return "\(ip) (Single Host /32)" + } + if prefix == 0 { + return "0.0.0.0 - 255.255.255.255 (Full-Tunnel / All IPv4)" + } + return "\(networkBaseIP) - \(broadcastIP) (\(totalAddresses) addresses)" + } +} + +public enum CIDRError: Error, LocalizedError, Equatable, Sendable { + case emptyInput + case missingCIDRPrefix(String) + case invalidFormat(String) + case invalidOctetCount(ip: String, count: Int) + case invalidOctetLeadingZero(octet: String, ip: String) + case invalidOctetRange(octet: String, ip: String) + case invalidOctetNonNumeric(octet: String, ip: String) + case missingPrefixNumber + case invalidPrefix(String) + case reservedUnspecifiedHost + case nonCanonicalBase(ip: String, canonicalBase: String, prefix: Int) + case identicalEndpoints(String) + + public var errorDescription: String? { + switch self { + case .emptyInput: + return "IP / CIDR address cannot be empty." + case .missingCIDRPrefix(let raw): + return "Missing CIDR prefix in '\(raw)'. Format must be 'IP/prefix' (e.g. 10.7.0.0/24)." + case .invalidFormat(let raw): + return "Invalid CIDR format: '\(raw)'. Expected 'IP/prefix' (e.g. 10.7.0.1/32 or 10.7.0.0/24)." + case .invalidOctetCount(let ip, let count): + return "IPv4 address '\(ip)' must contain exactly 4 octets (found \(count))." + case .invalidOctetLeadingZero(let octet, let ip): + return "Invalid octet '\(octet)' in '\(ip)': leading zeros are not allowed." + case .invalidOctetRange(let octet, let ip): + return "Octet '\(octet)' in '\(ip)' is out of range (must be 0-255)." + case .invalidOctetNonNumeric(let octet, let ip): + return "Octet '\(octet)' in '\(ip)' is not a valid number." + case .missingPrefixNumber: + return "Please specify a prefix length after '/' (e.g. /24 or /32)." + case .invalidPrefix(let prefix): + return "Invalid CIDR prefix '/\(prefix)'. Prefix length must be a number between 0 and 32." + case .reservedUnspecifiedHost: + return "0.0.0.0 cannot be used as a specific host endpoint. Use 0.0.0.0/0 for default full-tunnel routing." + case .nonCanonicalBase(let ip, let base, let prefix): + return "'\(ip)/\(prefix)' is an intermediate address, not the network base. The canonical network base is '\(base)/\(prefix)'." + case .identicalEndpoints(let ip): + return "Tunnel Interface IP and Peer IP cannot be the same address (\(ip))." + } + } +} + +public final class CIDRValidator: Sendable { + public static let shared = CIDRValidator() + + public init() {} + + public func parseCIDR( + _ input: String, + isRouteDestination: Bool = true, + requireExplicitCIDR: Bool = true, + defaultPrefix: Int = 32 + ) throws -> CIDRParseResult { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw CIDRError.emptyInput + } + + if requireExplicitCIDR && !trimmed.contains("/") { + throw CIDRError.missingCIDRPrefix(trimmed) + } + + let parts = trimmed.split(separator: "/", omittingEmptySubsequences: false) + guard parts.count == 1 || parts.count == 2 else { + throw CIDRError.invalidFormat(trimmed) + } + + let ipString = String(parts[0]).trimmingCharacters(in: .whitespaces) + try validateIPv4String(ipString) + + guard let ipRaw = ipToUInt32(ipString) else { + throw CIDRError.invalidFormat(ipString) + } + + let prefix: Int + if parts.count == 2 { + let prefixStr = String(parts[1]).trimmingCharacters(in: .whitespaces) + guard !prefixStr.isEmpty else { + throw CIDRError.missingPrefixNumber + } + guard let parsedPrefix = Int(prefixStr), (0...32).contains(parsedPrefix) else { + throw CIDRError.invalidPrefix(prefixStr) + } + prefix = parsedPrefix + } else { + guard (0...32).contains(defaultPrefix) else { + throw CIDRError.invalidPrefix(String(defaultPrefix)) + } + prefix = defaultPrefix + } + + if ipRaw == 0 && prefix > 0 { + throw CIDRError.reservedUnspecifiedHost + } + + let endpoint = CIDREndpoint(ip: ipString, prefix: prefix, raw: trimmed) + let category = categorizeIPv4(ipRaw, prefix: prefix) + let maskRaw = CIDREndpoint.prefixToMaskRaw(prefix) + + let baseRaw = ipRaw & maskRaw + let broadcastRaw = prefix == 32 ? ipRaw : (baseRaw | ~maskRaw) + + let networkBaseIP = uint32ToIP(baseRaw) + let broadcastIP = uint32ToIP(broadcastRaw) + let isCanonicalBase = (ipRaw == baseRaw) + + let totalAddresses: UInt64 = prefix == 0 ? (UInt64(1) << 32) : (UInt64(1) << (32 - prefix)) + + var warnings: [String] = [] + if isRouteDestination && !isCanonicalBase && prefix < 32 { + warnings.append("Warning: The routing table route begins at '\(networkBaseIP)/\(prefix)' covering '\(networkBaseIP)' to '\(broadcastIP)' (\(totalAddresses) addresses).") + } + if category == .defaultRoute { + warnings.append("Note: 0.0.0.0/0 is the default route. All IPv4 traffic will be routed through the tunnel (Full-Tunnel mode).") + } + if category == .multicast { + warnings.append("Note: This address is in the multicast range (224.0.0.0/4). Ensure your remote endpoint or server expects multicast traffic.") + } + if category == .broadcast { + warnings.append("Note: 255.255.255.255 is the limited broadcast address.") + } + if category == .loopback { + warnings.append("Note: This address is in the loopback range (127.0.0.0/8).") + } + + return CIDRParseResult( + endpoint: endpoint, + networkBaseIP: networkBaseIP, + broadcastIP: broadcastIP, + isCanonicalBase: isCanonicalBase, + totalAddresses: totalAddresses, + category: category, + warnings: warnings + ) + } + + public func validateCIDR( + _ input: String, + isRouteDestination: Bool = true, + allowIntermediateAddresses: Bool = false, + requireExplicitCIDR: Bool = true, + defaultPrefix: Int = 32 + ) throws -> CIDRParseResult { + let result = try parseCIDR( + input, + isRouteDestination: isRouteDestination, + requireExplicitCIDR: requireExplicitCIDR, + defaultPrefix: defaultPrefix + ) + + if isRouteDestination && !allowIntermediateAddresses && !result.isCanonicalBase && result.prefix < 32 { + throw CIDRError.nonCanonicalBase( + ip: result.ip, + canonicalBase: result.networkBaseIP, + prefix: result.prefix + ) + } + + return result + } + + public func validatePair( + tunnelIfaceInput: String, + tunnelPeerInput: String, + allowIntermediateAddresses: Bool = false, + requireExplicitCIDR: Bool = true + ) throws -> (iface: CIDRParseResult, peer: CIDRParseResult) { + let iface = try validateCIDR( + tunnelIfaceInput, + isRouteDestination: false, + allowIntermediateAddresses: true, + requireExplicitCIDR: requireExplicitCIDR, + defaultPrefix: 24 + ) + let peer = try validateCIDR( + tunnelPeerInput, + isRouteDestination: true, + allowIntermediateAddresses: allowIntermediateAddresses, + requireExplicitCIDR: requireExplicitCIDR, + defaultPrefix: 24 + ) + + if iface.ip == peer.ip { + throw CIDRError.identicalEndpoints(iface.ip) + } + + return (iface, peer) + } + + public func validateIPv4String(_ ip: String) throws { + let octets = ip.split(separator: ".", omittingEmptySubsequences: false) + guard octets.count == 4 else { + throw CIDRError.invalidOctetCount(ip: ip, count: octets.count) + } + + for octet in octets { + let str = String(octet) + guard !str.isEmpty else { + throw CIDRError.invalidOctetNonNumeric(octet: str, ip: ip) + } + guard let val = UInt32(str) else { + throw CIDRError.invalidOctetNonNumeric(octet: str, ip: ip) + } + if str.count > 1 && str.starts(with: "0") { + throw CIDRError.invalidOctetLeadingZero(octet: str, ip: ip) + } + if val > 255 { + throw CIDRError.invalidOctetRange(octet: str, ip: ip) + } + } + } + + public func isValidIPv4(_ ip: String) -> Bool { + (try? validateIPv4String(ip)) != nil + } + + public func ipToUInt32(_ ip: String) -> UInt32? { + let octets = ip.split(separator: ".") + guard octets.count == 4 else { return nil } + var result: UInt32 = 0 + for octet in octets { + guard let val = UInt32(octet), val <= 255 else { return nil } + result = (result << 8) | val + } + return result + } + + public func uint32ToIP(_ val: UInt32) -> String { + let b1 = (val >> 24) & 0xFF + let b2 = (val >> 16) & 0xFF + let b3 = (val >> 8) & 0xFF + let b4 = val & 0xFF + return "\(b1).\(b2).\(b3).\(b4)" + } + + public func categorizeIPv4(_ ipRaw: UInt32, prefix: Int = 32) -> IPCategory { + if ipRaw == 0 { + return prefix == 0 ? .defaultRoute : .unspecified + } + if ipRaw == 0xFFFFFFFF { + return .broadcast // 255.255.255.255 + } + let firstOctet = (ipRaw >> 24) & 0xFF + if firstOctet == 127 { + return .loopback // 127.0.0.0/8 + } + if firstOctet >= 224 && firstOctet <= 239 { + return .multicast // 224.0.0.0/4 + } + let firstTwoOctets = (ipRaw >> 16) & 0xFFFF + if firstTwoOctets == 0xA9FE { // 169.254.0.0/16 + return .linkLocal + } + return .unicast + } + + public func prefixToSubnetMask(_ prefix: Int) -> String { + CIDREndpoint.prefixToSubnetMask(prefix) + } + + public func subnetMaskToPrefix(_ subnetMask: String) -> Int? { + let octets = subnetMask.split(separator: ".").compactMap { UInt8($0) } + guard octets.count == 4 else { return nil } + var binaryString = "" + for octet in octets { + binaryString += String(octet, radix: 2).leftPadded(to: 8, with: "0") + } + guard let firstZero = binaryString.firstIndex(of: "0") else { + return 32 + } + let remaining = binaryString[firstZero...] + guard !remaining.contains("1") else { + return nil + } + return binaryString.distance(from: binaryString.startIndex, to: firstZero) + } +} + +private extension String { + func leftPadded(to length: Int, with character: Character) -> String { + let paddingCount = max(0, length - count) + return String(repeating: character, count: paddingCount) + self + } +} diff --git a/TunnelProv/Constants.swift b/TunnelProv/Constants.swift deleted file mode 100644 index 8a935eb..0000000 --- a/TunnelProv/Constants.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// Constants.swift -// TunnelProv -// -// Created by Magesh K on 11/07/26. -// Copyright © 2026 SideStore. All rights reserved. -// - -import Foundation - -struct TunnelConstants { - static let defaultDeviceIP = "10.7.0.0" - static let defaultFakeIP = "10.7.0.1" - static let defaultSubnetMask = "255.255.255.255" -} diff --git a/TunnelProv/PacketTunnelProvider.swift b/TunnelProv/PacketTunnelProvider.swift index f93527d..a7ac419 100644 --- a/TunnelProv/PacketTunnelProvider.swift +++ b/TunnelProv/PacketTunnelProvider.swift @@ -17,13 +17,10 @@ private func tunnelLog(_ message: @autoclosure () -> String) { #endif } + class PacketTunnelProvider: NEPacketTunnelProvider { - var tunnelDeviceIp: String = TunnelConstants.defaultDeviceIP - var tunnelFakeIp: String = TunnelConstants.defaultFakeIP - var tunnelSubnetMask: String = TunnelConstants.defaultSubnetMask - - private var deviceIpValue: UInt32 = 0 - private var fakeIpValue: UInt32 = 0 + var tunnelIfaceIP: String = TunnelConstants.defaultIfaceIP + var tunnelPeerIP: String = TunnelConstants.defaultPeerIP override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) { if let options = options { @@ -34,31 +31,35 @@ class PacketTunnelProvider: NEPacketTunnelProvider { tunnelLog("startTunnel: options is nil") } - if let deviceIp = options?["TunnelDeviceIP"] as? String { - tunnelLog("Option TunnelDeviceIP overridden to: \(deviceIp)") - tunnelDeviceIp = deviceIp - } - if let fakeIp = options?["TunnelFakeIP"] as? String { - tunnelLog("Option TunnelFakeIP overridden to: \(fakeIp)") - tunnelFakeIp = fakeIp + if let ifaceIp = options?["TunnelIfaceIP"] as? String { + tunnelLog("Option TunnelIfaceIP overridden to: \(ifaceIp)") + tunnelIfaceIP = ifaceIp } - if let subnetMask = options?["TunnelSubnetMask"] as? String { - tunnelLog("Option TunnelSubnetMask overridden to: \(subnetMask)") - tunnelSubnetMask = subnetMask + if let peerIp = options?["TunnelPeerIP"] as? String { + tunnelLog("Option TunnelPeerIP overridden to: \(peerIp)") + tunnelPeerIP = peerIp } - deviceIpValue = ipToUInt32(tunnelDeviceIp) - fakeIpValue = ipToUInt32(tunnelFakeIp) + let ifaceEndpoint = CIDREndpoint(tunnelIfaceIP, defaultPrefix: 24) + let peerEndpoint = CIDREndpoint(tunnelPeerIP, defaultPrefix: 32) - tunnelLog("Configuring P2P settings: remoteAddress=\(tunnelFakeIp), localAddress=\(tunnelDeviceIp), subnetMask=\(tunnelSubnetMask)") - let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: tunnelFakeIp) - let ipv4 = NEIPv4Settings(addresses: [tunnelDeviceIp], subnetMasks: [tunnelSubnetMask]) - ipv4.includedRoutes = [ - NEIPv4Route(destinationAddress: tunnelDeviceIp, subnetMask: tunnelSubnetMask), - NEIPv4Route(destinationAddress: tunnelFakeIp, subnetMask: tunnelSubnetMask) + tunnelLog("Configuring P2P settings: peer=\(peerEndpoint.ip)/\(peerEndpoint.prefix) (\(peerEndpoint.subnetMask)), iface=\(ifaceEndpoint.ip)/\(ifaceEndpoint.prefix) (\(ifaceEndpoint.subnetMask))") + + // tunnel iface configuration + let ifaceIPv4 = NEIPv4Settings(addresses: [ifaceEndpoint.ip], subnetMasks: [ifaceEndpoint.subnetMask]) + let tunnelDestinationIPv4Routes = [ + // actual destination routes of this VPN tunnel + NEIPv4Route(destinationAddress: peerEndpoint.ip, subnetMask: peerEndpoint.subnetMask) ] - ipv4.excludedRoutes = [.default()] - settings.ipv4Settings = ipv4 + ifaceIPv4.includedRoutes = tunnelDestinationIPv4Routes + ifaceIPv4.excludedRoutes = [.default()] + + // Tunneling config + let settings = NEPacketTunnelNetworkSettings( + // NOTE: 'tunnelRemoteAddress' is just for UI concerns and is not involved in routing + tunnelRemoteAddress: peerEndpoint.ip + ) + settings.ipv4Settings = ifaceIPv4 tunnelLog("Calling setTunnelNetworkSettings...") setTunnelNetworkSettings(settings) { error in @@ -74,22 +75,15 @@ class PacketTunnelProvider: NEPacketTunnelProvider { func setPackets() { packetFlow.readPackets { [self] packets, protocols in - let fakeip = self.fakeIpValue - let deviceip = self.deviceIpValue var modified = packets for i in modified.indices where protocols[i].int32Value == AF_INET && modified[i].count >= 20 { modified[i].withUnsafeMutableBytes { bytes in guard let ptr = bytes.baseAddress?.assumingMemoryBound(to: UInt32.self) else { return } - let src = UInt32(bigEndian: ptr[3]) - let dst = UInt32(bigEndian: ptr[4]) - - if src == deviceip { - ptr[3] = fakeip.bigEndian - } - if dst == fakeip { - ptr[4] = deviceip.bigEndian - } + let src = ptr[3] + let dst = ptr[4] + ptr[3] = dst + ptr[4] = src } } @@ -97,24 +91,4 @@ class PacketTunnelProvider: NEPacketTunnelProvider { setPackets() } } - - 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 (b1 << 24) | (b2 << 16) | (b3 << 8) | b4 - } - - private func ipToString(_ ip: UInt32) -> String { - let b1 = (ip >> 24) & 0xFF - let b2 = (ip >> 16) & 0xFF - let b3 = (ip >> 8) & 0xFF - let b4 = ip & 0xFF - return "\(b1).\(b2).\(b3).\(b4)" - } } From a2432a01f62aea51aa5caf768ff82177e4bd0735 Mon Sep 17 00:00:00 2001 From: mahee96 <47920326+mahee96@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:28:59 +0530 Subject: [PATCH 3/8] cleanup: moved all configurable items from pbproj into build.xcconfig and info.plist properly so one can reconfigure without touching pbxproj again for codesigning or info.plist updates. --- Build.xcconfig | 18 ++++- CodeSigning.xcconfig.sample | 11 ++- LocalDevVPN.xcodeproj/project.pbxproj | 96 ++++++++++++--------------- LocalDevVPN/Info.plist | 46 +++++++++++++ TunnelProv/Info.plist | 24 +++++++ 5 files changed, 137 insertions(+), 58 deletions(-) diff --git a/Build.xcconfig b/Build.xcconfig index 86b1886..5b95598 100644 --- a/Build.xcconfig +++ b/Build.xcconfig @@ -8,12 +8,26 @@ CURRENT_PROJECT_VERSION = 1 DEVELOPMENT_TEAM = 42Q7QX86GV ORG_IDENTIFIER = com.jkcoxson -// Codesigning settings defined optionally, see `CodeSigning.xcconfig.example` +CODE_SIGN_STYLE = Automatic +CODE_SIGN_IDENTITY = Apple Distribution +CODE_SIGN_IDENTITY_IOS = $(CODE_SIGN_IDENTITY) +CODE_SIGN_IDENTITY_TVOS = $(CODE_SIGN_IDENTITY) +CODE_SIGN_IDENTITY_MAC = $(CODE_SIGN_IDENTITY) + +PROVISIONING_PROFILE_SPECIFIER = + +// Codesigning settings defined optionally, see `CodeSigning.xcconfig.sample` #include? "CodeSigning.xcconfig" +CODE_SIGN_IDENTITY[sdk=iphoneos*] = $(CODE_SIGN_IDENTITY_IOS) +CODE_SIGN_IDENTITY[sdk=iphonesimulator*] = $(CODE_SIGN_IDENTITY_IOS) +CODE_SIGN_IDENTITY[sdk=appletvos*] = $(CODE_SIGN_IDENTITY_TVOS) +CODE_SIGN_IDENTITY[sdk=appletvsimulator*] = $(CODE_SIGN_IDENTITY_TVOS) +CODE_SIGN_IDENTITY[sdk=macosx*] = $(CODE_SIGN_IDENTITY_MAC) + ORG_PREFIX = $(ORG_IDENTIFIER) -PRODUCT_BUNDLE_IDENTIFIER = $(ORG_PREFIX).LocalDevVPN +PRODUCT_BUNDLE_IDENTIFIER = $(ORG_PREFIX).LocalDevVPN TUNNEL_NAME = TunnelProv diff --git a/CodeSigning.xcconfig.sample b/CodeSigning.xcconfig.sample index 45afdc6..c27f288 100644 --- a/CodeSigning.xcconfig.sample +++ b/CodeSigning.xcconfig.sample @@ -5,14 +5,21 @@ DEVELOPMENT_TEAM = 95J8WZ4TN8 // Prefix of unique bundle IDs registered to you in Apple Developer Portal. // You need to register: -// - com.myuniquename.StosVPN +// - com.myuniquename.StosVPN.TunnelProv ORG_IDENTIFIER = com.stossy11 +// Signing style: Manual or Automatic +CODE_SIGN_STYLE = Automatic + +// For Automatic signing, leave PROVISIONING_PROFILE_SPECIFIER empty. +// If using Manual signing, specify the name or UUID of the provisioning profile: +// PROVISIONING_PROFILE_SPECIFIER = + // Name of the iOS development signing certificate, you probably do not need // to change this. CODE_SIGN_IDENTITY_IOS = Apple Development -// Name of the iOS development signing certificate, you probably do not need +// Name of the tvOS development signing certificate, you probably do not need // to change this. CODE_SIGN_IDENTITY_TVOS = Apple Development diff --git a/LocalDevVPN.xcodeproj/project.pbxproj b/LocalDevVPN.xcodeproj/project.pbxproj index b4b17f3..28e627a 100644 --- a/LocalDevVPN.xcodeproj/project.pbxproj +++ b/LocalDevVPN.xcodeproj/project.pbxproj @@ -66,14 +66,20 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( CIDRValidator.swift, - Constants.swift, ); target = 4EB3C7572D96631A00C1B22C /* LocalDevVPN */; }; + A809C3653031D3E6005DF923 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Constants.swift, + ); + target = 4EB3C76D2D96715400C1B22C /* TunnelProv */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - 4EB3C75A2D96631A00C1B22C /* LocalDevVPN */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (190996C92ED0FC91006411F1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = LocalDevVPN; sourceTree = ""; }; + 4EB3C75A2D96631A00C1B22C /* LocalDevVPN */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (190996C92ED0FC91006411F1 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, A809C3653031D3E6005DF923 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = LocalDevVPN; sourceTree = ""; }; 4EB3C7722D96715400C1B22C /* TunnelProv */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (A809C3653031D3E6005DF922 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 4EB3C77A2D96715400C1B22C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = TunnelProv; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -308,7 +314,7 @@ CODE_SIGN_ENTITLEMENTS = "$(TARGET_NAME)/$(TARGET_NAME).entitlements"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = 95J8WZ4TN8; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -326,10 +332,8 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - GENERATE_INFOPLIST_FILE = YES; + GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "$(TARGET_NAME)/Info.plist"; - INFOPLIST_KEY_CFBundleDisplayName = "$(TARGET_NAME)"; - INFOPLIST_KEY_NSHumanReadableCopyright = Stossy11; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 11.0; @@ -385,7 +389,7 @@ CODE_SIGN_ENTITLEMENTS = "$(TARGET_NAME)/$(TARGET_NAME).entitlements"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = 95J8WZ4TN8; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -397,10 +401,8 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - GENERATE_INFOPLIST_FILE = YES; + GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "$(TARGET_NAME)/Info.plist"; - INFOPLIST_KEY_CFBundleDisplayName = "$(TARGET_NAME)"; - INFOPLIST_KEY_NSHumanReadableCopyright = Stossy11; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 11.0; @@ -420,32 +422,25 @@ }; 4EB3C7672D96631B00C1B22C /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 0E6351B62E18DF53002AF750 /* Build.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; - CODE_SIGN_IDENTITY = "Apple Distribution"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "$(CODE_SIGN_IDENTITY)"; + CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; DEVELOPMENT_ASSET_PATHS = "\"LocalDevVPN/Preview Content\""; - DEVELOPMENT_TEAM = 42Q7QX86GV; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = YES; - INFOPLIST_KEY_CFBundleDisplayName = LocalDevVPN; - INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; - INFOPLIST_KEY_NSHumanReadableCopyright = "Coxson Engineering"; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_FILE = LocalDevVPN/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.0; - PRODUCT_BUNDLE_IDENTIFIER = com.jkcoxson.LocalDevVPN; - PROVISIONING_PROFILE_SPECIFIER = ""; + MARKETING_VERSION = "$(MARKETING_VERSION)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; + PROVISIONING_PROFILE_SPECIFIER = "$(PROVISIONING_PROFILE_SPECIFIER)"; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_OBJC_BRIDGING_HEADER = "LocalDevVPN/LocalDevVPN-Bridging-Header.h"; @@ -458,32 +453,25 @@ }; 4EB3C7682D96631B00C1B22C /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 0E6351B62E18DF53002AF750 /* Build.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "$(CODE_SIGN_IDENTITY)"; + CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; DEVELOPMENT_ASSET_PATHS = "\"LocalDevVPN/Preview Content\""; - DEVELOPMENT_TEAM = 42Q7QX86GV; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_PREVIEWS = YES; - INFOPLIST_KEY_CFBundleDisplayName = LocalDevVPN; - INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; - INFOPLIST_KEY_NSHumanReadableCopyright = "Coxson Engineering"; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_FILE = LocalDevVPN/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.0; - PRODUCT_BUNDLE_IDENTIFIER = com.jkcoxson.LocalDevVPN; - PROVISIONING_PROFILE_SPECIFIER = ""; + MARKETING_VERSION = "$(MARKETING_VERSION)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; + PROVISIONING_PROFILE_SPECIFIER = "$(PROVISIONING_PROFILE_SPECIFIER)"; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_OBJC_BRIDGING_HEADER = "LocalDevVPN/LocalDevVPN-Bridging-Header.h"; @@ -495,20 +483,20 @@ }; 4EB3C77C2D96715400C1B22C /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 0E6351B62E18DF53002AF750 /* Build.xcconfig */; buildSettings = { - DEVELOPMENT_TEAM = 42Q7QX86GV; - INFOPLIST_KEY_CFBundleDisplayName = TunnelProv; - INFOPLIST_KEY_NSHumanReadableCopyright = Stossy11; - INFOPLIST_KEY_UIRequiredDeviceCapabilities = arm64; + CODE_SIGN_IDENTITY = "$(CODE_SIGN_IDENTITY)"; + CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; + INFOPLIST_FILE = TunnelProv/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.2.0; + MARKETING_VERSION = "$(MARKETING_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(TUNNEL_BUNDLE_IDENTIFIER)"; - "PRODUCT_BUNDLE_IDENTIFIER[sdk=appletvos*]" = com.jkcoxson.LocalDevVPN.TunnelProv; - "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = com.jkcoxson.LocalDevVPN.TunnelProv; + PROVISIONING_PROFILE_SPECIFIER = "$(PROVISIONING_PROFILE_SPECIFIER)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -521,20 +509,20 @@ }; 4EB3C77D2D96715400C1B22C /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 0E6351B62E18DF53002AF750 /* Build.xcconfig */; buildSettings = { - DEVELOPMENT_TEAM = 42Q7QX86GV; - INFOPLIST_KEY_CFBundleDisplayName = TunnelProv; - INFOPLIST_KEY_NSHumanReadableCopyright = Stossy11; - INFOPLIST_KEY_UIRequiredDeviceCapabilities = arm64; + CODE_SIGN_IDENTITY = "$(CODE_SIGN_IDENTITY)"; + CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; + DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; + INFOPLIST_FILE = TunnelProv/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.2.0; + MARKETING_VERSION = "$(MARKETING_VERSION)"; PRODUCT_BUNDLE_IDENTIFIER = "$(TUNNEL_BUNDLE_IDENTIFIER)"; - "PRODUCT_BUNDLE_IDENTIFIER[sdk=appletvos*]" = com.jkcoxson.LocalDevVPN.TunnelProv; - "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]" = com.jkcoxson.LocalDevVPN.TunnelProv; + PROVISIONING_PROFILE_SPECIFIER = "$(PROVISIONING_PROFILE_SPECIFIER)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; diff --git a/LocalDevVPN/Info.plist b/LocalDevVPN/Info.plist index dd24e8d..6e1452e 100644 --- a/LocalDevVPN/Info.plist +++ b/LocalDevVPN/Info.plist @@ -2,6 +2,22 @@ + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + LocalDevVPN + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) CFBundleURLTypes @@ -13,9 +29,39 @@ + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + LSApplicationCategoryType + public.app-category.developer-tools + LSRequiresIPhoneOS + NSBonjourServices _apple-mobdev2._tcp + NSHumanReadableCopyright + Coxson Engineering + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIApplicationSupportsIndirectInputEvents + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + diff --git a/TunnelProv/Info.plist b/TunnelProv/Info.plist index 3059459..0b0e543 100644 --- a/TunnelProv/Info.plist +++ b/TunnelProv/Info.plist @@ -2,6 +2,24 @@ + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + TunnelProv + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) NSExtension NSExtensionPointIdentifier @@ -9,5 +27,11 @@ NSExtensionPrincipalClass $(PRODUCT_MODULE_NAME).PacketTunnelProvider + NSHumanReadableCopyright + Stossy11 + UIRequiredDeviceCapabilities + + arm64 + From fb8fe8167e5f2c0d5e81be0e4d3fef6bb62e46c8 Mon Sep 17 00:00:00 2001 From: mahee96 <47920326+mahee96@users.noreply.github.com> Date: Mon, 17 Aug 2026 07:29:47 +0530 Subject: [PATCH 4/8] keep the xcode's suggestion on "askForAppToLaunch = YES" coz it is pestering and doesn't cause any harm anyways --- LocalDevVPN.xcodeproj/xcshareddata/xcschemes/TunnelProv.xcscheme | 1 + 1 file changed, 1 insertion(+) diff --git a/LocalDevVPN.xcodeproj/xcshareddata/xcschemes/TunnelProv.xcscheme b/LocalDevVPN.xcodeproj/xcshareddata/xcschemes/TunnelProv.xcscheme index 8d51541..7c566e4 100644 --- a/LocalDevVPN.xcodeproj/xcshareddata/xcschemes/TunnelProv.xcscheme +++ b/LocalDevVPN.xcodeproj/xcshareddata/xcschemes/TunnelProv.xcscheme @@ -74,6 +74,7 @@ savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES" + askForAppToLaunch = "Yes" launchAutomaticallySubstyle = "2"> From 90c76939813f4c92e189e1986d04274988c93ed2 Mon Sep 17 00:00:00 2001 From: mahee96 <47920326+mahee96@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:58:53 +0530 Subject: [PATCH 5/8] defaults: updated iface = 10.7.1.1/32 and kept peerIP = 10.7.0.1/32 (point2point) preserving backward compatibility. --- LocalDevVPN/Constants.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/LocalDevVPN/Constants.swift b/LocalDevVPN/Constants.swift index 535422d..cc17b33 100644 --- a/LocalDevVPN/Constants.swift +++ b/LocalDevVPN/Constants.swift @@ -9,7 +9,7 @@ import Foundation struct TunnelConstants { - static let defaultIfaceIP = "10.7.0.0/24" - static let defaultPeerIP = "11.7.0.0/24" - static let defaultAllowIntermediateAddresses = false + static let defaultIfaceIP = "10.7.1.1/32" + static let defaultPeerIP = "10.7.0.1/32" + static let defaultAllowIntermediateAddresses = true } From 3ebfc22ac67d7bca7e3a41684bd8a155ade16f27 Mon Sep 17 00:00:00 2001 From: se2crid <151872490+se2crid@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:08:21 +0200 Subject: [PATCH 6/8] fix: let automatic signing select development identity --- Build.xcconfig | 10 ---------- CodeSigning.xcconfig.sample | 26 ++------------------------ LocalDevVPN.xcodeproj/project.pbxproj | 4 ---- 3 files changed, 2 insertions(+), 38 deletions(-) diff --git a/Build.xcconfig b/Build.xcconfig index 5b95598..6bb6de2 100644 --- a/Build.xcconfig +++ b/Build.xcconfig @@ -9,22 +9,12 @@ DEVELOPMENT_TEAM = 42Q7QX86GV ORG_IDENTIFIER = com.jkcoxson CODE_SIGN_STYLE = Automatic -CODE_SIGN_IDENTITY = Apple Distribution -CODE_SIGN_IDENTITY_IOS = $(CODE_SIGN_IDENTITY) -CODE_SIGN_IDENTITY_TVOS = $(CODE_SIGN_IDENTITY) -CODE_SIGN_IDENTITY_MAC = $(CODE_SIGN_IDENTITY) PROVISIONING_PROFILE_SPECIFIER = // Codesigning settings defined optionally, see `CodeSigning.xcconfig.sample` #include? "CodeSigning.xcconfig" -CODE_SIGN_IDENTITY[sdk=iphoneos*] = $(CODE_SIGN_IDENTITY_IOS) -CODE_SIGN_IDENTITY[sdk=iphonesimulator*] = $(CODE_SIGN_IDENTITY_IOS) -CODE_SIGN_IDENTITY[sdk=appletvos*] = $(CODE_SIGN_IDENTITY_TVOS) -CODE_SIGN_IDENTITY[sdk=appletvsimulator*] = $(CODE_SIGN_IDENTITY_TVOS) -CODE_SIGN_IDENTITY[sdk=macosx*] = $(CODE_SIGN_IDENTITY_MAC) - ORG_PREFIX = $(ORG_IDENTIFIER) PRODUCT_BUNDLE_IDENTIFIER = $(ORG_PREFIX).LocalDevVPN diff --git a/CodeSigning.xcconfig.sample b/CodeSigning.xcconfig.sample index c27f288..43bd58c 100644 --- a/CodeSigning.xcconfig.sample +++ b/CodeSigning.xcconfig.sample @@ -15,27 +15,5 @@ CODE_SIGN_STYLE = Automatic // If using Manual signing, specify the name or UUID of the provisioning profile: // PROVISIONING_PROFILE_SPECIFIER = -// Name of the iOS development signing certificate, you probably do not need -// to change this. -CODE_SIGN_IDENTITY_IOS = Apple Development - -// Name of the tvOS development signing certificate, you probably do not need -// to change this. -CODE_SIGN_IDENTITY_TVOS = Apple Development - -// The values below are specific to macOS development. If you do not define -// these keys, the build will default to ad-hoc signing. You will need to -// follow `Documentation/MacDevelopment.md` to disable library verification and -// remove unsupported entitlements. - -// Name of the macOS development signing certificate. Comment out this line to -// use ad-hoc signing. -CODE_SIGN_IDENTITY_MAC = Apple Development - -// Create a Mac provisioning profile for com.myuniquename.UTM with the -// Hypervisor entitlements and get its UUID. If you do not have access to these -// entitlements, comment out the line and delete the following entitlements -// - com.apple.vm.device-access -// from the following file -// - Provenance/macOS.entitlements -PROVISIONING_PROFILE_SPECIFIER_MAC = 00000000-1111-2222-3333-444444444444 +// Automatic signing lets Xcode select the appropriate development certificate. +// Set CODE_SIGN_IDENTITY only when using manual signing. diff --git a/LocalDevVPN.xcodeproj/project.pbxproj b/LocalDevVPN.xcodeproj/project.pbxproj index 28e627a..2976c76 100644 --- a/LocalDevVPN.xcodeproj/project.pbxproj +++ b/LocalDevVPN.xcodeproj/project.pbxproj @@ -428,7 +428,6 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; - CODE_SIGN_IDENTITY = "$(CODE_SIGN_IDENTITY)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; DEVELOPMENT_ASSET_PATHS = "\"LocalDevVPN/Preview Content\""; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; @@ -459,7 +458,6 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; - CODE_SIGN_IDENTITY = "$(CODE_SIGN_IDENTITY)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; DEVELOPMENT_ASSET_PATHS = "\"LocalDevVPN/Preview Content\""; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; @@ -485,7 +483,6 @@ isa = XCBuildConfiguration; baseConfigurationReference = 0E6351B62E18DF53002AF750 /* Build.xcconfig */; buildSettings = { - CODE_SIGN_IDENTITY = "$(CODE_SIGN_IDENTITY)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; INFOPLIST_FILE = TunnelProv/Info.plist; @@ -511,7 +508,6 @@ isa = XCBuildConfiguration; baseConfigurationReference = 0E6351B62E18DF53002AF750 /* Build.xcconfig */; buildSettings = { - CODE_SIGN_IDENTITY = "$(CODE_SIGN_IDENTITY)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; INFOPLIST_FILE = TunnelProv/Info.plist; From de6555f538ae4e218b603bacdb4673fa835c0733 Mon Sep 17 00:00:00 2001 From: se2crid <151872490+se2crid@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:12:03 +0200 Subject: [PATCH 7/8] fix: persist tunnel addresses for system starts --- LocalDevVPN/Constants.swift | 3 +++ LocalDevVPN/ContentView.swift | 24 ++++++++++++++++++++++-- TunnelProv/PacketTunnelProvider.swift | 13 +++++++++---- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/LocalDevVPN/Constants.swift b/LocalDevVPN/Constants.swift index cc17b33..4cab584 100644 --- a/LocalDevVPN/Constants.swift +++ b/LocalDevVPN/Constants.swift @@ -9,6 +9,9 @@ import Foundation struct TunnelConstants { + static let ifaceIPConfigurationKey = "TunnelIfaceIP" + static let peerIPConfigurationKey = "TunnelPeerIP" + static let defaultIfaceIP = "10.7.1.1/32" static let defaultPeerIP = "10.7.0.1/32" static let defaultAllowIntermediateAddresses = true diff --git a/LocalDevVPN/ContentView.swift b/LocalDevVPN/ContentView.swift index 1c45642..a7eb460 100644 --- a/LocalDevVPN/ContentView.swift +++ b/LocalDevVPN/ContentView.swift @@ -67,6 +67,20 @@ class TunnelManager: ObservableObject { Bundle.main.bundleIdentifier!.appending(".TunnelProv") } + private func persistTunnelAddresses(in manager: NETunnelProviderManager) -> Bool { + guard let proto = manager.protocolConfiguration as? NETunnelProviderProtocol else { + VPNLogger.shared.log("Cannot persist tunnel addresses: invalid protocol configuration") + return false + } + + var providerConfiguration = proto.providerConfiguration ?? [:] + providerConfiguration[TunnelConstants.ifaceIPConfigurationKey] = tunnelIfaceIP + providerConfiguration[TunnelConstants.peerIPConfigurationKey] = tunnelPeerIP + proto.providerConfiguration = providerConfiguration + manager.protocolConfiguration = proto + return true + } + enum TunnelStatus { case disconnected case connecting @@ -272,6 +286,10 @@ class TunnelManager: ObservableObject { let proto = NETunnelProviderProtocol() proto.providerBundleIdentifier = self.tunnelBundleId proto.serverAddress = "LocalDevVPN's Local Network Tunnel" + proto.providerConfiguration = [ + TunnelConstants.ifaceIPConfigurationKey: self.tunnelIfaceIP, + TunnelConstants.peerIPConfigurationKey: self.tunnelPeerIP, + ] manager.protocolConfiguration = proto let onDemandRule = NEOnDemandRuleEvaluateConnection() @@ -496,8 +514,8 @@ class TunnelManager: ObservableObject { } let options: [String: NSObject] = [ - "TunnelIfaceIP": self.tunnelIfaceIP as NSObject, - "TunnelPeerIP": self.tunnelPeerIP as NSObject, + TunnelConstants.ifaceIPConfigurationKey: self.tunnelIfaceIP as NSObject, + TunnelConstants.peerIPConfigurationKey: self.tunnelPeerIP as NSObject, ] do { @@ -545,6 +563,8 @@ class TunnelManager: ObservableObject { VPNLogger.shared.log("Error loading preferences for update: \(error.localizedDescription)") return } + + guard self.persistTunnelAddresses(in: manager) else { return } let onDemandRule = NEOnDemandRuleEvaluateConnection() onDemandRule.interfaceTypeMatch = .any diff --git a/TunnelProv/PacketTunnelProvider.swift b/TunnelProv/PacketTunnelProvider.swift index a7ac419..8a36937 100644 --- a/TunnelProv/PacketTunnelProvider.swift +++ b/TunnelProv/PacketTunnelProvider.swift @@ -31,12 +31,17 @@ class PacketTunnelProvider: NEPacketTunnelProvider { tunnelLog("startTunnel: options is nil") } - if let ifaceIp = options?["TunnelIfaceIP"] as? String { - tunnelLog("Option TunnelIfaceIP overridden to: \(ifaceIp)") + let providerConfiguration = + (protocolConfiguration as? NETunnelProviderProtocol)?.providerConfiguration + + if let ifaceIp = options?[TunnelConstants.ifaceIPConfigurationKey] as? String + ?? providerConfiguration?[TunnelConstants.ifaceIPConfigurationKey] as? String { + tunnelLog("TunnelIfaceIP configured as: \(ifaceIp)") tunnelIfaceIP = ifaceIp } - if let peerIp = options?["TunnelPeerIP"] as? String { - tunnelLog("Option TunnelPeerIP overridden to: \(peerIp)") + if let peerIp = options?[TunnelConstants.peerIPConfigurationKey] as? String + ?? providerConfiguration?[TunnelConstants.peerIPConfigurationKey] as? String { + tunnelLog("TunnelPeerIP configured as: \(peerIp)") tunnelPeerIP = peerIp } From 550c4f792f82d2e0679c8ac1c2352f8c3ee7509d Mon Sep 17 00:00:00 2001 From: se2crid <151872490+se2crid@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:34:06 +0200 Subject: [PATCH 8/8] feat: use default tunnel addresses for empty fields --- LocalDevVPN/ContentView.swift | 46 +++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/LocalDevVPN/ContentView.swift b/LocalDevVPN/ContentView.swift index a7eb460..74f5204 100644 --- a/LocalDevVPN/ContentView.swift +++ b/LocalDevVPN/ContentView.swift @@ -1175,6 +1175,14 @@ struct SettingsView: View { ifaceError == nil && peerError == nil && pairError == nil } + private var effectiveIfaceIP: String { + resolvedInput(draftIfaceIP, defaultValue: TunnelConstants.defaultIfaceIP) + } + + private var effectivePeerIP: String { + resolvedInput(draftPeerIP, defaultValue: TunnelConstants.defaultPeerIP) + } + var body: some View { NBNavigationStack { List { @@ -1190,7 +1198,11 @@ struct SettingsView: View { footer: Text("allow_intermediate_addresses_desc") ) { VStack(alignment: .leading, spacing: 6) { - networkConfigRow(label: "tunnel_ip", text: $draftIfaceIP) + networkConfigRow( + label: "tunnel_ip", + text: $draftIfaceIP, + defaultValue: TunnelConstants.defaultIfaceIP + ) if let error = ifaceError { Text(error) .font(.caption2) @@ -1204,7 +1216,11 @@ struct SettingsView: View { } VStack(alignment: .leading, spacing: 6) { - networkConfigRow(label: "device_ip", text: $draftPeerIP) + networkConfigRow( + label: "device_ip", + text: $draftPeerIP, + defaultValue: TunnelConstants.defaultPeerIP + ) if let error = peerError { Text(error) .font(.caption2) @@ -1351,8 +1367,8 @@ struct SettingsView: View { } private func saveAndApply() { - savedIfaceIP = draftIfaceIP - savedPeerIP = draftPeerIP + savedIfaceIP = effectiveIfaceIP + savedPeerIP = effectivePeerIP savedAllowIntermediate = draftAllowIntermediate let isRunning = tunnelManager.tunnelStatus == .connected || tunnelManager.tunnelStatus == .connecting @@ -1369,7 +1385,7 @@ struct SettingsView: View { do { let res = try CIDRValidator.shared.validateCIDR( - draftIfaceIP, + effectiveIfaceIP, isRouteDestination: false, allowIntermediateAddresses: true, defaultPrefix: 24 @@ -1381,7 +1397,7 @@ struct SettingsView: View { do { let res = try CIDRValidator.shared.validateCIDR( - draftPeerIP, + effectivePeerIP, isRouteDestination: true, allowIntermediateAddresses: draftAllowIntermediate, defaultPrefix: 24 @@ -1394,8 +1410,8 @@ struct SettingsView: View { if ifaceError == nil && peerError == nil { do { _ = try CIDRValidator.shared.validatePair( - tunnelIfaceInput: draftIfaceIP, - tunnelPeerInput: draftPeerIP, + tunnelIfaceInput: effectiveIfaceIP, + tunnelPeerInput: effectivePeerIP, allowIntermediateAddresses: draftAllowIntermediate ) } catch { @@ -1404,14 +1420,24 @@ struct SettingsView: View { } } - private func networkConfigRow(label: LocalizedStringKey, text: Binding) -> some View { + private func resolvedInput(_ input: String, defaultValue: String) -> String { + let trimmedInput = input.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedInput.isEmpty ? defaultValue : trimmedInput + } + + private func networkConfigRow( + label: LocalizedStringKey, + text: Binding, + defaultValue: String + ) -> some View { HStack { Text(label) Spacer() - TextField(label, text: text) + TextField(defaultValue, text: text) .multilineTextAlignment(.trailing) .foregroundColor(.secondary) .keyboardType(.numbersAndPunctuation) + .accessibilityLabel(Text(label)) .onChange(of: text.wrappedValue) { _ in validateAll() if !shownTunnelAlert {