背景

公司内一般对调试环境会分为线上预发布QA测试三种,而在切换环境的时候大多还是通过网页或桌面应用,当在调试客户端应用时这样的方式就比较麻烦了,所以我们组社会光哥将整套环境切换和请求转发的原理理清晰之后实现了 Android 版的 Debug 环境切换工具,而我则根据其原理实现了 iOS 版。客户端接入该工具之后,只需在应用内即可切换环境和指定调试目录(每个目录独立部署代码)。

原理

环境切换

先简单介绍下环境切换原理(From 光哥)

不同环境对应不同的内网 IP 机器,它是一台反向代理服务器,将网络请求代理到对应环境的云端机器上处理,处理完成之后通过反向代理服务器再返回给请求方。

环境设置实际是向对应的地址发送一个网络请求,携带上自己的 WiFi IP(如果开启 Charles 等代理工具后,则是电脑的 WiFi IP),以及云端机器的 IP 和 Port进行绑定,例如:

1
2
3
4
5
6
7
8
NSString *URLString = [@"http://" stringByAppendingFormat:@"%@:12345/index", [self IPByEnv:self.env]];
NSURL *URL = [NSURL URLWithString:URLString];
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:URL];
request.HTTPMethod = @"POST";
[request setValue:self.proxyIP forHTTPHeaderField:@"X-Forward-For"];

GCCarmenModel *carmenModel = [self carmenConfig];
[request setHTTPBody:[[carmenModel toPostBodyString] dataUsingEncoding:NSUTF8StringEncoding]];

请求转发

我们需要使用 NSURLProtocol 拦截客户端发出的所有请求,我们继承 NSURLProtocol 后需要在 AppDelegate 中注册其子类,而 NSURLProtocol 的执行顺序和注册顺序是相反的,所以一般情况我们把它放在最后注册。

1
2
3
#ifdef DEBUG
[NSURLProtocol registerClass:NSClassFromString(@"ZanDNSProtocol")];
#endif

目前大多数网络库我们用的都是 AFNetworking ,是基于 NSURLSession 的网络请求,所以在构造 NSURLSessionConfiguration 之后,需要指定它的 protocolClasses ,但为了不破坏 AF 源码,我们可以 Swizzling 系统 NSURLSession 的 API sessionWithConfiguration:delegate:delegateQueue: ,做到对网络库无侵入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@implementation NSURLSession (DNSSwizzling)

+ (void)load {
#ifdef DEBUG
dns_classMethodSwizzle(self, @selector(sessionWithConfiguration:delegate:delegateQueue:), @selector(zan_sessionWithConfiguration:delegate:delegateQueue:));
#endif
}

+ (NSURLSession *)zan_sessionWithConfiguration:(NSURLSessionConfiguration *)configuration delegate:(nullable id <NSURLSessionDelegate>)delegate delegateQueue:(nullable NSOperationQueue *)queue {
configuration.protocolClasses = @[NSClassFromString(@"ZanDNSProtocol")];
return [self zan_sessionWithConfiguration:configuration delegate:delegate delegateQueue:queue];
}

@end

接着在 ZanDNSProtocol ( NSURLProtocol 子类)中实现父类方法,一共四步:

第一步判断是否需要拦截,本地我们维护了一份 DNS 列表,我们只拦截在列表中的域名请求:

1
2
3
4
5
6
7
8
9
10
11
+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
// ...

NSString *host = request.URL.host;
NSDictionary *DNSConfig = [ZanDNSManager sharedInstance].DNSConfig;
if ([[DNSConfig allKeys] containsObject:host]) {
return YES;
}

return NO;
}

第二步我们修改拦截到的请求,我们解析出当前环境下 Host 对应列表中的 IP 地址,然后在 URL 中用 IP 替换掉 Host,并在请求头中加上 Host,目的是为了防止开启代理之后,代理工具做 DNS 解析,解析到线上的 IP 地址,这样转发的请求还是往线上发:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request {
// ...

NSMutableURLRequest *forwardRequest = [request mutableCopy];

NSString *URLString = forwardRequest.URL.absoluteString;
NSString *originalHost = forwardRequest.URL.host;
NSDictionary *DNSConfig = [ZanDNSManager sharedInstance].DNSConfig;
NSString *IP = DNSConfig[originalHost];
URLString = [URLString stringByReplacingOccurrencesOfString:originalHost withString:IP];
forwardRequest.URL = [NSURL URLWithString:URLString];
[forwardRequest setValue:originalHost forHTTPHeaderField:@"Host"];
[forwardRequest setValue:[ZanDNSManager sharedInstance].proxyIP forHTTPHeaderField:@"X-Forward-For"];

return forwardRequest;
}

第三步就是发送请求,如果我们开启了代理,可以配置 NSURLSessionConfigurationconnectionProxyDictionary ,好处是我们可以不用再下载 Charles 证书到手机,可以直接代理到 Charles 来抓包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- (void)startLoading {
[NSURLProtocol setProperty:@(YES) forKey:propertyKey inRequest:[self.request mutableCopy]];
NSURLSessionConfiguration *configuration;
if ([ZanDNSManager sharedInstance].openProxy) {
NSDictionary *proxyDic = @{@"HTTPEnable": @(YES),
(NSString *)kCFStreamPropertyHTTPProxyHost: [ZanDNSManager sharedInstance].proxyIP,
(NSString *)kCFStreamPropertyHTTPProxyPort: @([ZanDNSManager sharedInstance].proxyPort),
@"HTTPSEnable": @(YES),
(NSString *)kCFStreamPropertyHTTPSProxyHost: [ZanDNSManager sharedInstance].proxyIP,
(NSString *)kCFStreamPropertyHTTPSProxyPort: @([ZanDNSManager sharedInstance].proxyPort)};
configuration = [NSURLSessionConfiguration ephemeralSessionConfiguration];
configuration.connectionProxyDictionary = proxyDic;
} else {
configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
}
self.session = [NSURLSession zan_sessionWithConfiguration:configuration delegate:self delegateQueue:nil];
NSURLSessionDataTask *task = [self.session dataTaskWithRequest:self.request];
[task resume];
}

第四步是我们对请求过程中信任所有证书

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge
completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential *_Nullable))completionHandler {
if (!challenge) {
return;
}

NSURLSessionAuthChallengeDisposition disposition = NSURLSessionAuthChallengePerformDefaultHandling;
NSString *host = self.request.allHTTPHeaderFields[@"host"];
if (![challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
completionHandler(disposition, nil);
return;
}

NSURLCredential *credential = nil;
if ([self evaluateServerTrust:challenge.protectionSpace.serverTrust forDomain:host]) {
disposition = NSURLSessionAuthChallengeUseCredential;
credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
}
completionHandler(disposition, credential);
}

至此我们完成了一整套环境切换和请求拦截转发的方案,但是该工具还未能做到拦截转发 WKWebView 的请求,欢迎大家一起来交流交流~

2018年9月24日更新:

看到腾讯在使用 WK 时遇到的坑里面提到对 WK 的网络拦截,简单的说可以通过注册 http(s) scheme 来拦截到 WK 的网络请求,但是由于 WK 是在独立进程中进行网络通信的,使用 NSURLProtocol 将网络请求拦截到 App 进程时,出于性能原因,IPC 会将 HTTPBody 和 HTTPStream 丢弃,所以 POST 请求的 Body 内容会丢失,不能做到完美的拦截。