Path: blob/main/SignalServiceKit/Network/API/NetworkManager.swift
1 views
//
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public import LibSignalClient
public protocol NetworkManagerProtocol {
func asyncRequestImpl(
_ request: TSRequest,
retryPolicy: NetworkManager.RetryPolicy,
) async throws -> HTTPResponse
}
extension NetworkManagerProtocol {
public func asyncRequest(
_ request: TSRequest,
retryPolicy: NetworkManager.RetryPolicy = .dont,
) async throws -> HTTPResponse {
return try await asyncRequestImpl(request, retryPolicy: retryPolicy)
}
}
// A class used for making HTTP requests against the main service.
public class NetworkManager: NetworkManagerProtocol {
private let appReadiness: AppReadiness
private let reachabilityDidChangeObserver: Task<Void, Never>?
private var chatConnectionManager: ChatConnectionManager {
// TODO: Fix circular dependencies.
DependenciesBridge.shared.chatConnectionManager
}
public let libsignalNet: Net?
public init(appReadiness: AppReadiness, libsignalNet: Net?) {
self.appReadiness = appReadiness
self.libsignalNet = libsignalNet
if let libsignalNet {
self.reachabilityDidChangeObserver = Task {
for await _ in NotificationCenter.default.notifications(named: SSKReachability.owsReachabilityDidChange) {
do {
Self.resetLibsignalNetProxySettings(libsignalNet, appReadiness: appReadiness)
try libsignalNet.networkDidChange()
} catch {
owsFailDebug("error notify libsignal of network change: \(error)")
}
}
}
self.resetLibsignalNetProxySettings()
Logger.info("Initialized libsignal Net and reset proxy settings (signalProxyEnabled: \(SignalProxy.isEnabled)).")
appReadiness.runNowOrWhenAppDidBecomeReadyAsync {
// We did this once already, but doing it properly depends on RemoteConfig.
self.resetLibsignalNetProxySettings()
}
} else {
self.reachabilityDidChangeObserver = nil
}
SwiftSingletons.register(self)
}
deinit {
if let reachabilityDidChangeObserver {
reachabilityDidChangeObserver.cancel()
}
}
// MARK: -
func resetLibsignalNetProxySettings() {
guard let libsignalNet else {
// In tests without a libsignal Net instance, no action is needed.
return
}
Self.resetLibsignalNetProxySettings(libsignalNet, appReadiness: appReadiness)
}
private static func resetLibsignalNetProxySettings(_ libsignalNet: Net, appReadiness: AppReadiness) {
guard !SignalProxy.isEnabled else {
// Don't stomp on in-app proxy settings, which are managed by SignalProxy.
return
}
if let systemProxy = ProxyConfig.fromCFNetwork() {
Logger.info("System '\(systemProxy.scheme)' proxy detected")
do {
try libsignalNet.setProxy(scheme: systemProxy.scheme, host: systemProxy.host, port: systemProxy.port, username: systemProxy.username, password: systemProxy.password)
return
} catch {
Logger.error("invalid proxy: \(error)")
// When setProxy(...) fails, it refuses to connect in case your proxy was load-bearing.
// That makes sense for in-app settings, but less so for system-level proxies, given that we are already ignoring system-level proxies we don't understand.
// Fall through to the reset call.
}
}
// This may be clearing a system proxy, or a previously set in-app proxy that is no longer in use.
libsignalNet.clearProxy()
}
// MARK: -
public struct RetryPolicy {
public struct RetryOn: OptionSet {
public let rawValue: Int
public init(rawValue: Int) {
self.rawValue = rawValue
}
static let fiveXXResponse: RetryOn = .init(rawValue: 1 << 0)
static let networkFailureOrTimeout: RetryOn = .init(rawValue: 1 << 1)
}
public let retryOn: [RetryOn]
public let maxAttempts: Int
public init(
retryOn: [RetryOn],
maxAttempts: Int,
) {
self.retryOn = retryOn
self.maxAttempts = maxAttempts
}
public static let dont: RetryPolicy = RetryPolicy(
retryOn: [],
maxAttempts: 1,
)
public static let hopefullyRecoverable: RetryPolicy = RetryPolicy(
retryOn: [.fiveXXResponse, .networkFailureOrTimeout],
maxAttempts: 3,
)
}
public func asyncRequestImpl(
_ request: TSRequest,
retryPolicy: RetryPolicy,
) async throws -> HTTPResponse {
return try await Retry.performWithBackoff(
maxAttempts: retryPolicy.maxAttempts,
isRetryable: { error -> Bool in
if
error.isNetworkFailureOrTimeout,
retryPolicy.retryOn.contains(.networkFailureOrTimeout)
{
return true
} else if
error.is5xxServiceResponse,
retryPolicy.retryOn.contains(.fiveXXResponse)
{
return true
}
return false
},
block: { try await _asyncRequest(request) },
)
}
private func _asyncRequest(_ request: TSRequest) async throws -> HTTPResponse {
do {
return try await chatConnectionManager.makeRequest(request)
} catch {
if case OWSHTTPError.wrappedFailure(URLError.cancelled) = error {
try Task.checkCancellation()
}
throw error
}
}
}
// MARK: -
private struct ProxyConfig {
var scheme: String
var host: String
var port: UInt16?
var username: String?
var password: String?
static func fromCFNetwork() -> Self? {
let chatURL = URL(string: TSConstants.mainServiceURL)!
guard let settings = CFNetworkCopySystemProxySettings()?.takeRetainedValue() else {
return nil
}
let proxies = CFNetworkCopyProxiesForURL(chatURL as CFURL, settings).takeRetainedValue() as! [NSDictionary]
for proxyConfig in proxies {
switch proxyConfig[kCFProxyTypeKey] as! NSObject? {
case kCFProxyTypeNone:
// CFNetworkCopyProxiesForURL returns a list of proxies to try in order,
// and that can include "try a direct connection".
// But libsignal only supports one global proxy setting,
// so if we get told to try a direct connection, that's what we'll do.
return nil
case kCFProxyTypeHTTP:
return ProxyConfig(
scheme: "http",
host: proxyConfig[kCFProxyHostNameKey] as! String,
port: proxyConfig[kCFProxyPortNumberKey] as! UInt16?,
username: proxyConfig[kCFProxyUsernameKey] as! String?,
password: proxyConfig[kCFProxyPasswordKey] as! String?,
)
case kCFProxyTypeHTTPS:
// This seems to mean "HTTP proxy for HTTPS connections" rather than "proxy that itself uses TLS".
// Leave room for the latter interpretation if the port number is traditionally HTTPS.
let port = proxyConfig[kCFProxyPortNumberKey] as! UInt16?
return ProxyConfig(
scheme: (port == 443 || port == 8443) ? "https" : "http",
host: proxyConfig[kCFProxyHostNameKey] as! String,
port: port,
username: proxyConfig[kCFProxyUsernameKey] as! String?,
password: proxyConfig[kCFProxyPasswordKey] as! String?,
)
case kCFProxyTypeSOCKS:
// iOS doesn't distinguish between SOCKS4 and SOCKS5. Defer to libsignal's default.
return ProxyConfig(
scheme: "socks",
host: proxyConfig[kCFProxyHostNameKey] as! String,
port: proxyConfig[kCFProxyPortNumberKey] as! UInt16?,
username: proxyConfig[kCFProxyUsernameKey] as! String?,
password: proxyConfig[kCFProxyPasswordKey] as! String?,
)
case kCFProxyTypeAutoConfigurationJavaScript, kCFProxyTypeAutoConfigurationURL:
// CFNetwork provides ways to execute these, but they're not something that can be done synchronously.
// PAC files are rare, though; we can come back to this if it turns out to be used in practice.
Logger.warn("Skipping PAC-based proxy configuration")
continue
case kCFProxyTypeFTP:
// Not relevant for an HTTPS request (honestly, it should never be returned in the first place)
continue
case let unknownProxyType?:
Logger.warn("Skipping unknown proxy type '\(unknownProxyType)'")
continue
case nil:
Logger.warn("Skipping proxy with nil kCFProxyType; this is probably an Apple bug!")
continue
}
}
return nil
}
}
// MARK: -
#if TESTABLE_BUILD
public class OWSFakeNetworkManager: NetworkManager {
override public func asyncRequestImpl(
_ request: TSRequest,
retryPolicy: RetryPolicy,
) async throws -> HTTPResponse {
Logger.info("Ignoring request: \(request)")
// Never resolve.
return try await withUnsafeThrowingContinuation { (_ continuation: UnsafeContinuation<HTTPResponse, any Error>) -> Void in }
}
}
class MockNetworkManager: NetworkManagerProtocol {
var asyncRequestHandlers = [(TSRequest, NetworkManager.RetryPolicy) async throws -> HTTPResponse]()
func asyncRequestImpl(
_ request: TSRequest,
retryPolicy: NetworkManager.RetryPolicy,
) async throws -> HTTPResponse {
return try await asyncRequestHandlers.removeFirst()(request, retryPolicy)
}
}
#endif