diff --git a/Build.xcconfig b/Build.xcconfig index 86b1886..6bb6de2 100644 --- a/Build.xcconfig +++ b/Build.xcconfig @@ -8,12 +8,16 @@ CURRENT_PROJECT_VERSION = 1 DEVELOPMENT_TEAM = 42Q7QX86GV ORG_IDENTIFIER = com.jkcoxson -// Codesigning settings defined optionally, see `CodeSigning.xcconfig.example` +CODE_SIGN_STYLE = Automatic + +PROVISIONING_PROFILE_SPECIFIER = + +// Codesigning settings defined optionally, see `CodeSigning.xcconfig.sample` #include? "CodeSigning.xcconfig" 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..43bd58c 100644 --- a/CodeSigning.xcconfig.sample +++ b/CodeSigning.xcconfig.sample @@ -5,30 +5,15 @@ 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 -// Name of the iOS development signing certificate, you probably do not need -// to change this. -CODE_SIGN_IDENTITY_IOS = Apple Development +// Signing style: Manual or Automatic +CODE_SIGN_STYLE = Automatic -// Name of the iOS development signing certificate, you probably do not need -// to change this. -CODE_SIGN_IDENTITY_TVOS = Apple Development +// For Automatic signing, leave PROVISIONING_PROFILE_SPECIFIER empty. +// If using Manual signing, specify the name or UUID of the provisioning profile: +// PROVISIONING_PROFILE_SPECIFIER = -// 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 d8b7294..2976c76 100644 --- a/LocalDevVPN.xcodeproj/project.pbxproj +++ b/LocalDevVPN.xcodeproj/project.pbxproj @@ -62,11 +62,25 @@ ); target = 4EB3C76D2D96715400C1B22C /* TunnelProv */; }; + A809C3653031D3E6005DF922 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + CIDRValidator.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 = ""; }; - 4EB3C7722D96715400C1B22C /* TunnelProv */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (4EB3C77A2D96715400C1B22C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = TunnelProv; 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 */ /* Begin PBXFrameworksBuildPhase section */ @@ -300,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; @@ -318,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; @@ -377,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; @@ -389,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; @@ -412,32 +422,24 @@ }; 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_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"; @@ -450,32 +452,24 @@ }; 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_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"; @@ -487,20 +481,19 @@ }; 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_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; @@ -513,20 +506,19 @@ }; 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_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.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"> diff --git a/LocalDevVPN/Constants.swift b/LocalDevVPN/Constants.swift new file mode 100644 index 0000000..4cab584 --- /dev/null +++ b/LocalDevVPN/Constants.swift @@ -0,0 +1,18 @@ +// +// Constants.swift +// LocalDevVPN +// +// Created by Magesh K on 11/07/26. +// Copyright © 2026 LocalDevVPN. All rights reserved. +// + +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 e8dd72a..74f5204 100644 --- a/LocalDevVPN/ContentView.swift +++ b/LocalDevVPN/ContentView.swift @@ -55,22 +55,32 @@ class TunnelManager: ObservableObject { #endif }() - private var tunnelDeviceIp: String { - UserDefaults.standard.string(forKey: "TunnelDeviceIP") ?? "10.7.0.0" + private var tunnelIfaceIP: String { + UserDefaults.standard.string(forKey: "TunnelIfaceIP") ?? TunnelConstants.defaultIfaceIP } - private var tunnelFakeIp: String { - UserDefaults.standard.string(forKey: "TunnelFakeIP") ?? "10.7.0.1" - } - - private var tunnelSubnetMask: String { - UserDefaults.standard.string(forKey: "TunnelSubnetMask") ?? "255.255.255.0" + private var tunnelPeerIP: String { + UserDefaults.standard.string(forKey: "TunnelPeerIP") ?? TunnelConstants.defaultPeerIP } private var tunnelBundleId: String { 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 @@ -276,12 +286,16 @@ 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() onDemandRule.interfaceTypeMatch = .any onDemandRule.connectionRules = [NEEvaluateConnectionRule( - matchDomains: ["10.7.0.0", "10.7.0.1"], + matchDomains: [self.tunnelIfaceIP, self.tunnelPeerIP], andAction: .connectIfNeeded )] @@ -500,9 +514,8 @@ class TunnelManager: ObservableObject { } let options: [String: NSObject] = [ - "TunnelDeviceIP": self.tunnelDeviceIp as NSObject, - "TunnelFakeIP": self.tunnelFakeIp as NSObject, - "TunnelSubnetMask": self.tunnelSubnetMask as NSObject, + TunnelConstants.ifaceIPConfigurationKey: self.tunnelIfaceIP as NSObject, + TunnelConstants.peerIPConfigurationKey: self.tunnelPeerIP as NSObject, ] do { @@ -539,6 +552,48 @@ 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 + } + + guard self.persistTunnelAddresses(in: manager) else { return } + + let onDemandRule = NEOnDemandRuleEvaluateConnection() + onDemandRule.interfaceTypeMatch = .any + onDemandRule.connectionRules = [NEEvaluateConnectionRule( + matchDomains: [self.tunnelIfaceIP, self.tunnelPeerIP], + andAction: .connectIfNeeded + )] + manager.onDemandRules = [onDemandRule] + manager.isOnDemandEnabled = true + manager.isEnabled = true + + manager.saveToPreferences { [weak self] error in + guard self != nil 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 +841,7 @@ extension View { struct StatusOverviewCard: View { @StateObject private var tunnelManager = TunnelManager.shared - @AppStorage("TunnelDeviceIP") private var deviceIP = "10.7.0.0" + @AppStorage("TunnelIfaceIP") private var tunnelIfaceIP = TunnelConstants.defaultIfaceIP var body: some View { DashboardCard { @@ -820,7 +875,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: @@ -972,9 +1027,8 @@ 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("TunnelIfaceIP") private var tunnelIfaceIP = TunnelConstants.defaultIfaceIP + @AppStorage("TunnelPeerIP") private var tunnelPeerIP = TunnelConstants.defaultPeerIP var body: some View { DashboardCard { @@ -993,27 +1047,20 @@ struct ConnectionStatsView: View { .font(.caption) .foregroundColor(.secondary) - ConnectionInfoRow( - title: "local_device_ip", - value: deviceIP, - icon: "desktopcomputer" - ) - ConnectionInfoRow( title: "tunnel_ip", - value: fakeIP, + value: tunnelIfaceIP, icon: "point.3.filled.connected.trianglepath.dotted" ) ConnectionInfoRow( - title: "subnet_mask", - value: subnetMask, - icon: "network" + title: "device_ip", + value: tunnelPeerIP, + icon: "desktopcomputer" ) } } } - } struct StatItemView: View { @@ -1082,19 +1129,59 @@ 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 = "10.7.0.0" - @AppStorage("TunnelFakeIP") private var fakeIP = "10.7.0.1" - @AppStorage("TunnelSubnetMask") private var subnetMask = "255.255.255.0" + @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 + // 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 + } + + private var effectiveIfaceIP: String { + resolvedInput(draftIfaceIP, defaultValue: TunnelConstants.defaultIfaceIP) + } + + private var effectivePeerIP: String { + resolvedInput(draftPeerIP, defaultValue: TunnelConstants.defaultPeerIP) + } var body: some View { NBNavigationStack { @@ -1106,12 +1193,56 @@ 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, + defaultValue: TunnelConstants.defaultIfaceIP + ) + 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, + defaultValue: TunnelConstants.defaultPeerIP + ) + 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")) { @@ -1147,41 +1278,87 @@ 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 = "10.7.0.0" - fakeIP = "10.7.0.1" - subnetMask = "255.255.255.0" - } - ) + .onAppear { + draftIfaceIP = savedIfaceIP + draftPeerIP = savedPeerIP + draftAllowIntermediate = savedAllowIntermediate + validateAll() } .navigationTitle(Text("settings")) .tvOSNavigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("done") { - dismiss() + if isDirty { + if isValid { + activeAlert = .savePrompt + } else { + activeAlert = .fixErrors + } + } else { + dismiss() + } + } + } + ToolbarItem(placement: .topBarLeading) { + Button("cancel") { + if isDirty { + activeAlert = .discardPrompt + } else { + dismiss() + } } } } + .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")) + ) + } + } } } @@ -1189,23 +1366,82 @@ struct SettingsView: View { presentationMode.wrappedValue.dismiss() } - private func networkConfigRow(label: LocalizedStringKey, text: Binding) -> some View { + private func saveAndApply() { + savedIfaceIP = effectiveIfaceIP + savedPeerIP = effectivePeerIP + 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( + effectiveIfaceIP, + isRouteDestination: false, + allowIntermediateAddresses: true, + defaultPrefix: 24 + ) + ifaceWarnings = res.warnings + } catch { + ifaceError = error.localizedDescription + } + + do { + let res = try CIDRValidator.shared.validateCIDR( + effectivePeerIP, + isRouteDestination: true, + allowIntermediateAddresses: draftAllowIntermediate, + defaultPrefix: 24 + ) + peerWarnings = res.warnings + } catch { + peerError = error.localizedDescription + } + + if ifaceError == nil && peerError == nil { + do { + _ = try CIDRValidator.shared.validatePair( + tunnelIfaceInput: effectiveIfaceIP, + tunnelPeerInput: effectivePeerIP, + allowIntermediateAddresses: draftAllowIntermediate + ) + } catch { + pairError = error.localizedDescription + } + } + } + + 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 { - showNetworkWarning = true - } - - tunnelManager.vpnManager?.saveToPreferences { error in - if let error = error { - VPNLogger.shared.log(error.localizedDescription) - } + activeAlert = .networkWarning } } } 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/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/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 + diff --git a/TunnelProv/PacketTunnelProvider.swift b/TunnelProv/PacketTunnelProvider.swift index 8d7c352..8a36937 100644 --- a/TunnelProv/PacketTunnelProvider.swift +++ b/TunnelProv/PacketTunnelProvider.swift @@ -6,34 +6,73 @@ // 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" - - 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 deviceIp = options?["TunnelDeviceIP"] as? String { - tunnelDeviceIp = deviceIp + if let options = options { + for (key, val) in options { + tunnelLog("startTunnel option \(key) = \(String(describing: val))") + } + } else { + tunnelLog("startTunnel: options is nil") + } + + 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 fakeIp = options?["TunnelFakeIP"] as? String { - tunnelFakeIp = fakeIp + if let peerIp = options?[TunnelConstants.peerIPConfigurationKey] as? String + ?? providerConfiguration?[TunnelConstants.peerIPConfigurationKey] as? String { + tunnelLog("TunnelPeerIP configured as: \(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: peer=\(peerEndpoint.ip)/\(peerEndpoint.prefix) (\(peerEndpoint.subnetMask)), iface=\(ifaceEndpoint.ip)/\(ifaceEndpoint.prefix) (\(ifaceEndpoint.subnetMask))") - 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 + // 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) + ] + 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 - 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) } @@ -41,32 +80,20 @@ 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 } } + self.packetFlow.writePackets(modified, withProtocols: protocols) 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 - } }