單IP多HTTPS域名場景下的解決方案__最佳實踐_HTTPDNS-阿裏雲
1.背景說明
在搭建支持HTTPS的前端代理服務器時候,通常會遇到讓人頭痛的證書問題。根據HTTPS的工作原理,瀏覽器在訪問一個HTTPS站點時,先與服務器建立SSL連接,建立連接的第一步就是請求服務器的證書。而服務器在發送證書的時候,是不知道瀏覽器訪問的是哪個域名的,所以不能根據不同域名發送不同的證書。
SNI(Server Name Indication)是為了解決一個服務器使用多個域名和證書的SSL/TLS擴展。它的工作原理如下:
- 在連接到服務器建立SSL鏈接之前先發送要訪問站點的域名(Hostname)。
- 服務器根據這個域名返回一個合適的證書。
目前,大多數操作係統和瀏覽器都已經很好地支持SNI擴展,OpenSSL 0.9.8也已經內置這一功能。
上述過程中,當客戶端使用HTTPDNS解析域名時,請求URL中的host會被替換成HTTPDNS解析出來的IP,導致服務器獲取到的域名為解析後的IP,無法找到匹配的證書,隻能返回默認的證書或者不返回,所以會出現SSL/TLS握手不成功的錯誤。
比如當你需要通過HTTPS訪問CDN資源時,CDN的站點往往服務了很多的域名,所以需要通過SNI指定具體的域名證書進行通信。
2 解決方案
針對以上問題,可以采用如下方案解決:hook HTTPS訪問前SSL連接過程,根據網絡請求頭部域中的HOST信息,設置SSL連接PeerHost的值,再根據服務器返回的證書執行驗證過程。
下麵分別列出Android和iOS平台的示例代碼。
2.1 Android示例
在HTTPDNS Android Demo Github中針對HttpsURLConnection接口,提供了在SNI業務場景下使用HTTPDNS的示例代碼。
定製SSLSocketFactory,在createSocket時替換為HTTPDNS的IP,並進行SNI/HostNameVerify配置。
class TlsSniSocketFactory extends SSLSocketFactory {private final String TAG = TlsSniSocketFactory.class.getSimpleName();HostnameVerifier hostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier();private HttpsURLConnection conn;public TlsSniSocketFactory(HttpsURLConnection conn) {this.conn = conn;}@Overridepublic Socket createSocket() throws IOException {return null;}@Overridepublic Socket createSocket(String host, int port) throws IOException, UnknownHostException {return null;}@Overridepublic Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException, UnknownHostException {return null;}@Overridepublic Socket createSocket(InetAddress host, int port) throws IOException {return null;}@Overridepublic Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException {return null;}// TLS layer@Overridepublic String[] getDefaultCipherSuites() {return new String[0];}@Overridepublic String[] getSupportedCipherSuites() {return new String[0];}@Overridepublic Socket createSocket(Socket plainSocket, String host, int port, boolean autoClose) throws IOException {String peerHost = this.conn.getRequestProperty("Host");if (peerHost == null)peerHost = host;Log.i(TAG, "customized createSocket. host: " + peerHost);InetAddress address = plainSocket.getInetAddress();if (autoClose) {// we don't need the plainSocketplainSocket.close();}// create and connect SSL socket, but don't do hostname/certificate verification yetSSLCertificateSocketFactory sslSocketFactory = (SSLCertificateSocketFactory) SSLCertificateSocketFactory.getDefault(0);SSLSocket ssl = (SSLSocket) sslSocketFactory.createSocket(address, port);// enable TLSv1.1/1.2 if availablessl.setEnabledProtocols(ssl.getSupportedProtocols());// set up SNI before the handshakeif (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {Log.i(TAG, "Setting SNI hostname");sslSocketFactory.setHostname(ssl, peerHost);} else {Log.d(TAG, "No documented SNI support on Android <4.2, trying with reflection");try {java.lang.reflect.Method setHostnameMethod = ssl.getClass().getMethod("setHostname", String.class);setHostnameMethod.invoke(ssl, peerHost);} catch (Exception e) {Log.w(TAG, "SNI not useable", e);}}// verify hostname and certificateSSLSession session = ssl.getSession();if (!hostnameVerifier.verify(peerHost, session))throw new SSLPeerUnverifiedException("Cannot verify hostname: " + peerHost);Log.i(TAG, "Established " + session.getProtocol() + " connection with " + session.getPeerHost() +" using " + session.getCipherSuite());return ssl;}}
對於需要設置SNI的站點,通常需要重定向請求,示例中也給出了重定向請求的處理方法。
public void recursiveRequest(String path, String reffer) {URL url = null;try {url = new URL(path);conn = (HttpsURLConnection) url.openConnection();// 同步接口獲取IPString ip = httpdns.getIpByHostAsync(url.getHost());if (ip != null) {// 通過HTTPDNS獲取IP成功,進行URL替換和HOST頭設置Log.d(TAG, "Get IP: " + ip + " for host: " + url.getHost() + " from HTTPDNS successfully!");String newUrl = path.replaceFirst(url.getHost(), ip);conn = (HttpsURLConnection) new URL(newUrl).openConnection();// 設置HTTP請求頭Host域conn.setRequestProperty("Host", url.getHost());}conn.setConnectTimeout(30000);conn.setReadTimeout(30000);conn.setInstanceFollowRedirects(false);TlsSniSocketFactory sslSocketFactory = new TlsSniSocketFactory(conn);conn.setSSLSocketFactory(sslSocketFactory);conn.setHostnameVerifier(new HostnameVerifier() {/** 關於這個接口的說明,官方有文檔描述:* This is an extended verification option that implementers can provide.* It is to be used during a handshake if the URL's hostname does not match the* peer's identification hostname.** 使用HTTPDNS後URL裏設置的hostname不是遠程的主機名(如:m.taobao.com),與證書頒發的域不匹配,* Android HttpsURLConnection提供了回調接口讓用戶來處理這種定製化場景。* 在確認HTTPDNS返回的源站IP與Session攜帶的IP信息一致後,您可以在回調方法中將待驗證域名替換為原來的真實域名進行驗證。**/@Overridepublic boolean verify(String hostname, SSLSession session) {String host = conn.getRequestProperty("Host");if (null == host) {host = conn.getURL().getHost();}return HttpsURLConnection.getDefaultHostnameVerifier().verify(host, session);}});int code = conn.getResponseCode();// Network blockif (needRedirect(code)) {//臨時重定向和永久重定向location的大小寫有區分String location = conn.getHeaderField("Location");if (location == null) {location = conn.getHeaderField("location");}if (!(location.startsWith("https://") || location.startsWith("https://"))) {//某些時候會省略host,隻返回後麵的path,所以需要補全urlURL originalUrl = new URL(path);location = originalUrl.getProtocol() + "://"+ originalUrl.getHost() + location;}recursiveRequest(location, path);} else {// redirect finish.DataInputStream dis = new DataInputStream(conn.getInputStream());int len;byte[] buff = new byte[4096];StringBuilder response = new StringBuilder();while ((len = dis.read(buff)) != -1) {response.append(new String(buff, 0, len));}Log.d(TAG, "Response: " + response.toString());}} catch (MalformedURLException e) {Log.w(TAG, "recursiveRequest MalformedURLException");} catch (IOException e) {Log.w(TAG, "recursiveRequest IOException");} catch (Exception e) {Log.w(TAG, "unknow exception");} finally {if (conn != null) {conn.disconnect();}}}private boolean needRedirect(int code) {return code >= 300 && code < 400;}
2.2 iOS示例
由於iOS係統並沒有提供設置SNI的上層接口(NSURLConnection/NSURLSession),因此在HTTPDNS iOS Demo Github中,我們使用NSURLProtocol攔截網絡請求,然後使用CFHTTPMessageRef創建NSInputStream實例進行Socket通信,並設置其kCFStreamSSLPeerName的值。
需要注意的是,使用NSURLProtocol攔截NSURLSession發起的POST請求時,HTTPBody為空。解決方案有兩個:
- 使用NSURLConnection發POST請求。
- 先將HTTPBody放入HTTP Header field中,然後在NSURLProtocol中再取出來,Demo中主要演示該方案。
部分代碼如下:
在網絡請求前注冊NSURLProtocol子類,在示例的SNIViewController.m中。
// 注冊攔截請求的NSURLProtocol[NSURLProtocol registerClass:[CFHttpMessageURLProtocol class]];// 初始化HTTPDNSHttpDnsService *httpdns = [HttpDnsService sharedInstance];// 需要設置SNI的URLNSString *originalUrl = @"your url";NSURL *url = [NSURL URLWithString:originalUrl];self.request = [[NSMutableURLRequest alloc] initWithURL:url];NSString *ip = [httpdns getIpByHostAsync:url.host];// 通過HTTPDNS獲取IP成功,進行URL替換和HOST頭設置if (ip) {NSLog(@"Get IP from HTTPDNS Successfully!");NSRange hostFirstRange = [originalUrl rangeOfString:url.host];if (NSNotFound != hostFirstRange.location) {NSString *newUrl = [originalUrl stringByReplacingCharactersInRange:hostFirstRange withString:ip];self.request.URL = [NSURL URLWithString:newUrl];[_request setValue:url.host forHTTPHeaderField:@"host"];}}// NSURLConnection例子[[NSURLConnection alloc] initWithRequest:_request delegate:self startImmediately:YES];// NSURLSession例子NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];NSArray *protocolArray = @[ [CFHttpMessageURLProtocol class] ];configuration.protocolClasses = protocolArray;NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:[NSOperationQueue mainQueue]];NSURLSessionTask *task = [session dataTaskWithRequest:_request];[task resume];// 注*:使用NSURLProtocol攔截NSURLSession發起的POST請求時,HTTPBody為空。// 解決方案有兩個:1. 使用NSURLConnection發POST請求。// 2. 先將HTTPBody放入HTTP Header field中,然後在NSURLProtocol中再取出來。// 下麵主要演示第二種解決方案// NSString *postStr = [NSString stringWithFormat:@"param1=%@¶m2=%@", @"val1", @"val2"];// [_request addValue:postStr forHTTPHeaderField:@"originalBody"];// _request.HTTPMethod = @"POST";// NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];// NSArray *protocolArray = @[ [CFHttpMessageURLProtocol class] ];// configuration.protocolClasses = protocolArray;// NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:[NSOperationQueue mainQueue]];// NSURLSessionTask *task = [session dataTaskWithRequest:_request];// [task resume];
在NSURLProtocol子類中攔截網絡請求,在示例的CFHttpMessageURLProtocol.m中。
+ (BOOL)canInitWithRequest:(NSURLRequest *)request {/* 防止無限循環,因為一個請求在被攔截處理過程中,也會發起一個請求,這樣又會走到這裏,如果不進行處理,就會造成無限循環 */if ([NSURLProtocol propertyForKey:protocolKey inRequest:request]) {return NO;}NSString *url = request.URL.absoluteString;// 如果url以https開頭,則進行攔截處理,否則不處理if ([url hasPrefix:@"https"]) {return YES;}return NO;}
使用CFHTTPMessageRef創建NSInputStream,並設置kCFStreamSSLPeerName,重新發起請求。
/*** 開始加載,在該方法中,加載一個請求*/- (void)startLoading {NSMutableURLRequest *request = [self.request mutableCopy];// 表示該請求已經被處理,防止無限循環[NSURLProtocol setProperty:@(YES) forKey:protocolKey inRequest:request];curRequest = request;[self startRequest];}/*** 取消請求*/- (void)stopLoading {if (inputStream.streamStatus == NSStreamStatusOpen) {[inputStream removeFromRunLoop:curRunLoop forMode:NSRunLoopCommonModes];[inputStream setDelegate:nil];[inputStream close];}[self.client URLProtocol:self didFailWithError:[[NSError alloc] initWithDomain:@"stop loading" code:-1 userInfo:nil]];}/*** 使用CFHTTPMessage轉發請求*/- (void)startRequest {// 原請求的header信息NSDictionary *headFields = curRequest.allHTTPHeaderFields;// 添加http post請求所附帶的數據CFStringRef requestBody = CFSTR("");CFDataRef bodyData = CFStringCreateExternalRepresentation(kCFAllocatorDefault, requestBody, kCFStringEncodingUTF8, 0);if (curRequest.HTTPBody) {bodyData = (__bridge_retained CFDataRef) curRequest.HTTPBody;} else if (headFields[@"originalBody"]) {// 使用NSURLSession發POST請求時,將原始HTTPBody從header中取出bodyData = (__bridge_retained CFDataRef) [headFields[@"originalBody"] dataUsingEncoding:NSUTF8StringEncoding];}CFStringRef url = (__bridge CFStringRef) [curRequest.URL absoluteString];CFURLRef requestURL = CFURLCreateWithString(kCFAllocatorDefault, url, NULL);// 原請求所使用的方法,GET或POSTCFStringRef requestMethod = (__bridge_retained CFStringRef) curRequest.HTTPMethod;// 根據請求的url、方法、版本創建CFHTTPMessageRef對象CFHTTPMessageRef cfrequest = CFHTTPMessageCreateRequest(kCFAllocatorDefault, requestMethod, requestURL, kCFHTTPVersion1_1);CFHTTPMessageSetBody(cfrequest, bodyData);// copy原請求的header信息for (NSString *header in headFields) {if (![header isEqualToString:@"originalBody"]) {// 不包含POST請求時存放在header的body信息CFStringRef requestHeader = (__bridge CFStringRef) header;CFStringRef requestHeaderValue = (__bridge CFStringRef) [headFields valueForKey:header];CFHTTPMessageSetHeaderFieldValue(cfrequest, requestHeader, requestHeaderValue);}}// 創建CFHTTPMessage對象的輸入流CFReadStreamRef readStream = CFReadStreamCreateForHTTPRequest(kCFAllocatorDefault, cfrequest);inputStream = (__bridge_transfer NSInputStream *) readStream;// 設置SNI host信息,關鍵步驟NSString *host = [curRequest.allHTTPHeaderFields objectForKey:@"host"];if (!host) {host = curRequest.URL.host;}[inputStream setProperty:NSStreamSocketSecurityLevelNegotiatedSSL forKey:NSStreamSocketSecurityLevelKey];NSDictionary *sslProperties = [[NSDictionary alloc] initWithObjectsAndKeys:host, (__bridge id) kCFStreamSSLPeerName,nil];[inputStream setProperty:sslProperties forKey:(__bridge_transfer NSString *) kCFStreamPropertySSLSettings];[inputStream setDelegate:self];if (!curRunLoop)// 保存當前線程的runloop,這對於重定向的請求很關鍵curRunLoop = [NSRunLoop currentRunLoop];// 將請求放入當前runloop的事件隊列[inputStream scheduleInRunLoop:curRunLoop forMode:NSRunLoopCommonModes];[inputStream open];CFRelease(cfrequest);CFRelease(requestURL);CFRelease(url);cfrequest = NULL;CFRelease(bodyData);CFRelease(requestBody);CFRelease(requestMethod);}
在NSStream的回調函數中,根據不同的eventCode通知原請求。此處eventCode主要有四種:
- NSStreamEventOpenCompleted:連接已打開。
- NSStreamEventHasBytesAvailable:響應頭部已下載完整,body中字節可讀。
- NSStreamEventErrorOccurred:連接發生錯誤。
- NSStreamEventEndEncountered:連接結束。
- (void)stream:(NSStream *)aStream handleEvent:(NSStreamEvent)eventCode {if (eventCode == NSStreamEventHasBytesAvailable) {CFReadStreamRef readStream = (__bridge_retained CFReadStreamRef) aStream;CFHTTPMessageRef message = (CFHTTPMessageRef) CFReadStreamCopyProperty(readStream, kCFStreamPropertyHTTPResponseHeader);if (CFHTTPMessageIsHeaderComplete(message)) {// 以防response的header信息不完整UInt8 buffer[16 * 1024];UInt8 *buf = NULL;unsigned long length = 0;NSInputStream *inputstream = (NSInputStream *) aStream;NSNumber *alreadyAdded = objc_getAssociatedObject(aStream, kAnchorAlreadyAdded);if (!alreadyAdded || ![alreadyAdded boolValue]) {objc_setAssociatedObject(aStream, kAnchorAlreadyAdded, [NSNumber numberWithBool:YES], OBJC_ASSOCIATION_COPY);// 通知client已收到response,隻通知一次NSDictionary *headDict = (__bridge NSDictionary *) (CFHTTPMessageCopyAllHeaderFields(message));CFStringRef httpVersion = CFHTTPMessageCopyVersion(message);// 獲取響應頭部的狀態碼CFIndex myErrCode = CFHTTPMessageGetResponseStatusCode(message);NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:curRequest.URL statusCode:myErrCode HTTPVersion:(__bridge NSString *) httpVersion headerFields:headDict];[self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];// 驗證證書SecTrustRef trust = (__bridge SecTrustRef) [aStream propertyForKey:(__bridge NSString *) kCFStreamPropertySSLPeerTrust];SecTrustResultType res = kSecTrustResultInvalid;NSMutableArray *policies = [NSMutableArray array];NSString *domain = [[curRequest allHTTPHeaderFields] valueForKey:@"host"];if (domain) {[policies addObject:(__bridge_transfer id) SecPolicyCreateSSL(true, (__bridge CFStringRef) domain)];} else {[policies addObject:(__bridge_transfer id) SecPolicyCreateBasicX509()];}/** 綁定校驗策略到服務端的證書上*/SecTrustSetPolicies(trust, (__bridge CFArrayRef) policies);if (SecTrustEvaluate(trust, &res) != errSecSuccess) {[aStream removeFromRunLoop:curRunLoop forMode:NSRunLoopCommonModes];[aStream setDelegate:nil];[aStream close];[self.client URLProtocol:self didFailWithError:[[NSError alloc] initWithDomain:@"can not evaluate the server trust" code:-1 userInfo:nil]];}if (res != kSecTrustResultProceed && res != kSecTrustResultUnspecified) {/* 證書驗證不通過,關閉input stream */[aStream removeFromRunLoop:curRunLoop forMode:NSRunLoopCommonModes];[aStream setDelegate:nil];[aStream close];[self.client URLProtocol:self didFailWithError:[[NSError alloc] initWithDomain:@"fail to evaluate the server trust" code:-1 userInfo:nil]];} else {// 證書通過,返回數據if (![inputstream getBuffer:&buf length:&length]) {NSInteger amount = [inputstream read:buffer maxLength:sizeof(buffer)];buf = buffer;length = amount;}NSData *data = [[NSData alloc] initWithBytes:buf length:length];[self.client URLProtocol:self didLoadData:data];}} else {// 證書已驗證過,返回數據if (![inputstream getBuffer:&buf length:&length]) {NSInteger amount = [inputstream read:buffer maxLength:sizeof(buffer)];buf = buffer;length = amount;}NSData *data = [[NSData alloc] initWithBytes:buf length:length];[self.client URLProtocol:self didLoadData:data];}}} else if (eventCode == NSStreamEventErrorOccurred) {[aStream removeFromRunLoop:curRunLoop forMode:NSRunLoopCommonModes];[aStream setDelegate:nil];[aStream close];// 通知client發生錯誤了[self.client URLProtocol:self didFailWithError:[aStream streamError]];} else if (eventCode == NSStreamEventEndEncountered) {[self handleResponse];}}- (void)handleResponse {// 獲取響應頭部信息CFReadStreamRef readStream = (__bridge_retained CFReadStreamRef) inputStream;CFHTTPMessageRef message = (CFHTTPMessageRef) CFReadStreamCopyProperty(readStream, kCFStreamPropertyHTTPResponseHeader);if (CFHTTPMessageIsHeaderComplete(message)) {// 確保response頭部信息完整NSDictionary *headDict = (__bridge NSDictionary *) (CFHTTPMessageCopyAllHeaderFields(message));// 獲取響應頭部的狀態碼CFIndex myErrCode = CFHTTPMessageGetResponseStatusCode(message);// 把當前請求關閉[inputStream removeFromRunLoop:curRunLoop forMode:NSRunLoopCommonModes];[inputStream setDelegate:nil];[inputStream close];if (myErrCode >= 200 && myErrCode < 300) {// 返回碼為2xx,直接通知client[self.client URLProtocolDidFinishLoading:self];} else if (myErrCode >= 300 && myErrCode < 400) {// 返回碼為3xx,需要重定向請求,繼續訪問重定向頁麵NSString *location = headDict[@"Location"];NSURL *url = [[NSURL alloc] initWithString:location];curRequest = [[NSMutableURLRequest alloc] initWithURL:url];/***********重定向通知client處理或內部處理*************/// client處理// NSURLResponse* response = [[NSURLResponse alloc] initWithURL:curRequest.URL MIMEType:headDict[@"Content-Type"] expectedContentLength:[headDict[@"Content-Length"] integerValue] textEncodingName:@"UTF8"];// [self.client URLProtocol:self wasRedirectedToRequest:curRequest redirectResponse:response];// 內部處理,將url中的host通過HTTPDNS轉換為IP,不能在startLoading線程中進行同步網絡請求,會被阻塞NSString *ip = [[HttpDnsService sharedInstance] getIpByHostAsync:url.host];if (ip) {NSLog(@"Get IP from HTTPDNS Successfully!");NSRange hostFirstRange = [location rangeOfString:url.host];if (NSNotFound != hostFirstRange.location) {NSString *newUrl = [location stringByReplacingCharactersInRange:hostFirstRange withString:ip];curRequest = [[NSMutableURLRequest alloc] initWithURL:[NSURL URLWithString:newUrl]];[curRequest setValue:url.host forHTTPHeaderField:@"host"];}}[self startRequest];} else {// 其他情況,直接返回響應信息給client[self.client URLProtocolDidFinishLoading:self];}} else {// 頭部信息不完整,關閉inputstream,通知client[inputStream removeFromRunLoop:curRunLoop forMode:NSRunLoopCommonModes];[inputStream setDelegate:nil];[inputStream close];[self.client URLProtocolDidFinishLoading:self];}}
立即試用HTTPDNS服務,點擊此處。
最後更新:2016-11-23 16:04:14
上一篇:
HTTPDNS域名解析場景下如何使用Cookie?__最佳實踐_HTTPDNS-阿裏雲
下一篇:
金融雲特性__金融雲介紹_金融雲-阿裏雲
常見錯誤說明__附錄_大數據計算服務-阿裏雲
發送短信接口__API使用手冊_短信服務-阿裏雲
接口文檔__Android_安全組件教程_移動安全-阿裏雲
運營商錯誤碼(聯通)__常見問題_短信服務-阿裏雲
設置短信模板__使用手冊_短信服務-阿裏雲
OSS 權限問題及排查__常見錯誤及排除_最佳實踐_對象存儲 OSS-阿裏雲
消息通知__操作指南_批量計算-阿裏雲
設備端快速接入(MQTT)__快速開始_阿裏雲物聯網套件-阿裏雲
查詢API調用流量數據__API管理相關接口_API_API 網關-阿裏雲
使用STS訪問__JavaScript-SDK_SDK 參考_對象存儲 OSS-阿裏雲