为 iOS 网络请求设置代理

0x00 背景

iOS 设置代理的方式常用的有两种:

  • 系统设置-->WiFi-->配置代理(HTTP代理)
  • 使用科学上网工具全局设置代理 VPN

由于并没有研读 iOS 系统网络库全部功能, 只是使用过AFNetworking 和 Alamofire 的基础功能并没有发现可以设置代理, 但是使用过 curl 请求, 知道是可以直接在请求的时候设置代理 --proxy http://x.x.x.x:7890

所以询问了强大的 ChatGPT, 才知道 iOS 是有一个 URLSessionConfiguration 中的 connectionProxyDictionary 属性, 该属性就是配置代理的字典

0x01 ChatGPT 描述

connectionProxyDictionary 是一个用于配置 NSURLConnectionNSURLSession 的代理字典。当你需要使用代理服务器连接到互联网时,你可以使用 connectionProxyDictionary 来指定代理服务器的配置选项。

该字典包含以下键值对:

HTTPEnableBOOL 类型,表示是否开启 HTTP 代理。默认为 NO
HTTPProxyNSString 类型,表示 HTTP 代理服务器的地址。
HTTPPortNSInteger 类型,表示 HTTP 代理服务器的端口号。
HTTPSEnableBOOL 类型,表示是否开启 HTTPS 代理。默认为 NO
HTTPSProxyNSString 类型,表示 HTTPS 代理服务器的地址。
HTTPSPortNSInteger 类型,表示 HTTPS 代理服务器的端口号。
FTPEnableBOOL 类型,表示是否开启 FTP 代理。默认为 NO
FTPProxyNSString 类型,表示 FTP 代理服务器的地址。
FTPPortNSInteger 类型,表示 FTP 代理服务器的端口号。
SOCKSEnableBOOL 类型,表示是否开启 SOCKS 代理。默认为 NO
SOCKSProxyNSString 类型,表示 SOCKS 代理服务器的地址。
SOCKSPortNSInteger 类型,表示 SOCKS 代理服务器的端口号。
ProxyAutoConfigEnableBOOL 类型,表示是否开启代理自动配置(PAC)。默认为 NO
ProxyAutoConfigURLStringNSString 类型,表示 PAC 配置文件的 URL。

在配置完 connectionProxyDictionary 后,你可以将其传递给 NSURLConnectionNSURLSession 对象的初始化方法中来使用代理服务器连接到互联网。

0x02 尝试

简单撸一个代码需要科学上网的代码:

    func test() {
        var request = URLRequest(url: URL(string: "https://www.google.com/search?q=hello")!, timeoutInterval: 10)
        request.httpMethod = "GET"
        
        let config = URLSessionConfiguration.default
        let session = URLSession(configuration: config)

        let task = session.dataTask(with: request) { data, response, error in
          guard let data = data else {
            print(String(describing: error))
            return
          }
          print(String(data: data, encoding: .ascii)!)
        }

        task.resume()
    }

直接进行 run 的话, 达到超时时间会有以下日志输出:

