mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
apple: Set network settings using data from connlib (#1846)
This PR sets the network settings, split-DNS, and macOS UI resources using the data from connlib callbacks. This should enable connlib to be developed / tested in Apple platforms (Caveat: There's no UI to see resources in iOS yet). Some assumptions being made are: - It's ok to call disconnect() before onTunnelReady(), but after connect() - CIDR addresses don't include enclosing quotes (they currently include the quotes, like: `"8.8.4.4/32"`) - CIDR addresses in routes always end with “/n” - Connlib calls can be made from a queue (non-main thread) --------- Co-authored-by: Jamil <jamilbk@users.noreply.github.com>
This commit is contained in:
@@ -16,7 +16,7 @@ extension RustString: @unchecked Sendable {}
|
||||
extension RustString: Error {}
|
||||
|
||||
public protocol CallbackHandlerDelegate: AnyObject {
|
||||
func onSetInterfaceConfig(tunnelAddressIPv4: String, tunnelAddressIPv6: String, dnsAddress: String)
|
||||
func onSetInterfaceConfig(tunnelAddressIPv4: String, tunnelAddressIPv6: String, dnsAddress: String, dnsFallbackStrategy: String)
|
||||
func onTunnelReady()
|
||||
func onAddRoute(_: String)
|
||||
func onRemoveRoute(_: String)
|
||||
@@ -34,7 +34,8 @@ public class CallbackHandler {
|
||||
delegate?.onSetInterfaceConfig(
|
||||
tunnelAddressIPv4: tunnelAddressIPv4.toString(),
|
||||
tunnelAddressIPv6: tunnelAddressIPv6.toString(),
|
||||
dnsAddress: dnsAddress.toString()
|
||||
dnsAddress: dnsAddress.toString(),
|
||||
dnsFallbackStrategy: "system_resolver" // Will come from a onSetInterfaceConfig arg eventually
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
6FE4550D2A5D111E006549B1 /* SwiftBridgeCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE4550B2A5D111D006549B1 /* SwiftBridgeCore.swift */; };
|
||||
6FE4550F2A5D112C006549B1 /* connlib-apple.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE4550E2A5D112C006549B1 /* connlib-apple.swift */; };
|
||||
6FE455102A5D112C006549B1 /* connlib-apple.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE4550E2A5D112C006549B1 /* connlib-apple.swift */; };
|
||||
6FE93AFB2A738D7E002D278A /* NetworkSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */; };
|
||||
6FE93AFC2A738D7E002D278A /* NetworkSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */; };
|
||||
794C38152970A2660029F38F /* FirezoneKit in Frameworks */ = {isa = PBXBuildFile; productRef = 794C38142970A2660029F38F /* FirezoneKit */; };
|
||||
794C38172970A26A0029F38F /* FirezoneKit in Frameworks */ = {isa = PBXBuildFile; productRef = 794C38162970A26A0029F38F /* FirezoneKit */; };
|
||||
79756C6629704A7A0018E2D5 /* FirezoneKit in Frameworks */ = {isa = PBXBuildFile; productRef = 79756C6529704A7A0018E2D5 /* FirezoneKit */; };
|
||||
@@ -105,6 +107,7 @@
|
||||
6FE4550B2A5D111D006549B1 /* SwiftBridgeCore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SwiftBridgeCore.swift; path = Connlib/Generated/SwiftBridgeCore.swift; sourceTree = "<group>"; };
|
||||
6FE4550E2A5D112C006549B1 /* connlib-apple.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "connlib-apple.swift"; path = "Connlib/Generated/connlib-apple/connlib-apple.swift"; sourceTree = "<group>"; };
|
||||
6FE455112A5D13A2006549B1 /* FirezoneNetworkExtension-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FirezoneNetworkExtension-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSettings.swift; sourceTree = "<group>"; };
|
||||
8DCC021928D512AC007E12D2 /* Firezone.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Firezone.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
8DCC021C28D512AC007E12D2 /* FirezoneApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirezoneApp.swift; sourceTree = "<group>"; };
|
||||
8DCC022528D512AE007E12D2 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
@@ -159,6 +162,7 @@
|
||||
05CF1CDE290B1A9000CF4755 /* FirezoneNetworkExtension_macOS.entitlements */,
|
||||
05833DFA28F73B070008FAB0 /* PacketTunnelProvider.swift */,
|
||||
6FE454EA2A5BFABA006549B1 /* Adapter.swift */,
|
||||
6FE93AFA2A738D7E002D278A /* NetworkSettings.swift */,
|
||||
6FA39A032A6A7248000F0157 /* NetworkResource.swift */,
|
||||
6FE455082A5D110D006549B1 /* CallbackHandler.swift */,
|
||||
6FE4550B2A5D111D006549B1 /* SwiftBridgeCore.swift */,
|
||||
@@ -456,6 +460,7 @@
|
||||
6FE454F62A5BFB93006549B1 /* Adapter.swift in Sources */,
|
||||
6FA39A042A6A7248000F0157 /* NetworkResource.swift in Sources */,
|
||||
6FE4550C2A5D111E006549B1 /* SwiftBridgeCore.swift in Sources */,
|
||||
6FE93AFB2A738D7E002D278A /* NetworkSettings.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -469,6 +474,7 @@
|
||||
6FE454F72A5BFB93006549B1 /* Adapter.swift in Sources */,
|
||||
6FA39A052A6A7248000F0157 /* NetworkResource.swift in Sources */,
|
||||
6FE4550D2A5D111E006549B1 /* SwiftBridgeCore.swift in Sources */,
|
||||
6FE93AFC2A738D7E002D278A /* NetworkSettings.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -12,30 +12,55 @@ public enum AdapterError: Error {
|
||||
/// Failure to perform an operation in such state.
|
||||
case invalidState
|
||||
|
||||
/// connlib failed to start
|
||||
case connlibConnectError(Error)
|
||||
|
||||
/// connlib fatal error
|
||||
case connlibFatalError(String)
|
||||
|
||||
/// No network settings were provided
|
||||
case noNetworkSettings
|
||||
|
||||
/// Failure to set network settings.
|
||||
case setNetworkSettings(Error)
|
||||
|
||||
/// stop() called before the tunnel became ready
|
||||
case stoppedByRequestWhileStarting
|
||||
}
|
||||
|
||||
/// Enum representing internal state of the `WireGuardAdapter`
|
||||
private enum State {
|
||||
/// The tunnel is stopped
|
||||
case stopped
|
||||
/// Enum representing internal state of the adapter
|
||||
private enum AdapterState: CustomStringConvertible {
|
||||
case startingTunnel(session: WrappedSession, onStarted: Adapter.StartTunnelCompletionHandler?)
|
||||
case tunnelReady(session: WrappedSession)
|
||||
case stoppingTunnel(session: WrappedSession, onStopped: Adapter.StopTunnelCompletionHandler?)
|
||||
case stoppedTunnel
|
||||
case stoppingTunnelTemporarily(session: WrappedSession, onStopped: Adapter.StopTunnelCompletionHandler?)
|
||||
case stoppedTunnelTemporarily
|
||||
|
||||
/// The tunnel is up and running
|
||||
case started(_ handle: WrappedSession)
|
||||
|
||||
/// The tunnel is temporarily shutdown due to device going offline
|
||||
case temporaryShutdown
|
||||
var description: String {
|
||||
switch self {
|
||||
case .startingTunnel: return "startingTunnel"
|
||||
case .tunnelReady: return "tunnelReady"
|
||||
case .stoppingTunnel: return "stoppingTunnel"
|
||||
case .stoppedTunnel: return "stoppedTunnel"
|
||||
case .stoppingTunnelTemporarily: return "stoppingTunnelTemporarily"
|
||||
case .stoppedTunnelTemporarily: return "stoppedTunnelTemporarily"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loosely inspired from WireGuardAdapter from WireGuardKit
|
||||
public class Adapter {
|
||||
|
||||
typealias StartTunnelCompletionHandler = ((AdapterError?) -> Void)
|
||||
typealias StopTunnelCompletionHandler = (() -> Void)
|
||||
|
||||
private let logger = Logger(subsystem: "dev.firezone.firezone", category: "packet-tunnel")
|
||||
|
||||
private var callbackHandler: CallbackHandler
|
||||
|
||||
// Latest applied NETunnelProviderNetworkSettings
|
||||
public var lastNetworkSettings: NEPacketTunnelNetworkSettings?
|
||||
/// Network settings
|
||||
private var networkSettings: NetworkSettings?
|
||||
|
||||
/// Packet tunnel provider.
|
||||
private weak var packetTunnelProvider: NEPacketTunnelProvider?
|
||||
@@ -47,25 +72,36 @@ public class Adapter {
|
||||
private let workQueue = DispatchQueue(label: "FirezoneAdapterWorkQueue")
|
||||
|
||||
/// Adapter state.
|
||||
private var state: State = .stopped
|
||||
private var state: AdapterState {
|
||||
didSet {
|
||||
logger.debug("Adapter state changed to: \(self.state, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep track of resources
|
||||
private var displayableResources = DisplayableResources()
|
||||
|
||||
public init(with packetTunnelProvider: NEPacketTunnelProvider) {
|
||||
/// Starting parameters
|
||||
private var portalURLString: String
|
||||
private var token: String
|
||||
|
||||
public init(portalURLString: String, token: String, packetTunnelProvider: NEPacketTunnelProvider) {
|
||||
self.portalURLString = portalURLString
|
||||
self.token = token
|
||||
self.packetTunnelProvider = packetTunnelProvider
|
||||
self.callbackHandler = CallbackHandler()
|
||||
self.callbackHandler.delegate = self
|
||||
self.state = .stoppedTunnel
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.logger.debug("Adapter.deinit")
|
||||
// Cancel network monitor
|
||||
networkMonitor?.cancel()
|
||||
|
||||
// Shutdown the tunnel
|
||||
if case .started(let wrappedSession) = self.state {
|
||||
self.logger.log(level: .debug, "\(#function)")
|
||||
wrappedSession.disconnect()
|
||||
if case .tunnelReady(let wrappedSession) = self.state {
|
||||
logger.debug("Adapter.deinit: Shutting down connlib")
|
||||
_ = wrappedSession.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,59 +109,76 @@ public class Adapter {
|
||||
/// - Parameters:
|
||||
/// - completionHandler: completion handler.
|
||||
public func start(completionHandler: @escaping (AdapterError?) -> Void) throws {
|
||||
workQueue.async {
|
||||
guard case .stopped = self.state else {
|
||||
workQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.logger.debug("Adapter.start")
|
||||
guard case .stoppedTunnel = self.state else {
|
||||
completionHandler(.invalidState)
|
||||
return
|
||||
}
|
||||
|
||||
let networkMonitor = NWPathMonitor()
|
||||
networkMonitor.pathUpdateHandler = { [weak self] path in
|
||||
self?.didReceivePathUpdate(path: path)
|
||||
}
|
||||
networkMonitor.start(queue: self.workQueue)
|
||||
self.callbackHandler.delegate = self
|
||||
|
||||
self.logger.debug("Adapter.start: Starting connlib")
|
||||
do {
|
||||
try self.setNetworkSettings(self.generateNetworkSettings(ipv4Routes: [], ipv6Routes: []))
|
||||
self.state = .started(
|
||||
try WrappedSession.connect("http://localhost:4568", "test-token", self.callbackHandler)
|
||||
self.state = .startingTunnel(
|
||||
session: try WrappedSession.connect(self.portalURLString, self.token, self.callbackHandler),
|
||||
onStarted: completionHandler
|
||||
)
|
||||
self.networkMonitor = networkMonitor
|
||||
completionHandler(nil)
|
||||
} catch let error as AdapterError {
|
||||
networkMonitor.cancel()
|
||||
completionHandler(error)
|
||||
} catch {
|
||||
fatalError()
|
||||
} catch let error {
|
||||
self.logger.error("Adapter.start: Error: \(error, privacy: .public)")
|
||||
self.state = .stoppedTunnel
|
||||
completionHandler(AdapterError.connlibConnectError(error))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public func stop(completionHandler: @escaping (AdapterError?) -> Void) {
|
||||
workQueue.async {
|
||||
switch self.state {
|
||||
case .started(let wrappedSession):
|
||||
wrappedSession.disconnect()
|
||||
case .temporaryShutdown:
|
||||
break
|
||||
/// Stop the tunnel
|
||||
public func stop(completionHandler: @escaping () -> Void) {
|
||||
workQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
case .stopped:
|
||||
completionHandler(.invalidState)
|
||||
return
|
||||
self.logger.debug("Adapter.stop")
|
||||
|
||||
switch self.state {
|
||||
case .stoppedTunnel, .stoppingTunnel:
|
||||
break
|
||||
case .tunnelReady(let session):
|
||||
self.logger.debug("Adapter.stop: Shutting down connlib")
|
||||
self.state = .stoppingTunnel(session: session, onStopped: completionHandler)
|
||||
_ = session.disconnect()
|
||||
case .startingTunnel(let session, let onStarted):
|
||||
self.logger.debug("Adapter.stop: Shutting down connlib before tunnel ready")
|
||||
self.state = .stoppingTunnel(session: session, onStopped: {
|
||||
onStarted?(AdapterError.stoppedByRequestWhileStarting)
|
||||
completionHandler()
|
||||
})
|
||||
// FIXME: Is it ok to call disconnect() while we haven't got onTunnelReady?
|
||||
_ = session.disconnect()
|
||||
case .stoppingTunnelTemporarily(let session, let onStopped):
|
||||
self.state = .stoppingTunnel(session: session, onStopped: {
|
||||
onStopped?()
|
||||
completionHandler()
|
||||
})
|
||||
case .stoppedTunnelTemporarily:
|
||||
self.state = .stoppedTunnel
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
self.networkMonitor?.cancel()
|
||||
self.networkMonitor = nil
|
||||
|
||||
self.state = .stopped
|
||||
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current set of resources in the completionHandler.
|
||||
/// If unchanged since referenceVersionString, call completionHandler(nil).
|
||||
public func getDisplayableResourcesIfVersionDifferentFrom(
|
||||
referenceVersionString: String, completionHandler: @escaping (DisplayableResources?) -> Void) {
|
||||
workQueue.async {
|
||||
workQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
if referenceVersionString == self.displayableResources.versionString {
|
||||
completionHandler(nil)
|
||||
} else {
|
||||
@@ -133,209 +186,255 @@ public class Adapter {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func generateNetworkSettings(
|
||||
addresses4: [String] = ["100.100.111.2"], addresses6: [String] = ["fd00:0222:2011:1111::2"],
|
||||
ipv4Routes: [NEIPv4Route], ipv6Routes: [NEIPv6Route]
|
||||
)
|
||||
-> NEPacketTunnelNetworkSettings
|
||||
{
|
||||
// The destination IP that connlib will assign our DNS proxy to.
|
||||
let dnsSentinel = "1.1.1.1"
|
||||
// MARK: Responding to path updates
|
||||
|
||||
// We can probably do better than this; see https://www.rfc-editor.org/info/rfc4821
|
||||
// But stick with something simple for now. 1280 is the minimum that will work for IPv6.
|
||||
let mtu = 1280
|
||||
|
||||
// TODO: replace these with IPs returned from the connect call to portal
|
||||
let subnetmask = "255.192.0.0"
|
||||
let networkPrefixLength = NSNumber(value: 64)
|
||||
|
||||
/* iOS requires a tunnel endpoint, whereas in WireGuard it's valid for
|
||||
* a tunnel to have no endpoint, or for there to be many endpoints, in
|
||||
* which case, displaying a single one in settings doesn't really
|
||||
* make sense. So, we fill it in with this placeholder, which is not
|
||||
* a valid IP address that will actually route over the Internet.
|
||||
*/
|
||||
let networkSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1")
|
||||
let dnsSettings = NEDNSSettings(servers: [dnsSentinel])
|
||||
|
||||
// All DNS queries must first go through the tunnel's DNS
|
||||
dnsSettings.matchDomains = [""]
|
||||
networkSettings.dnsSettings = dnsSettings
|
||||
networkSettings.mtu = NSNumber(value: mtu)
|
||||
|
||||
let ipv4Settings = NEIPv4Settings(
|
||||
addresses: addresses4,
|
||||
subnetMasks: [subnetmask])
|
||||
ipv4Settings.includedRoutes = ipv4Routes
|
||||
networkSettings.ipv4Settings = ipv4Settings
|
||||
|
||||
let ipv6Settings = NEIPv6Settings(
|
||||
addresses: addresses6,
|
||||
networkPrefixLengths: [networkPrefixLength])
|
||||
ipv6Settings.includedRoutes = ipv6Routes
|
||||
networkSettings.ipv6Settings = ipv6Settings
|
||||
|
||||
return networkSettings
|
||||
}
|
||||
|
||||
public func setNetworkSettings(_ networkSettings: NEPacketTunnelNetworkSettings) throws {
|
||||
var systemError: Error?
|
||||
let condition = NSCondition()
|
||||
|
||||
// Activate the condition
|
||||
condition.lock()
|
||||
defer { condition.unlock() }
|
||||
|
||||
self.packetTunnelProvider?.setTunnelNetworkSettings(networkSettings) { error in
|
||||
systemError = error
|
||||
condition.signal()
|
||||
}
|
||||
|
||||
// Packet tunnel's `setTunnelNetworkSettings` times out in certain
|
||||
// scenarios & never calls the given callback.
|
||||
let setTunnelNetworkSettingsTimeout: TimeInterval = 5 // seconds
|
||||
|
||||
if condition.wait(until: Date().addingTimeInterval(setTunnelNetworkSettingsTimeout)) {
|
||||
if let systemError = systemError {
|
||||
throw AdapterError.setNetworkSettings(systemError)
|
||||
}
|
||||
}
|
||||
|
||||
// Save the latest applied network settings if there was no error.
|
||||
if systemError != nil {
|
||||
self.lastNetworkSettings = networkSettings
|
||||
}
|
||||
}
|
||||
|
||||
/// Update runtime configuration.
|
||||
/// - Parameters:
|
||||
/// - ipv4Routes: IPv4 routes to send through the tunnel.
|
||||
/// - ipv6Routes: IPv6 routes to send through the tunnel.
|
||||
/// - completionHandler: completion handler.
|
||||
public func update(
|
||||
ipv4Routes: [NEIPv4Route], ipv6Routes: [NEIPv6Route],
|
||||
completionHandler: @escaping (AdapterError?) -> Void
|
||||
) {
|
||||
workQueue.async {
|
||||
if case .stopped = self.state {
|
||||
completionHandler(.invalidState)
|
||||
return
|
||||
}
|
||||
|
||||
// Tell the system that the tunnel is going to reconnect using new WireGuard
|
||||
// configuration.
|
||||
// This will broadcast the `NEVPNStatusDidChange` notification to the GUI process.
|
||||
self.packetTunnelProvider?.reasserting = true
|
||||
defer {
|
||||
self.packetTunnelProvider?.reasserting = false
|
||||
}
|
||||
|
||||
do {
|
||||
try self.setNetworkSettings(
|
||||
self.generateNetworkSettings(ipv4Routes: ipv4Routes, ipv6Routes: ipv6Routes))
|
||||
|
||||
switch self.state {
|
||||
case .started(let wrappedSession):
|
||||
self.state = .started(wrappedSession)
|
||||
|
||||
case .temporaryShutdown:
|
||||
self.state = .temporaryShutdown
|
||||
|
||||
case .stopped:
|
||||
fatalError()
|
||||
}
|
||||
|
||||
completionHandler(nil)
|
||||
} catch let error as AdapterError {
|
||||
completionHandler(error)
|
||||
} catch {
|
||||
fatalError()
|
||||
}
|
||||
extension Adapter {
|
||||
private func beginPathMonitoring() {
|
||||
self.logger.debug("Beginning path monitoring")
|
||||
let networkMonitor = NWPathMonitor()
|
||||
networkMonitor.pathUpdateHandler = { [weak self] path in
|
||||
self?.didReceivePathUpdate(path: path)
|
||||
}
|
||||
networkMonitor.start(queue: self.workQueue)
|
||||
}
|
||||
|
||||
private func didReceivePathUpdate(path: Network.NWPath) {
|
||||
#if os(macOS)
|
||||
if case .started(let wrappedSession) = self.state {
|
||||
self.logger.log(level: .debug, "Suppressing call to bumpSockets()")
|
||||
// wrappedSession.bumpSockets()
|
||||
}
|
||||
#elseif os(iOS)
|
||||
switch self.state {
|
||||
case .started(let wrappedSession):
|
||||
// Will be invoked in the workQueue by the path monitor
|
||||
switch self.state {
|
||||
|
||||
case .startingTunnel(let session, let onStarted):
|
||||
if path.status != .satisfied {
|
||||
self.logger.debug("Adapter.didReceivePathUpdate: Offline. Shutting down connlib.")
|
||||
onStarted?(nil)
|
||||
self.packetTunnelProvider?.reasserting = true
|
||||
self.state = .stoppingTunnelTemporarily(session: session, onStopped: nil)
|
||||
// FIXME: Is it ok to call disconnect() while we haven't got onTunnelReady?
|
||||
_ = session.disconnect()
|
||||
}
|
||||
|
||||
case .tunnelReady(let session):
|
||||
if path.status == .satisfied {
|
||||
self.logger.log(level: .debug, "Suppressing calls to disableSomeRoamingForBrokenMobileSemantics() and bumpSockets()")
|
||||
self.logger.debug("Suppressing calls to disableSomeRoamingForBrokenMobileSemantics() and bumpSockets()")
|
||||
// #if os(iOS)
|
||||
// wrappedSession.disableSomeRoamingForBrokenMobileSemantics()
|
||||
// #endif
|
||||
// wrappedSession.bumpSockets()
|
||||
} else {
|
||||
//self.logger.log(.debug, "Connectivity offline, pausing backend.")
|
||||
self.state = .temporaryShutdown
|
||||
wrappedSession.disconnect()
|
||||
self.logger.debug("Adapter.didReceivePathUpdate: Offline. Shutting down connlib.")
|
||||
self.packetTunnelProvider?.reasserting = true
|
||||
self.state = .stoppingTunnelTemporarily(session: session, onStopped: nil)
|
||||
_ = session.disconnect()
|
||||
}
|
||||
|
||||
case .temporaryShutdown:
|
||||
case .stoppingTunnelTemporarily:
|
||||
break
|
||||
|
||||
case .stoppedTunnelTemporarily:
|
||||
guard path.status == .satisfied else { return }
|
||||
|
||||
self.logger.log(level: .debug, "Connectivity online, resuming backend.")
|
||||
self.logger.debug("Adapter.didReceivePathUpdate: Back online. Starting connlib.")
|
||||
|
||||
do {
|
||||
try self.setNetworkSettings(self.lastNetworkSettings!)
|
||||
|
||||
self.state = .started(
|
||||
try WrappedSession.connect("http://localhost:4568", "test-token", self.callbackHandler)
|
||||
self.state = .startingTunnel(
|
||||
session: try WrappedSession.connect(portalURLString, token, self.callbackHandler),
|
||||
onStarted: { error in
|
||||
if let error = error {
|
||||
self.logger.error("Adapter.didReceivePathUpdate: Error starting connlib: \(error, privacy: .public)")
|
||||
self.packetTunnelProvider?.cancelTunnelWithError(error)
|
||||
} else {
|
||||
self.packetTunnelProvider?.reasserting = false
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch let error as AdapterError {
|
||||
self.logger.error("Adapter.didReceivePathUpdate: Error: \(error, privacy: .public)")
|
||||
} catch {
|
||||
self.logger.log(level: .debug, "Failed to restart backend: \(error.localizedDescription)")
|
||||
self.logger.error("Adapter.didReceivePathUpdate: Unknown error: \(error, privacy: .public) (fatal)")
|
||||
}
|
||||
|
||||
case .stopped:
|
||||
case .stoppingTunnel, .stoppedTunnel:
|
||||
// no-op
|
||||
break
|
||||
}
|
||||
#else
|
||||
#error("Unsupported")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Implementing CallbackHandlerDelegate
|
||||
|
||||
extension Adapter: CallbackHandlerDelegate {
|
||||
public func onSetInterfaceConfig(tunnelAddressIPv4: String, tunnelAddressIPv6: String, dnsAddress: String) {
|
||||
// Unimplemented
|
||||
public func onSetInterfaceConfig(tunnelAddressIPv4: String, tunnelAddressIPv6: String, dnsAddress: String, dnsFallbackStrategy: String) {
|
||||
workQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.logger.debug("Adapter.onSetInterfaceConfig")
|
||||
|
||||
switch self.state {
|
||||
case .startingTunnel:
|
||||
self.networkSettings = NetworkSettings(
|
||||
tunnelAddressIPv4: tunnelAddressIPv4, tunnelAddressIPv6: tunnelAddressIPv6,
|
||||
dnsAddress: dnsAddress, dnsFallbackStrategy: NetworkSettings.DNSFallbackStrategy(dnsFallbackStrategy))
|
||||
case .tunnelReady:
|
||||
if let networkSettings = self.networkSettings {
|
||||
networkSettings.setDNSFallbackStrategy(NetworkSettings.DNSFallbackStrategy(dnsFallbackStrategy))
|
||||
if let packetTunnelProvider = self.packetTunnelProvider {
|
||||
networkSettings.apply(on: packetTunnelProvider, logger: self.logger, completionHandler: nil)
|
||||
}
|
||||
}
|
||||
|
||||
case .stoppingTunnel, .stoppedTunnel, .stoppingTunnelTemporarily, .stoppedTunnelTemporarily:
|
||||
// This is not expected to happen
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func onTunnelReady() {
|
||||
// Unimplemented
|
||||
workQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.logger.debug("Adapter.onTunnelReady")
|
||||
guard case .startingTunnel(let session, let onStarted) = self.state else {
|
||||
self.logger.error("Adapter.onTunnelReady: Unexpected state: \(self.state, privacy: .public)")
|
||||
return
|
||||
}
|
||||
guard let networkSettings = self.networkSettings else {
|
||||
self.logger.error("Adapter.onTunnelReady: No network settings")
|
||||
return
|
||||
}
|
||||
guard let packetTunnelProvider = self.packetTunnelProvider else {
|
||||
self.logger.error("Adapter.onTunnelReady: No packet tunnel provider")
|
||||
return
|
||||
}
|
||||
networkSettings.apply(on: packetTunnelProvider, logger: self.logger) { error in
|
||||
if let error = error {
|
||||
onStarted?(AdapterError.setNetworkSettings(error))
|
||||
self.state = .stoppedTunnel
|
||||
} else {
|
||||
onStarted?(nil)
|
||||
self.state = .tunnelReady(session: session)
|
||||
self.beginPathMonitoring()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func onAddRoute(_: String) {
|
||||
// Unimplemented
|
||||
public func onAddRoute(_ route: String) {
|
||||
workQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.logger.debug("Adapter.onAddRoute(\(route, privacy: .public))")
|
||||
guard let networkSettings = self.networkSettings else {
|
||||
self.logger.error("Adapter.onAddRoute: No network settings")
|
||||
return
|
||||
}
|
||||
guard let packetTunnelProvider = self.packetTunnelProvider else {
|
||||
self.logger.error("Adapter.onAddRoute: No packet tunnel provider")
|
||||
return
|
||||
}
|
||||
|
||||
networkSettings.addRoute(route)
|
||||
if case .tunnelReady = self.state {
|
||||
networkSettings.apply(on: packetTunnelProvider, logger: self.logger, completionHandler: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func onRemoveRoute(_: String) {
|
||||
// Unimplemented
|
||||
public func onRemoveRoute(_ route: String) {
|
||||
workQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.logger.debug("Adapter.onRemoveRoute(\(route, privacy: .public))")
|
||||
guard let networkSettings = self.networkSettings else {
|
||||
self.logger.error("Adapter.onRemoveRoute: No network settings")
|
||||
return
|
||||
}
|
||||
guard let packetTunnelProvider = self.packetTunnelProvider else {
|
||||
self.logger.error("Adapter.onRemoveRoute: No packet tunnel provider")
|
||||
return
|
||||
}
|
||||
networkSettings.removeRoute(route)
|
||||
if case .tunnelReady = self.state {
|
||||
networkSettings.apply(on: packetTunnelProvider, logger: self.logger, completionHandler: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func onUpdateResources(resourceList: String) {
|
||||
workQueue.async {
|
||||
let jsonString = "[\(resourceList)]"
|
||||
workQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.logger.debug("Adapter.onUpdateResources")
|
||||
let jsonString = resourceList
|
||||
guard let jsonData = jsonString.data(using: .utf8) else {
|
||||
return
|
||||
}
|
||||
guard let networkResources = try? JSONDecoder().decode([NetworkResource].self, from: jsonData) else {
|
||||
return
|
||||
}
|
||||
|
||||
// Note down the resources
|
||||
self.displayableResources.update(resources: networkResources.map { $0.displayableResource })
|
||||
|
||||
// Update DNS in case resource domains is changing
|
||||
guard let networkSettings = self.networkSettings else {
|
||||
self.logger.error("Adapter.onUpdateResources: No network settings")
|
||||
return
|
||||
}
|
||||
guard let packetTunnelProvider = self.packetTunnelProvider else {
|
||||
self.logger.error("Adapter.onUpdateResources: No packet tunnel provider")
|
||||
return
|
||||
}
|
||||
let updatedResourceDomains = networkResources.compactMap { $0.resourceLocation.domain }
|
||||
networkSettings.setResourceDomains(updatedResourceDomains)
|
||||
if case .tunnelReady = self.state {
|
||||
networkSettings.apply(on: packetTunnelProvider, logger: self.logger, completionHandler: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func onDisconnect(error: Optional<String>) {
|
||||
// Unimplemented
|
||||
workQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
self.logger.debug("Adapter.onDisconnect")
|
||||
if let errorMessage = error {
|
||||
self.logger.error("Connlib disconnected with unrecoverable error: \(errorMessage, privacy: .public)")
|
||||
switch self.state {
|
||||
case .stoppingTunnel(session: _, let onStopped):
|
||||
onStopped?()
|
||||
self.state = .stoppedTunnel
|
||||
case .stoppingTunnelTemporarily(session: _, let onStopped):
|
||||
onStopped?()
|
||||
self.state = .stoppedTunnel
|
||||
case .stoppedTunnel:
|
||||
// This should not happen
|
||||
break
|
||||
case .stoppedTunnelTemporarily:
|
||||
self.state = .stoppedTunnel
|
||||
default:
|
||||
self.packetTunnelProvider?.cancelTunnelWithError(AdapterError.connlibFatalError(errorMessage))
|
||||
self.state = .stoppedTunnel
|
||||
}
|
||||
} else {
|
||||
self.logger.debug("Connlib disconnected")
|
||||
switch self.state {
|
||||
case .stoppingTunnel(session: _, let onStopped):
|
||||
onStopped?()
|
||||
self.state = .stoppedTunnel
|
||||
case .stoppingTunnelTemporarily(session: _, let onStopped):
|
||||
onStopped?()
|
||||
self.state = .stoppedTunnelTemporarily
|
||||
default:
|
||||
// This should not happen
|
||||
self.state = .stoppedTunnel
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func onError(error: String) {
|
||||
let logger = Logger(subsystem: "dev.firezone.firezone", category: "packet-tunnel")
|
||||
logger.log(level: .error, "Internal connlib error: \(error, privacy: .public)")
|
||||
self.logger.error("Internal connlib error: \(error, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,13 @@ public struct NetworkResource: Decodable {
|
||||
case .cidr(let cidrAddress): return cidrAddress
|
||||
}
|
||||
}
|
||||
|
||||
var domain: String? {
|
||||
switch self {
|
||||
case .dns(let domain, ipv4: _, ipv6: _): return domain
|
||||
case .cidr(cidrAddress: _): return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let name: String
|
||||
|
||||
194
swift/apple/FirezoneNetworkExtension/NetworkSettings.swift
Normal file
194
swift/apple/FirezoneNetworkExtension/NetworkSettings.swift
Normal file
@@ -0,0 +1,194 @@
|
||||
//
|
||||
// NetworkSettings.swift
|
||||
// (c) 2023 Firezone, Inc.
|
||||
// LICENSE: Apache-2.0
|
||||
|
||||
import Foundation
|
||||
import NetworkExtension
|
||||
import os.log
|
||||
|
||||
class NetworkSettings {
|
||||
|
||||
enum DNSFallbackStrategy: String {
|
||||
// How to handle DNS requests for domains not handled by Firezone
|
||||
case systemResolver = "system_resolver" // Have the OS handle it using split-DNS
|
||||
case upstreamResolver = "upstream_resolver" // Have connlib pass it on to a user-specified DNS server
|
||||
|
||||
init(_ string: String) {
|
||||
if string == "upstream_resolver" {
|
||||
self = .upstreamResolver
|
||||
} else if string == "system_resolver" {
|
||||
self = .systemResolver
|
||||
} else {
|
||||
// silent default
|
||||
self = .systemResolver
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unchanging values
|
||||
let tunnelAddressIPv4: String
|
||||
let tunnelAddressIPv6: String
|
||||
let dnsAddress: String
|
||||
|
||||
// Modifiable values
|
||||
private(set) var dnsFallbackStrategy: DNSFallbackStrategy
|
||||
private(set) var routes: [String] = []
|
||||
private(set) var resourceDomains: [String] = []
|
||||
|
||||
// To keep track of modifications
|
||||
private(set) var hasUnappliedChanges: Bool
|
||||
|
||||
init(tunnelAddressIPv4: String, tunnelAddressIPv6: String, dnsAddress: String, dnsFallbackStrategy: DNSFallbackStrategy) {
|
||||
self.tunnelAddressIPv4 = tunnelAddressIPv4
|
||||
self.tunnelAddressIPv6 = tunnelAddressIPv6
|
||||
self.dnsAddress = dnsAddress
|
||||
self.dnsFallbackStrategy = dnsFallbackStrategy
|
||||
self.hasUnappliedChanges = true
|
||||
}
|
||||
|
||||
func setDNSFallbackStrategy(_ dnsFallbackStrategy: DNSFallbackStrategy) {
|
||||
if self.dnsFallbackStrategy != dnsFallbackStrategy {
|
||||
self.dnsFallbackStrategy = dnsFallbackStrategy
|
||||
self.hasUnappliedChanges = true
|
||||
}
|
||||
}
|
||||
|
||||
func addRoute(_ route: String) {
|
||||
if !self.routes.contains(route) {
|
||||
self.routes.append(route)
|
||||
self.hasUnappliedChanges = true
|
||||
}
|
||||
}
|
||||
|
||||
func removeRoute(_ route: String) {
|
||||
if self.routes.contains(route) {
|
||||
self.routes.removeAll(where: { $0 == route })
|
||||
self.hasUnappliedChanges = true
|
||||
}
|
||||
}
|
||||
|
||||
func setResourceDomains(_ resourceDomains: [String]) {
|
||||
let sortedResourceDomains = resourceDomains.sorted()
|
||||
if self.resourceDomains != sortedResourceDomains {
|
||||
self.resourceDomains = sortedResourceDomains
|
||||
if dnsFallbackStrategy == .systemResolver {
|
||||
self.hasUnappliedChanges = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func apply(on packetTunnelProvider: NEPacketTunnelProvider, logger: Logger, completionHandler: ((Error?) -> Void)?) {
|
||||
|
||||
guard self.hasUnappliedChanges else {
|
||||
logger.error("NetworkSettings.apply: No changes to apply")
|
||||
completionHandler?(nil)
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug("NetworkSettings.apply: Applying network settings")
|
||||
|
||||
var tunnelIPv4Routes: [NEIPv4Route] = []
|
||||
var tunnelIPv6Routes: [NEIPv6Route] = []
|
||||
|
||||
for route in routes {
|
||||
let components = route.split(separator: "/")
|
||||
guard components.count == 2 else {
|
||||
logger.error("NetworkSettings.apply: Ignoring invalid route '\(route, privacy: .public)'")
|
||||
continue
|
||||
}
|
||||
let address = String(components[0])
|
||||
let networkPrefixLengthString = String(components[1])
|
||||
if let groupSeparator = address.first(where: { $0 == "." || $0 == ":"}) {
|
||||
if groupSeparator == "." { // IPv4 address
|
||||
if IPv4Address(address) == nil {
|
||||
logger.error("NetworkSettings.apply: Ignoring invalid IPv4 address '\(address, privacy: .public)'")
|
||||
continue
|
||||
}
|
||||
let validNetworkPrefixLength = Self.validNetworkPrefixLength(fromString: networkPrefixLengthString, maximumValue: 32)
|
||||
let ipv4SubnetMask = Self.ipv4SubnetMask(networkPrefixLength: validNetworkPrefixLength)
|
||||
logger.debug("NetworkSettings.apply: Adding IPv4 route: \(address) (subnet mask: \(ipv4SubnetMask))")
|
||||
tunnelIPv4Routes.append(NEIPv4Route(destinationAddress: address, subnetMask: ipv4SubnetMask))
|
||||
}
|
||||
if groupSeparator == ":" { // IPv6 address
|
||||
if IPv6Address(address) == nil {
|
||||
logger.error("NetworkSettings.apply: Ignoring invalid IPv6 address '\(address, privacy: .public)'")
|
||||
continue
|
||||
}
|
||||
let validNetworkPrefixLength = Self.validNetworkPrefixLength(fromString: networkPrefixLengthString, maximumValue: 128)
|
||||
logger.debug("NetworkSettings.apply: Adding IPv6 route: \(address) (prefix length: \(validNetworkPrefixLength))")
|
||||
tunnelIPv6Routes.append(NEIPv6Route(destinationAddress: address, networkPrefixLength: NSNumber(integerLiteral: validNetworkPrefixLength)))
|
||||
}
|
||||
} else {
|
||||
logger.error("NetworkSettings.apply: Ignoring invalid route '\(route, privacy: .public)'")
|
||||
}
|
||||
}
|
||||
|
||||
// We don't really know the connlib gateway IP address at this point, but just using 127.0.0.1 is okay
|
||||
// because the OS doesn't really need this IP address.
|
||||
// NEPacketTunnelNetworkSettings taking in tunnelRemoteAddress is probably a bad abstraction caused by
|
||||
// NEPacketTunnelNetworkSettings inheriting from NETunnelNetworkSettings.
|
||||
|
||||
let tunnelNetworkSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1")
|
||||
|
||||
let ipv4Settings = NEIPv4Settings(addresses: [tunnelAddressIPv4], subnetMasks: ["255.255.255.255"])
|
||||
ipv4Settings.includedRoutes = tunnelIPv4Routes
|
||||
tunnelNetworkSettings.ipv4Settings = ipv4Settings
|
||||
|
||||
let ipv6Settings = NEIPv6Settings(addresses: [tunnelAddressIPv6], networkPrefixLengths: [128])
|
||||
ipv6Settings.includedRoutes = tunnelIPv6Routes
|
||||
tunnelNetworkSettings.ipv6Settings = ipv6Settings
|
||||
|
||||
let dnsSettings = NEDNSSettings(servers: [dnsAddress])
|
||||
switch dnsFallbackStrategy {
|
||||
case .systemResolver:
|
||||
// Enable split-DNS. Only those domains matching the resources will be sent to the tunnel's DNS.
|
||||
dnsSettings.matchDomains = resourceDomains
|
||||
case .upstreamResolver:
|
||||
// All DNS queries go to the tunnel's DNS.
|
||||
dnsSettings.matchDomains = [""]
|
||||
}
|
||||
tunnelNetworkSettings.dnsSettings = dnsSettings
|
||||
|
||||
self.hasUnappliedChanges = false
|
||||
packetTunnelProvider.setTunnelNetworkSettings(tunnelNetworkSettings) { error in
|
||||
if let error = error {
|
||||
logger.error("NetworkSettings.apply: Error: \(error, privacy: .public)")
|
||||
} else {
|
||||
guard !self.hasUnappliedChanges else {
|
||||
// Changes were made while the packetTunnelProvider was setting the network settings
|
||||
logger.debug("NetworkSettings.apply: Applying changes made to network settings while we were applying the network settings")
|
||||
self.apply(on: packetTunnelProvider, logger: logger, completionHandler: completionHandler)
|
||||
return
|
||||
}
|
||||
logger.debug("NetworkSettings.apply: Applied successfully")
|
||||
}
|
||||
completionHandler?(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension NetworkSettings {
|
||||
private static func validNetworkPrefixLength(fromString string: String, maximumValue: Int) -> Int {
|
||||
guard let networkPrefixLength = Int(string) else { return 0 }
|
||||
if networkPrefixLength < 0 { return 0 }
|
||||
if networkPrefixLength > maximumValue { return maximumValue }
|
||||
return networkPrefixLength
|
||||
}
|
||||
|
||||
private static func ipv4SubnetMask(networkPrefixLength: Int) -> String {
|
||||
precondition(networkPrefixLength >= 0 && networkPrefixLength <= 32)
|
||||
let mask: UInt32 = 0xFFFFFFFF
|
||||
let maxPrefixLength = 32
|
||||
let octets = 4
|
||||
|
||||
let subnetMask = mask & (mask << (maxPrefixLength - networkPrefixLength))
|
||||
var parts: [String] = []
|
||||
for idx in 0...(octets - 1) {
|
||||
let part = String(UInt32(0x000000FF) & (subnetMask >> ((octets - 1 - idx) * 8)), radix: 10)
|
||||
parts.append(part)
|
||||
}
|
||||
|
||||
return parts.joined(separator: ".")
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ enum PacketTunnelProviderError: String, Error {
|
||||
class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
static let logger = Logger(subsystem: "dev.firezone.firezone", category: "packet-tunnel")
|
||||
|
||||
private lazy var adapter = Adapter(with: self)
|
||||
private var adapter: Adapter?
|
||||
|
||||
override func startTunnel(
|
||||
options _: [String: NSObject]? = nil,
|
||||
@@ -28,7 +28,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
}
|
||||
|
||||
let providerConfiguration = tunnelProviderProtocol.providerConfiguration
|
||||
guard let portalURL = providerConfiguration?["portalURL"] as? String else {
|
||||
guard let portalURLString = providerConfiguration?["portalURL"] as? String else {
|
||||
completionHandler(PacketTunnelProviderError.savedProtocolConfigurationIsInvalid)
|
||||
return
|
||||
}
|
||||
@@ -36,14 +36,10 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
completionHandler(PacketTunnelProviderError.savedProtocolConfigurationIsInvalid)
|
||||
return
|
||||
}
|
||||
|
||||
Self.logger.log("portalURL = \(portalURL, privacy: .public)")
|
||||
Self.logger.log("token = \(token, privacy: .public)")
|
||||
|
||||
let adapter = Adapter(portalURLString: portalURLString, token: token, packetTunnelProvider: self)
|
||||
self.adapter = adapter
|
||||
do {
|
||||
// Once connlib is updated to take in portalURL and token, this call
|
||||
// should become adapter.start(portalURL: portalURL, token: token)
|
||||
try adapter.start { error in
|
||||
try adapter.start() { error in
|
||||
if let error {
|
||||
Self.logger.error("Error in adapter.start: \(error)")
|
||||
}
|
||||
@@ -55,24 +51,18 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||
}
|
||||
|
||||
override func stopTunnel(with _: NEProviderStopReason, completionHandler: @escaping () -> Void) {
|
||||
adapter.stop { error in
|
||||
if let error {
|
||||
Self.logger.error("Error in adapter.stop: \(error)")
|
||||
}
|
||||
adapter?.stop {
|
||||
completionHandler()
|
||||
#if os(macOS)
|
||||
// HACK: This is a filthy hack to work around Apple bug 32073323
|
||||
exit(0)
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
// HACK: This is a filthy hack to work around Apple bug 32073323 (dup'd by us as 47526107).
|
||||
// Remove it when they finally fix this upstream and the fix has been rolled out to
|
||||
// sufficient quantities of users.
|
||||
exit(0)
|
||||
#endif
|
||||
}
|
||||
|
||||
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)? = nil) {
|
||||
let query = String(data: messageData, encoding: .utf8) ?? ""
|
||||
adapter.getDisplayableResourcesIfVersionDifferentFrom(referenceVersionString: query) { displayableResources in
|
||||
adapter?.getDisplayableResourcesIfVersionDifferentFrom(referenceVersionString: query) { displayableResources in
|
||||
completionHandler?(displayableResources?.toData())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user