diff --git a/NetBird/Source/App/ViewModels/MainViewModel.swift b/NetBird/Source/App/ViewModels/MainViewModel.swift index 0ddf9ce..f7da8fc 100644 --- a/NetBird/Source/App/ViewModels/MainViewModel.swift +++ b/NetBird/Source/App/ViewModels/MainViewModel.swift @@ -115,6 +115,7 @@ class ViewModel: ObservableObject { } @Published var forceRelayConnection = true @Published var showForceRelayAlert = false + @Published var disableIPv6 = false @Published var connectOnDemand = false @Published var showOnDemandAlert = false @Published var showOnDemandConflictAlert = false @@ -691,6 +692,21 @@ class ViewModel: ObservableObject { previousExtensionState = extensionState } + func setDisableIPv6(disabled: Bool) { + let previous = self.disableIPv6 + self.disableIPv6 = disabled + configProvider.disableIPv6 = disabled + if !configProvider.commit() { + print("Failed to update IPv6 settings") + self.disableIPv6 = previous + configProvider.disableIPv6 = previous + } + } + + func loadIPv6Settings() { + self.disableIPv6 = configProvider.disableIPv6 + } + func setForcedRelayConnection(isEnabled: Bool) { let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) userDefaults?.set(isEnabled, forKey: GlobalConstants.keyForceRelayConnection) diff --git a/NetBird/Source/App/Views/AdvancedView.swift b/NetBird/Source/App/Views/AdvancedView.swift index 9c336be..bad8f69 100644 --- a/NetBird/Source/App/Views/AdvancedView.swift +++ b/NetBird/Source/App/Views/AdvancedView.swift @@ -82,11 +82,17 @@ struct AdvancedView: View { viewModel.setForcedRelayConnection(isEnabled: value) } + Toggle("Disable IPv6", isOn: $viewModel.disableIPv6) + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + .onChange(of: viewModel.disableIPv6) { value in + viewModel.setDisableIPv6(disabled: value) + } } } .onAppear { viewModel.loadRosenpassSettings() viewModel.loadPreSharedKey() + viewModel.loadIPv6Settings() } .navigationTitle("Advanced") .navigationBarTitleDisplayMode(.inline) diff --git a/NetBird/Source/App/Views/Components/PeerCard.swift b/NetBird/Source/App/Views/Components/PeerCard.swift index 4216096..e819393 100644 --- a/NetBird/Source/App/Views/Components/PeerCard.swift +++ b/NetBird/Source/App/Views/Components/PeerCard.swift @@ -21,6 +21,12 @@ struct PeerCard: View { .font(.subheadline) .foregroundColor(Color("TextSecondary")) .lineLimit(1) + if let ipv6 = peer.ipv6, !ipv6.isEmpty { + Text(ipv6) + .font(.subheadline) + .foregroundColor(Color("TextSecondary")) + .lineLimit(1) + } } Spacer() ConnectionIndicator(status: peer.connStatus) diff --git a/NetBird/Source/App/Views/Components/PeerDetailSheet.swift b/NetBird/Source/App/Views/Components/PeerDetailSheet.swift index 5c1d549..3126cb9 100644 --- a/NetBird/Source/App/Views/Components/PeerDetailSheet.swift +++ b/NetBird/Source/App/Views/Components/PeerDetailSheet.swift @@ -29,6 +29,10 @@ struct PeerDetailSheet: View { NavigationView { List { Section { + detailRow("IPv4", peer.ip) + if let ipv6 = peer.ipv6, !ipv6.isEmpty { + detailRow("IPv6", ipv6) + } detailRow("Status", peer.connStatus) detailRow("Last status update", relativeDateText) detailRow("Connection type", peer.relayed ? "Relayed" : "P2P") diff --git a/NetBird/Source/App/Views/TV/TVSettingsView.swift b/NetBird/Source/App/Views/TV/TVSettingsView.swift index 06b6db3..8630625 100644 --- a/NetBird/Source/App/Views/TV/TVSettingsView.swift +++ b/NetBird/Source/App/Views/TV/TVSettingsView.swift @@ -79,6 +79,32 @@ struct TVSettingsView: View { ) } + TVSettingsSection(title: "Network") { + TVSettingsToggleRow( + icon: "network", + title: "Disable IPv6", + subtitle: "Disable IPv6 overlay addressing on the tunnel", + isOn: Binding( + get: { viewModel.disableIPv6 }, + set: { newValue in + viewModel.setDisableIPv6(disabled: newValue) + } + ) + ) + + TVSettingsToggleRow( + icon: "arrow.triangle.branch", + title: "Force Relay", + subtitle: "Force all connections through relay servers", + isOn: Binding( + get: { viewModel.forceRelayConnection }, + set: { newValue in + viewModel.setForcedRelayConnection(isEnabled: newValue) + } + ) + ) + } + TVSettingsSection(title: "Security") { TVSettingsRow( icon: "key.fill", @@ -125,6 +151,7 @@ struct TVSettingsView: View { // Load settings from storage to sync UI with actual values viewModel.loadRosenpassSettings() viewModel.loadPreSharedKey() + viewModel.loadIPv6Settings() } .sheet(isPresented: $showDocsQRCode) { TVQRCodeSheet( diff --git a/NetBirdTVNetworkExtension/PacketTunnelProvider.swift b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift index 2844fea..3b9fc13 100644 --- a/NetBirdTVNetworkExtension/PacketTunnelProvider.swift +++ b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift @@ -556,6 +556,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let peerInfo = PeerInfo( ip: peer.ip, + ipv6: peer.iPv6, fqdn: peer.fqdn, localIceCandidateEndpoint: peer.localIceCandidateEndpoint, remoteIceCandidateEndpoint: peer.remoteIceCandidateEndpoint, @@ -579,6 +580,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let clientState = adapter.clientState let statusDetails = StatusDetails( ip: statusDetailsMessage.getIP(), + ipv6: statusDetailsMessage.getIPv6(), fqdn: statusDetailsMessage.getFQDN(), managementStatus: clientState, peerInfo: peerInfoArray @@ -608,7 +610,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { do { let routeSelectionDetailsMessage = try adapter.client.getRoutesSelectionDetails() - let routeSelectionInfo: [RoutesSelectionInfo] = (0.. RoutesSelectionInfo? in guard let route = routeSelectionDetailsMessage.get(index) else { return nil } let domains = (0..<(route.domains?.size() ?? 0)).compactMap { domainIndex -> DomainDetails? in diff --git a/NetbirdKit/ConfigurationProvider.swift b/NetbirdKit/ConfigurationProvider.swift index 7bc2977..17bb7a1 100644 --- a/NetbirdKit/ConfigurationProvider.swift +++ b/NetbirdKit/ConfigurationProvider.swift @@ -23,6 +23,11 @@ protocol ConfigurationProvider { /// Whether Rosenpass permissive mode is enabled (allows non-Rosenpass peers) var rosenpassPermissive: Bool { get set } + // MARK: - IPv6 + + /// Whether IPv6 overlay addressing is disabled + var disableIPv6: Bool { get set } + // MARK: - Pre-Shared Key /// The current pre-shared key (empty string if not set) @@ -86,6 +91,23 @@ final class iOSConfigurationProvider: ConfigurationProvider { } } + // MARK: - IPv6 + + var disableIPv6: Bool { + get { + var result = ObjCBool(false) + do { + try preferences.getDisableIPv6(&result) + } catch { + print("ConfigurationProvider: Failed to read disableIPv6 - \(error)") + } + return result.boolValue + } + set { + preferences.setDisableIPv6(newValue) + } + } + // MARK: - Pre-Shared Key var preSharedKey: String { @@ -150,6 +172,13 @@ final class tvOSConfigurationProvider: ConfigurationProvider { set { updateJSONField(field: "RosenpassPermissive", value: newValue) } } + // MARK: - IPv6 + + var disableIPv6: Bool { + get { extractJSONBool(field: "DisableIPv6") ?? false } + set { updateJSONField(field: "DisableIPv6", value: newValue) } + } + // MARK: - Pre-Shared Key var preSharedKey: String { @@ -206,11 +235,6 @@ final class tvOSConfigurationProvider: ConfigurationProvider { return } - guard dict[field] != nil else { - AppLogger.shared.log("ConfigurationProvider: Field '\(field)' not found in config JSON") - return - } - dict[field] = value guard let data = try? JSONSerialization.data(withJSONObject: dict, options: [.sortedKeys]), diff --git a/NetbirdKit/NetworkChangeListener.swift b/NetbirdKit/NetworkChangeListener.swift index b991c1b..9cc8dc6 100644 --- a/NetbirdKit/NetworkChangeListener.swift +++ b/NetbirdKit/NetworkChangeListener.swift @@ -30,19 +30,28 @@ class NetworkChangeListener: NSObject, NetBirdSDKNetworkChangeListenerProtocol { private var tunnelManager: PacketTunnelProviderSettingsManager var interfaceIP: String? - + var interfaceIPv6: String? + init(with tunnelManager: PacketTunnelProviderSettingsManager) { self.tunnelManager = tunnelManager } - + func setInterfaceIP(_ p0: String?) { guard let validIP = p0, !validIP.isEmpty else { return } - + self.interfaceIP = validIP self.tunnelManager.setInterfaceIP(interfaceIP: validIP) } + + func setInterfaceIPv6(_ p0: String?) { + guard let validIPv6 = p0, !validIPv6.isEmpty else { + return + } + self.interfaceIPv6 = validIPv6 + self.tunnelManager.setInterfaceIPv6(interfaceIPv6: validIPv6) + } func parseRoutesToNESettings(routesString: String) -> ([NEIPv4Route], [NEIPv6Route], Bool) { var v4Routes : [NEIPv4Route] = [] @@ -75,6 +84,9 @@ class NetworkChangeListener: NSObject, NetBirdSDKNetworkChangeListenerProtocol { if let interfaceIP = self.interfaceIP, let interfaceRoute = createIPv4RouteFromCIDR(cidr: interfaceIP) { v4Routes.append(interfaceRoute) } + if let interfaceIPv6 = self.interfaceIPv6, let interfaceRoute = createIPv6RouteFromCIDR(cidr: interfaceIPv6) { + v6Routes.append(interfaceRoute) + } return (v4Routes, v6Routes, containsDefault) } @@ -102,22 +114,17 @@ class NetworkChangeListener: NSObject, NetBirdSDKNetworkChangeListenerProtocol { } func detectIPAddressType(_ address: String) -> IPAddressType { - let ipv4Pattern = "^(\\d{1,3}\\.){3}\\d{1,3}(\\/\\d{1,2})?$" - let ipv6Pattern = "^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}(\\/\\d{1,3})?$" + let bare = address.split(separator: "/").first.map(String.init) ?? address - let ipv4Regex = try! NSRegularExpression(pattern: ipv4Pattern, options: []) - let ipv6Regex = try! NSRegularExpression(pattern: ipv6Pattern, options: []) - - let ipv4Matches = ipv4Regex.numberOfMatches(in: address, options: [], range: NSRange(location: 0, length: address.utf16.count)) - let ipv6Matches = ipv6Regex.numberOfMatches(in: address, options: [], range: NSRange(location: 0, length: address.utf16.count)) - - if ipv4Matches > 0 { + var v4 = in_addr() + if bare.withCString({ inet_pton(AF_INET, $0, &v4) }) == 1 { return .ipv4 - } else if ipv6Matches > 0 { + } + var v6 = in6_addr() + if bare.withCString({ inet_pton(AF_INET6, $0, &v6) }) == 1 { return .ipv6 - } else { - return .invalid } + return .invalid } func extractIPAddressAndSubnet(from cidr: String) -> (String, String)? { diff --git a/NetbirdKit/StatusDetails.swift b/NetbirdKit/StatusDetails.swift index 126bcfb..55b8f51 100644 --- a/NetbirdKit/StatusDetails.swift +++ b/NetbirdKit/StatusDetails.swift @@ -10,6 +10,7 @@ import Combine struct StatusDetails: Codable { var ip: String + var ipv6: String? var fqdn: String var managementStatus: ClientState var peerInfo: [PeerInfo] @@ -18,6 +19,7 @@ struct StatusDetails: Codable { extension StatusDetails: Equatable { static func == (lhs: StatusDetails, rhs: StatusDetails) -> Bool { return lhs.ip == rhs.ip && + lhs.ipv6 == rhs.ipv6 && lhs.fqdn == rhs.fqdn && lhs.managementStatus == rhs.managementStatus && lhs.peerInfo == rhs.peerInfo @@ -27,6 +29,7 @@ extension StatusDetails: Equatable { class PeerInfo: ObservableObject, Codable, Identifiable { var id = UUID() var ip: String + var ipv6: String? var fqdn: String var localIceCandidateEndpoint: String var remoteIceCandidateEndpoint: String @@ -45,11 +48,12 @@ class PeerInfo: ObservableObject, Codable, Identifiable { var routes: [String] var selected: Bool = false - init(ip: String, fqdn: String, localIceCandidateEndpoint: String, remoteIceCandidateEndpoint: String, + init(ip: String, ipv6: String? = nil, fqdn: String, localIceCandidateEndpoint: String, remoteIceCandidateEndpoint: String, localIceCandidateType: String, remoteIceCandidateType: String, pubKey: String, latency: String, bytesRx: Int64, bytesTx: Int64, connStatus: String, connStatusUpdate: String, direct: Bool, lastWireguardHandshake: String, relayed: Bool, rosenpassEnabled: Bool, routes: [String]) { self.ip = ip + self.ipv6 = ipv6 self.fqdn = fqdn self.localIceCandidateEndpoint = localIceCandidateEndpoint self.remoteIceCandidateEndpoint = remoteIceCandidateEndpoint @@ -73,6 +77,7 @@ extension PeerInfo: Equatable { static func == (lhs: PeerInfo, rhs: PeerInfo) -> Bool { return lhs.id == rhs.id && lhs.ip == rhs.ip && + lhs.ipv6 == rhs.ipv6 && lhs.fqdn == rhs.fqdn && lhs.localIceCandidateEndpoint == rhs.localIceCandidateEndpoint && lhs.remoteIceCandidateEndpoint == rhs.remoteIceCandidateEndpoint && @@ -95,6 +100,7 @@ extension PeerInfo: Equatable { extension PeerInfo { func update(from newInfo: PeerInfo) { self.ip = newInfo.ip + self.ipv6 = newInfo.ipv6 self.fqdn = newInfo.fqdn self.localIceCandidateEndpoint = newInfo.localIceCandidateEndpoint self.remoteIceCandidateEndpoint = newInfo.remoteIceCandidateEndpoint diff --git a/NetbirdNetworkExtension/PacketTunnelProvider.swift b/NetbirdNetworkExtension/PacketTunnelProvider.swift index 247c622..e56a389 100644 --- a/NetbirdNetworkExtension/PacketTunnelProvider.swift +++ b/NetbirdNetworkExtension/PacketTunnelProvider.swift @@ -417,6 +417,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let peerInfo = PeerInfo( ip: peer.ip, + ipv6: peer.iPv6, fqdn: peer.fqdn, localIceCandidateEndpoint: peer.localIceCandidateEndpoint, remoteIceCandidateEndpoint: peer.remoteIceCandidateEndpoint, @@ -440,6 +441,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let clientState = adapter.clientState let statusDetails = StatusDetails( ip: statusDetailsMessage.getIP(), + ipv6: statusDetailsMessage.getIPv6(), fqdn: statusDetailsMessage.getFQDN(), managementStatus: clientState, peerInfo: peerInfoArray @@ -469,7 +471,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { do { let routeSelectionDetailsMessage = try adapter.client.getRoutesSelectionDetails() - let routeSelectionInfo: [RoutesSelectionInfo] = (0.. RoutesSelectionInfo? in guard let route = routeSelectionDetailsMessage.get(index) else { return nil } let domains = (0..<(route.domains?.size() ?? 0)).compactMap { domainIndex -> DomainDetails? in diff --git a/NetbirdNetworkExtension/PacketTunnelProviderSettingsManager.swift b/NetbirdNetworkExtension/PacketTunnelProviderSettingsManager.swift index 4d1b7fc..7abb842 100644 --- a/NetbirdNetworkExtension/PacketTunnelProviderSettingsManager.swift +++ b/NetbirdNetworkExtension/PacketTunnelProviderSettingsManager.swift @@ -13,15 +13,16 @@ class PacketTunnelProviderSettingsManager { private weak var packetTunnelProvider: PacketTunnelProvider? private var interfaceIP: String? + private var interfaceIPv6: String? private var ipv4Routes: [NEIPv4Route]? private var ipv6Routes: [NEIPv6Route]? private var dnsSettings: NEDNSSettings? private var needFallbackNS: Bool = false private var containsDefaultRoute: Bool = false - // Link-local dummy IPv6 used to satisfy NEIPv6Settings when we install a - // ::/0 blackhole route to prevent IPv6 leaks while the IPv4 default - // route is in the tunnel and IPv6 is not yet supported on the interface. + // Link-local dummy IPv6 used to satisfy NEIPv6Settings when the + // interface has no IPv6 address but we still need a ::/0 blackhole route + // to prevent IPv6 leaks while the IPv4 default route is in the tunnel. private static let ipv6BlackholeAddress = "fe80::1" private static let ipv6BlackholePrefix: NSNumber = 64 @@ -65,7 +66,11 @@ class PacketTunnelProviderSettingsManager { func setInterfaceIP(interfaceIP: String) { self.interfaceIP = interfaceIP } - + + func setInterfaceIPv6(interfaceIPv6: String) { + self.interfaceIPv6 = interfaceIPv6 + } + func getInterfaceIP() -> String? { return self.interfaceIP } @@ -95,19 +100,25 @@ class PacketTunnelProviderSettingsManager { } tunnelNetworkSettings.ipv4Settings = ipv4Settings - if self.containsDefaultRoute { - let ipv6Settings = NEIPv6Settings( - addresses: [Self.ipv6BlackholeAddress], - networkPrefixLengths: [Self.ipv6BlackholePrefix] - ) - var v6Routes: [NEIPv6Route] = self.ipv6Routes ?? [] - v6Routes.append(NEIPv6Route(destinationAddress: "::", networkPrefixLength: 0)) - ipv6Settings.includedRoutes = v6Routes - tunnelNetworkSettings.ipv6Settings = ipv6Settings - } else { - let ipv6Settings = NEIPv6Settings(addresses: [], networkPrefixLengths: []) - if self.ipv6Routes != nil { - ipv6Settings.includedRoutes = self.ipv6Routes + var v6Addresses: [String] = [] + var v6PrefixLengths: [NSNumber] = [] + var v6Routes: [NEIPv6Route] = [] + + if let ipv6CIDR = self.interfaceIPv6, + let (v6Addr, v6Prefix) = extractIPv6AddressAndPrefix(from: ipv6CIDR) { + v6Addresses.append(v6Addr) + v6PrefixLengths.append(NSNumber(value: v6Prefix)) + v6Routes = self.ipv6Routes ?? [] + } else if self.containsDefaultRoute { + v6Addresses.append(Self.ipv6BlackholeAddress) + v6PrefixLengths.append(Self.ipv6BlackholePrefix) + v6Routes = [NEIPv6Route(destinationAddress: "::", networkPrefixLength: 0)] + } + + if !v6Addresses.isEmpty { + let ipv6Settings = NEIPv6Settings(addresses: v6Addresses, networkPrefixLengths: v6PrefixLengths) + if !v6Routes.isEmpty { + ipv6Settings.includedRoutes = v6Routes } tunnelNetworkSettings.ipv6Settings = ipv6Settings } @@ -121,8 +132,18 @@ class PacketTunnelProviderSettingsManager { return tunnelNetworkSettings } } - + return nil } - + + private func extractIPv6AddressAndPrefix(from cidr: String) -> (String, Int)? { + let parts = cidr.split(separator: "/") + guard parts.count == 2, + let prefix = Int(parts[1]), + (0...128).contains(prefix) else { + return nil + } + return (String(parts[0]), prefix) + } + } diff --git a/netbird-core b/netbird-core index f23aaa9..205ebcf 160000 --- a/netbird-core +++ b/netbird-core @@ -1 +1 @@ -Subproject commit f23aaa9ae7097c3f47e50efe0f418f40a90fd4d7 +Subproject commit 205ebcfda28d08ba692d21fb3dbbc0788310c736