Optional(Error Domain=NSURLErrorDomain Code=-1001 "The request timed out." UserInfo={_kCFStreamErrorCodeKey=-2102, NSUnderlyingError=0x280148cc0 {Error Domain=kCFErrorDomainCFNetwork Code=-1001 "(null)" UserInfo={_kCFStreamErrorCodeKey=-2102, _kCFStreamErrorDomainKey=4}}, _NSURLErrorFailingURLSessionTaskErrorKey=LocalDataTask <09CB8002-4D12-4D91-B722-1FE53425A66B>.<1>, _NSURLErrorRelatedURLSessionTaskErrorKey=(
    "LocalDataTask <09CB8002-4D12-4D91-B722-1FE53425A66B>.<1>"
), NSLocalizedDescription=The request timed out., NSErrorFailingURLStringKey=https://www.google.com/search?q=hello, NSErrorFailingURLKey=https://www.google.com/search?q=hello, _kCFStreamErrorDomainKey=4})

根据 ChatGPT 给的提示, 尝试添加 connectionProxyDictionary 属性:

    func test() {
        var request = URLRequest(url: URL(string: "https://www.google.com/search?q=hello")!, timeoutInterval: Double.infinity)
        request.httpMethod = "GET"
        
        let config = URLSessionConfiguration.default
        config.connectionProxyDictionary = [
            "HTTPEnable": true,
            "HTTPProxy": "10.240.9.20",
            "HTTPPort": 7890
        ]
        let session = URLSession(configuration: config)

        let task = session.dataTask(with: request) { data, response, error in
          guard let data = data else {
            print(String(describing: error))
            return
          }
          print(String(data: data, encoding: .ascii)!)
        }

        task.resume()
    }

结果依旧是超时😂😂😂, 随后发现我的 url 是 https 的, 修改后的代码:

    func test() {
        var request = URLRequest(url: URL(string: "https://www.google.com/search?q=hello")!, timeoutInterval: Double.infinity)
        request.httpMethod = "GET"
        
        let config = URLSessionConfiguration.default
        config.connectionProxyDictionary = [
            "HTTPEnable": true,
            "HTTPProxy": "10.240.9.20",
            "HTTPPort": 7890,
            "HTTPSEnable": true,
            "HTTPSProxy": "10.240.9.20",
            "HTTPSPort": 7890
        ]
        let session = URLSession(configuration: config)

        let task = session.dataTask(with: request) { data, response, error in
          guard let data = data else {
            print(String(describing: error))
            return
          }
          print(String(data: data, encoding: .ascii)!)
        }

        task.resume()
    }

此时就通透了, 日志输出的是一段 html

0x03 WKWebView 代理

做普通的 iOS 端内请求已经可以做到代理了, 那 WKWebView 呢?

查询资料后得知: iOS 11 以上,苹果为 WKWebView 增加了 WKURLSchemeHandler 协议,可以为自定义的 Scheme 增加遵循 WKURLSchemeHandler 协议的处理。其中可以在 start 和 stop 的时机增加自己的处理。

由于苹果的 setURLSchemeHandler 只能对自定义的 Scheme 进行设置,所以像 httphttps 这种 Scheme,需要通过 hook 系统方法来绕过系统的限制检查

参考 iOSHttpProxyDemo 内的 HttpProxyHandler, 对其进行简单的修改得到如下代码(代码后有注意事项):

import Foundation
import WebKit
import ObjectiveC

final class HttpProxyHandler: NSObject {
    private var dataTasks: [String: URLSessionDataTask] = [:]
    private let proxyConfig: HttpProxyConfig
    
    init(proxyConfig: HttpProxyConfig) {
        self.proxyConfig = proxyConfig
    }
}

extension HttpProxyHandler: WKURLSchemeHandler {
    func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
        var request = urlSchemeTask.request
        request.addValue(MMNetworkConfig.shared().cookie ?? "", forHTTPHeaderField: "Cookie")
        let config = URLSessionConfiguration.default
        config.addProxyConfig(proxyConfig)
        let session = URLSession(configuration: config)
        let dataTask = session.dataTask(with: request) { [weak urlSchemeTask] data, response, error in
            guard let urlSchemeTask = urlSchemeTask else { return }
            if let error = error, error._code != NSURLErrorCancelled {
                urlSchemeTask.didFailWithError(error)
            } else {
                if let response = response {
                    urlSchemeTask.didReceive(response)
                }
                if let data = data {
                    urlSchemeTask.didReceive(data)
                }
                urlSchemeTask.didFinish()
            }
        }
        dataTask.resume()
        dataTasks[request.url?.absoluteString ?? ""] = dataTask
    }

    func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
        dataTasks[urlSchemeTask.request.url?.absoluteString ?? ""]?.cancel()
    }
}

private var hookWKWebView: () = {
    guard let origin = class_getClassMethod(WKWebView.self, #selector(WKWebView.handlesURLScheme(_:))),
          let hook = class_getClassMethod(WKWebView.self, #selector(WKWebView._handlesURLScheme(_:))) else {
        return
    }
    method_exchangeImplementations(origin, hook)
}()

fileprivate extension WKWebView {
    @objc static func _handlesURLScheme(_ urlScheme: String) -> Bool {
        if httpSchemes.contains(urlScheme) {
            return false
        }
        return Self.handlesURLScheme(urlScheme)
    }
}

extension WKWebViewConfiguration {
    func addProxyConfig(_ config: HttpProxyConfig) {
        let handler = HttpProxyHandler(proxyConfig: config)
        _ = hookWKWebView
        httpSchemes.forEach {
            setURLSchemeHandler(handler, forURLScheme: $0)
        }
    }
}

需要注意的是, Cookie 会在代理的时候丢失所以需要再这个代码中重新设置一下 Cookie信息

0x04 应用在工程

由于参考 iOSHttpProxyDemo , 发现使用 URLProtocol 做全局拦截, 然后统一修改代理, 是个很好的方案, 可是现实情况并不好, 这种拦截只支持 URLLoadingSystem, 也就是 URLSession.shared

对于我们的工程使用的是 AFNetworking, 它的 URLSession 创建使用的是 let session = URLSession(configuration: config), 就需要对所有的 config 赋值 connectionProxyDictionary 属性

本来想要用 runtime 处理, 后面还是觉得直接写个赋值的方法, 至少看的清晰, 如果真的需要对所有的网络请求都做拦截, 还是需要尝试使用 runtime 来解决吧, 没有再进行深入了解了, 已经满足研究背景了

0x05 结语

我的代理是本地的, 通过 clash 做的局域网代理, 上面的请求就是通过代码代理到我电脑本地, 然后通过电脑本地的 clash 工具科学上网的

connectionProxyDictionary 的赋值不要偷懒, http/https 做代理的话记得同时写上, 上面就是因为偷懒导致尝试了几次都是超时🤣

按照 ChatGPT 的描述, 还可以使用 socks 代理, 也可以直接使用 PAC 文件代理, 玩法也挺多的, 这样写的代码只针对自己开发过程中的 app 提供抓包能力, 不需要全局设置了

参考链接:

iOS 设置代理(Proxy)方案总结
iOSHttpProxyDemo

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,723评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,485评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,998评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,323评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,355评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,079评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,389评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,019评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,519评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,971评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,100评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,738评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,293评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,289评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,517评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,547评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,834评论 2 345

推荐阅读更多精彩内容