diff --git a/NetBird/Source/App/ViewModels/MainViewModel.swift b/NetBird/Source/App/ViewModels/MainViewModel.swift index eb7930a..f6f971e 100644 --- a/NetBird/Source/App/ViewModels/MainViewModel.swift +++ b/NetBird/Source/App/ViewModels/MainViewModel.swift @@ -110,6 +110,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 @@ -607,6 +608,18 @@ class ViewModel: ObservableObject { loadRosenpassSettings() } + func setDisableIPv6(disabled: Bool) { + self.disableIPv6 = disabled + configProvider.disableIPv6 = disabled + if !configProvider.commit() { + print("Failed to update IPv6 settings") + } + } + + 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 929bc7d..6763e8c 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 diff --git a/NetbirdKit/ConfigurationProvider.swift b/NetbirdKit/ConfigurationProvider.swift index dbe3290..395b4df 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 { @@ -143,6 +165,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 { diff --git a/NetbirdKit/NetworkChangeListener.swift b/NetbirdKit/NetworkChangeListener.swift index b991c1b..fbc4fb0 100644 --- a/NetbirdKit/NetworkChangeListener.swift +++ b/NetbirdKit/NetworkChangeListener.swift @@ -39,10 +39,17 @@ class NetworkChangeListener: NSObject, NetBirdSDKNetworkChangeListenerProtocol { 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.tunnelManager.setInterfaceIPv6(interfaceIPv6: validIPv6) + } func parseRoutesToNESettings(routesString: String) -> ([NEIPv4Route], [NEIPv6Route], Bool) { var v4Routes : [NEIPv4Route] = [] 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 dff0d3b..73e69b0 100644 --- a/NetbirdNetworkExtension/PacketTunnelProvider.swift +++ b/NetbirdNetworkExtension/PacketTunnelProvider.swift @@ -346,6 +346,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let peerInfo = PeerInfo( ip: peer.ip, + ipv6: peer.iPv6, fqdn: peer.fqdn, localIceCandidateEndpoint: peer.localIceCandidateEndpoint, remoteIceCandidateEndpoint: peer.remoteIceCandidateEndpoint, @@ -369,6 +370,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { let clientState = adapter.clientState let statusDetails = StatusDetails( ip: statusDetailsMessage.getIP(), + ipv6: statusDetailsMessage.getIPv6(), fqdn: statusDetailsMessage.getFQDN(), managementStatus: clientState, peerInfo: peerInfoArray diff --git a/NetbirdNetworkExtension/PacketTunnelProviderSettingsManager.swift b/NetbirdNetworkExtension/PacketTunnelProviderSettingsManager.swift index dde837e..28bb1f3 100644 --- a/NetbirdNetworkExtension/PacketTunnelProviderSettingsManager.swift +++ b/NetbirdNetworkExtension/PacketTunnelProviderSettingsManager.swift @@ -13,17 +13,26 @@ 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 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 + init(with packetTunnelProvider: PacketTunnelProvider) { self.packetTunnelProvider = packetTunnelProvider } - + func setRoutes(v4Routes: [NEIPv4Route], v6Routes: [NEIPv6Route], containsDefault: Bool) { self.needFallbackNS = containsDefault + self.containsDefaultRoute = containsDefault self.ipv4Routes = v4Routes self.ipv6Routes = v6Routes self.updateTunnel() @@ -57,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 } @@ -87,12 +100,25 @@ class PacketTunnelProviderSettingsManager { } tunnelNetworkSettings.ipv4Settings = ipv4Settings - 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)] + } + + let ipv6Settings = NEIPv6Settings(addresses: v6Addresses, networkPrefixLengths: v6PrefixLengths) + if !v6Routes.isEmpty { + ipv6Settings.includedRoutes = v6Routes } - tunnelNetworkSettings.ipv6Settings = ipv6Settings tunnelNetworkSettings.mtu = 1280 @@ -104,8 +130,17 @@ 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]) else { + return nil + } + return (String(parts[0]), prefix) + } + } diff --git a/netbird-core b/netbird-core index 3dd34c9..02c0b20 160000 --- a/netbird-core +++ b/netbird-core @@ -1 +1 @@ -Subproject commit 3dd34c920ec30f3797aad0ec87fb3d92a328dda1 +Subproject commit 02c0b20f21e187184eb201ac028a9471ead3b73f