From 81c7461c98f2228c3bb1a0c80fb803b5ea2e47f0 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Sun, 5 Mar 2017 19:46:23 -0500 Subject: [PATCH] Connection coalescing As described here: https://daniel.haxx.se/blog/2016/08/18/http2-connection-coalescing/ https://hpbn.co/optimizing-application-delivery/#eliminate-domain-sharding https://tools.ietf.org/html/rfc7540#section-9.1.1 When the following is true - HTTP/2 so we don't overload non multiplexed connections. - HTTPS since we need subjectAltNames (n.b. RFC 7540 defines as safe to use over plaintext, based purely on DNS). - Desired url host is part of the subjectAltNames for an existing connection. - One of the DNS results for desired host match the established connection. - HostnameVerifier is *not* overridden. - ConnectionPinner accepts the existing certificate chain. From https://tools.ietf.org/html/rfc7540#section-9.1.1 > A server that does not wish clients to reuse connections can indicate > that it is not authoritative for a request by sending a 421 > (Misdirected Request) status code in response to the request (see > Section 9.1.2). --- .../okhttp3/ConnectionCoalescingTest.java | 291 ++++++++++++++++++ okhttp/src/main/java/okhttp3/Address.java | 30 +- .../src/main/java/okhttp3/ConnectionPool.java | 11 +- .../src/main/java/okhttp3/OkHttpClient.java | 10 +- .../main/java/okhttp3/internal/Internal.java | 7 +- .../internal/connection/RealConnection.java | 64 +++- .../internal/connection/StreamAllocation.java | 14 +- .../internal/http/RealInterceptorChain.java | 15 +- .../internal/tls/OkHostnameVerifier.java | 2 +- 9 files changed, 401 insertions(+), 43 deletions(-) create mode 100644 okhttp-tests/src/test/java/okhttp3/ConnectionCoalescingTest.java diff --git a/okhttp-tests/src/test/java/okhttp3/ConnectionCoalescingTest.java b/okhttp-tests/src/test/java/okhttp3/ConnectionCoalescingTest.java new file mode 100644 index 000000000000..aedb5f230029 --- /dev/null +++ b/okhttp-tests/src/test/java/okhttp3/ConnectionCoalescingTest.java @@ -0,0 +1,291 @@ +/* + * Copyright (C) 2017 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3; + +import java.io.IOException; +import java.net.InetAddress; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLSession; +import okhttp3.internal.tls.HeldCertificate; +import okhttp3.internal.tls.SslClient; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Rule; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +public final class ConnectionCoalescingTest { + @Rule public final MockWebServer server = new MockWebServer(); + + private OkHttpClient client; + + private HeldCertificate rootCa; + private HeldCertificate certificate; + private FakeDns dns = new FakeDns(); + private HttpUrl url; + private List serverIps; + + @Before public void setUp() throws Exception { + rootCa = new HeldCertificate.Builder() + .serialNumber("1") + .ca(3) + .commonName("root") + .build(); + certificate = new HeldCertificate.Builder() + .issuedBy(rootCa) + .serialNumber("2") + .commonName(server.getHostName()) + .subjectAlternativeName(server.getHostName()) + .subjectAlternativeName("san.com") + .subjectAlternativeName("*.wildcard.com") + .subjectAlternativeName("differentdns.com") + .build(); + + serverIps = Dns.SYSTEM.lookup(server.getHostName()); + + dns.set(server.getHostName(), serverIps); + dns.set("san.com", serverIps); + dns.set("nonsan.com", serverIps); + dns.set("www.wildcard.com", serverIps); + dns.set("differentdns.com", Collections.emptyList()); + + SslClient sslClient = new SslClient.Builder() + .addTrustedCertificate(rootCa.certificate) + .build(); + + client = new OkHttpClient.Builder().dns(dns) + .sslSocketFactory(sslClient.socketFactory, sslClient.trustManager) + .build(); + + SslClient serverSslClient = new SslClient.Builder() + .certificateChain(certificate, rootCa) + .build(); + server.useHttps(serverSslClient.socketFactory, false); + + url = server.url("/robots.txt"); + } + + /** + * Test connecting to the main host then an alternative, although only subject alternative names + * are used if present no special consideration of common name. + */ + @Test public void commonThenAlternative() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + server.enqueue(new MockResponse().setResponseCode(200)); + + assert200Http2Response(execute(url), server.getHostName()); + + HttpUrl sanUrl = url.newBuilder().host("san.com").build(); + assert200Http2Response(execute(sanUrl), "san.com"); + + assertEquals(1, client.connectionPool().connectionCount()); + } + + /** + * Test connecting to an alternative host then common name, although only subject alternative + * names are used if present no special consideration of common name. + */ + @Test public void alternativeThenCommon() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + server.enqueue(new MockResponse().setResponseCode(200)); + + HttpUrl sanUrl = url.newBuilder().host("san.com").build(); + assert200Http2Response(execute(sanUrl), "san.com"); + + assert200Http2Response(execute(url), server.getHostName()); + + assertEquals(1, client.connectionPool().connectionCount()); + } + + /** If the existing connection matches a SAN but not a match for DNS then skip. */ + @Test public void skipsWhenDnsDontMatch() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + + assert200Http2Response(execute(url), server.getHostName()); + + HttpUrl differentDnsUrl = url.newBuilder().host("differentdns.com").build(); + try { + execute(differentDnsUrl); + fail("expected a failed attempt to connect"); + } catch (IOException expected) { + } + } + + /** Not in the certificate SAN. */ + @Test public void skipsWhenNotSubjectAltName() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + + assert200Http2Response(execute(url), server.getHostName()); + + HttpUrl nonsanUrl = url.newBuilder().host("nonsan.com").build(); + + try { + execute(nonsanUrl); + fail("expected a failed attempt to connect"); + } catch (IOException expected) { + } + } + + /** Can still coalesce when pinning is used if pins match. */ + @Test public void coalescesWhenCertificatePinsMatch() throws Exception { + CertificatePinner pinner = new CertificatePinner.Builder() + .add("san.com", "sha1/" + CertificatePinner.sha1(certificate.certificate).base64()) + .build(); + client = client.newBuilder().certificatePinner(pinner).build(); + + server.enqueue(new MockResponse().setResponseCode(200)); + server.enqueue(new MockResponse().setResponseCode(200)); + + assert200Http2Response(execute(url), server.getHostName()); + + HttpUrl sanUrl = url.newBuilder().host("san.com").build(); + + assert200Http2Response(execute(sanUrl), "san.com"); + + assertEquals(1, client.connectionPool().connectionCount()); + } + + /** Certificate pinning used and not a match will avoid coalescing and try to connect. */ + @Test public void skipsWhenCertificatePinningFails() throws Exception { + CertificatePinner pinner = new CertificatePinner.Builder() + .add("san.com", "sha1/afwiKY3RxoMmLkuRW1l7QsPZTJPwDS2pdDROQjXw8ig=") + .build(); + client = client.newBuilder().certificatePinner(pinner).build(); + + server.enqueue(new MockResponse().setResponseCode(200)); + + assert200Http2Response(execute(url), server.getHostName()); + + HttpUrl sanUrl = url.newBuilder().host("san.com").build(); + + try { + execute(sanUrl); + fail("expected a failed attempt to connect"); + } catch (IOException expected) { + } + } + + /** + * Skips coalescing when hostname verifier is overridden since the intention of the hostname + * verification is a black box. + */ + @Test public void skipsWhenHostnameVerifierUsed() throws Exception { + HostnameVerifier verifier = new HostnameVerifier() { + @Override public boolean verify(String s, SSLSession sslSession) { + return true; + } + }; + client = client.newBuilder().hostnameVerifier(verifier).build(); + + server.enqueue(new MockResponse().setResponseCode(200)); + server.enqueue(new MockResponse().setResponseCode(200)); + + assert200Http2Response(execute(url), server.getHostName()); + + HttpUrl sanUrl = url.newBuilder().host("san.com").build(); + + assert200Http2Response(execute(sanUrl), "san.com"); + + assertEquals(2, client.connectionPool().connectionCount()); + } + + /** + * Check we would use an existing connection to a later DNS result instead of connecting to the + * first DNS result for the first time. + */ + @Test public void prefersExistingCompatible() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200)); + server.enqueue(new MockResponse().setResponseCode(200)); + + assert200Http2Response(execute(url), server.getHostName()); + + HttpUrl sanUrl = url.newBuilder().host("san.com").build(); + dns.set("san.com", + Arrays.asList(InetAddress.getByAddress("san.com", new byte[] {0, 0, 0, 0}), + serverIps.get(0))); + assert200Http2Response(execute(sanUrl), "san.com"); + + assertEquals(1, client.connectionPool().connectionCount()); + } + + /** Check that wildcard SANs are supported. */ + @Test public void commonThenWildcard() throws Exception { + + server.enqueue(new MockResponse().setResponseCode(200)); + server.enqueue(new MockResponse().setResponseCode(200)); + + assert200Http2Response(execute(url), server.getHostName()); + + HttpUrl sanUrl = url.newBuilder().host("www.wildcard.com").build(); + assert200Http2Response(execute(sanUrl), "www.wildcard.com"); + + assertEquals(1, client.connectionPool().connectionCount()); + } + + /** Network interceptors check for changes to target. */ + @Test public void worksWithNetworkInterceptors() throws Exception { + client = client.newBuilder().addNetworkInterceptor(new Interceptor() { + @Override public Response intercept(Chain chain) throws IOException { + return chain.proceed(chain.request()); + } + }).build(); + + server.enqueue(new MockResponse().setResponseCode(200)); + server.enqueue(new MockResponse().setResponseCode(200)); + + assert200Http2Response(execute(url), server.getHostName()); + + HttpUrl sanUrl = url.newBuilder().host("san.com").build(); + assert200Http2Response(execute(sanUrl), "san.com"); + + assertEquals(1, client.connectionPool().connectionCount()); + } + + /** Run against public external sites, doesn't run by default. */ + @Ignore + @Test public void coalescesConnectionsToRealSites() throws IOException { + client = new OkHttpClient(); + + assert200Http2Response(execute("https://graph.facebook.com/robots.txt"), "graph.facebook.com"); + assert200Http2Response(execute("https://www.facebook.com/robots.txt"), "m.facebook.com"); + assert200Http2Response(execute("https://fb.com/robots.txt"), "m.facebook.com"); + assert200Http2Response(execute("https://messenger.com/robots.txt"), "messenger.com"); + assert200Http2Response(execute("https://m.facebook.com/robots.txt"), "m.facebook.com"); + + assertEquals(3, client.connectionPool().connectionCount()); + } + + private Response execute(String url) throws IOException { + return execute(HttpUrl.parse(url)); + } + + private Response execute(HttpUrl url) throws IOException { + return client.newCall(new Request.Builder().url(url).build()).execute(); + } + + private void assert200Http2Response(Response response, String expectedHost) { + assertEquals(200, response.code()); + assertEquals(expectedHost, response.request().url().host()); + assertEquals(Protocol.HTTP_2, response.protocol()); + } +} diff --git a/okhttp/src/main/java/okhttp3/Address.java b/okhttp/src/main/java/okhttp3/Address.java index 8aef1932a5b2..26828f16f107 100644 --- a/okhttp/src/main/java/okhttp3/Address.java +++ b/okhttp/src/main/java/okhttp3/Address.java @@ -150,20 +150,9 @@ public CertificatePinner certificatePinner() { } @Override public boolean equals(Object other) { - if (other instanceof Address) { - Address that = (Address) other; - return this.url.equals(that.url) - && this.dns.equals(that.dns) - && this.proxyAuthenticator.equals(that.proxyAuthenticator) - && this.protocols.equals(that.protocols) - && this.connectionSpecs.equals(that.connectionSpecs) - && this.proxySelector.equals(that.proxySelector) - && equal(this.proxy, that.proxy) - && equal(this.sslSocketFactory, that.sslSocketFactory) - && equal(this.hostnameVerifier, that.hostnameVerifier) - && equal(this.certificatePinner, that.certificatePinner); - } - return false; + return other instanceof Address + && url.equals(((Address) other).url) + && equalsNonHost((Address) other); } @Override public int hashCode() { @@ -181,6 +170,19 @@ && equal(this.hostnameVerifier, that.hostnameVerifier) return result; } + boolean equalsNonHost(Address that) { + return this.dns.equals(that.dns) + && this.proxyAuthenticator.equals(that.proxyAuthenticator) + && this.protocols.equals(that.protocols) + && this.connectionSpecs.equals(that.connectionSpecs) + && this.proxySelector.equals(that.proxySelector) + && equal(this.proxy, that.proxy) + && equal(this.sslSocketFactory, that.sslSocketFactory) + && equal(this.hostnameVerifier, that.hostnameVerifier) + && equal(this.certificatePinner, that.certificatePinner) + && this.url().port() == that.url().port(); + } + @Override public String toString() { StringBuilder result = new StringBuilder() .append("Address{") diff --git a/okhttp/src/main/java/okhttp3/ConnectionPool.java b/okhttp/src/main/java/okhttp3/ConnectionPool.java index a589e3ad1dce..a0128cfbc81a 100644 --- a/okhttp/src/main/java/okhttp3/ConnectionPool.java +++ b/okhttp/src/main/java/okhttp3/ConnectionPool.java @@ -114,11 +114,14 @@ public synchronized int connectionCount() { return connections.size(); } - /** Returns a recycled connection to {@code address}, or null if no such connection exists. */ - RealConnection get(Address address, StreamAllocation streamAllocation) { + /** + * Returns a recycled connection to {@code address}, or null if no such connection exists. The + * route is null if the address has not yet been routed. + */ + RealConnection get(Address address, StreamAllocation streamAllocation, Route route) { assert (Thread.holdsLock(this)); for (RealConnection connection : connections) { - if (connection.isEligible(address)) { + if (connection.isEligible(address, route)) { streamAllocation.acquire(connection); return connection; } @@ -133,7 +136,7 @@ RealConnection get(Address address, StreamAllocation streamAllocation) { Socket deduplicate(Address address, StreamAllocation streamAllocation) { assert (Thread.holdsLock(this)); for (RealConnection connection : connections) { - if (connection.isEligible(address) + if (connection.isEligible(address, null) && connection.isMultiplexed() && connection != streamAllocation.connection()) { return streamAllocation.releaseAndAcquire(connection); diff --git a/okhttp/src/main/java/okhttp3/OkHttpClient.java b/okhttp/src/main/java/okhttp3/OkHttpClient.java index 35c1c28c7493..2ec6738f271b 100644 --- a/okhttp/src/main/java/okhttp3/OkHttpClient.java +++ b/okhttp/src/main/java/okhttp3/OkHttpClient.java @@ -144,9 +144,13 @@ public class OkHttpClient implements Cloneable, Call.Factory, WebSocket.Factory return pool.connectionBecameIdle(connection); } - @Override public RealConnection get( - ConnectionPool pool, Address address, StreamAllocation streamAllocation) { - return pool.get(address, streamAllocation); + @Override public RealConnection get(ConnectionPool pool, Address address, + StreamAllocation streamAllocation, Route route) { + return pool.get(address, streamAllocation, route); + } + + @Override public boolean equalsNonHost(Address a, Address b) { + return a.equalsNonHost(b); } @Override public Socket deduplicate( diff --git a/okhttp/src/main/java/okhttp3/internal/Internal.java b/okhttp/src/main/java/okhttp3/internal/Internal.java index a72f71e4d445..1be96fd3e7ab 100644 --- a/okhttp/src/main/java/okhttp3/internal/Internal.java +++ b/okhttp/src/main/java/okhttp3/internal/Internal.java @@ -28,6 +28,7 @@ import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; +import okhttp3.Route; import okhttp3.internal.cache.InternalCache; import okhttp3.internal.connection.RealConnection; import okhttp3.internal.connection.RouteDatabase; @@ -52,8 +53,10 @@ public static void initializeInstanceForTests() { public abstract void setCache(OkHttpClient.Builder builder, InternalCache internalCache); - public abstract RealConnection get( - ConnectionPool pool, Address address, StreamAllocation streamAllocation); + public abstract RealConnection get(ConnectionPool pool, Address address, + StreamAllocation streamAllocation, Route route); + + public abstract boolean equalsNonHost(Address a, Address b); public abstract Socket deduplicate( ConnectionPool pool, Address address, StreamAllocation streamAllocation); diff --git a/okhttp/src/main/java/okhttp3/internal/connection/RealConnection.java b/okhttp/src/main/java/okhttp3/internal/connection/RealConnection.java index d8603375b322..c6ee1ff8bd8a 100644 --- a/okhttp/src/main/java/okhttp3/internal/connection/RealConnection.java +++ b/okhttp/src/main/java/okhttp3/internal/connection/RealConnection.java @@ -44,6 +44,7 @@ import okhttp3.Request; import okhttp3.Response; import okhttp3.Route; +import okhttp3.internal.Internal; import okhttp3.internal.Util; import okhttp3.internal.Version; import okhttp3.internal.http.HttpCodec; @@ -373,11 +374,64 @@ private Request createTunnelRequest() { .build(); } - /** Returns true if this connection can carry a stream allocation to {@code address}. */ - public boolean isEligible(Address address) { - return allocations.size() < allocationLimit - && address.equals(route().address()) - && !noNewStreams; + /** + * Returns true if this connection can carry a stream allocation to {@code address}. If non-null + * {@code route} is the resolved route for a connection. + */ + public boolean isEligible(Address address, Route route) { + // If this connection is not accepting new streams, we're done. + if (allocations.size() >= allocationLimit || noNewStreams) return false; + + // If the non-host fields of the address don't overlap, we're done. + if (!Internal.instance.equalsNonHost(this.route.address(), address)) return false; + + // If the host exactly matches, we're done: this connection can carry the address. + if (address.url().host().equals(this.route().address().url().host())) { + return true; // This connection is a perfect match. + } + + // At this point we don't have a hostname match. But we still be able to carry the request if + // our connection coalescing requirements are met. See also: + // https://hpbn.co/optimizing-application-delivery/#eliminate-domain-sharding + // https://daniel.haxx.se/blog/2016/08/18/http2-connection-coalescing/ + + // 1. This connection must be HTTP/2. + if (http2Connection == null) return false; + + // 2. The routes must share an IP address. This requires us to have a DNS address for both + // hosts, which only happens after route planning. We can't coalesce connections that use a + // proxy, since proxies don't tell us the origin server's IP address. + if (route == null) return false; + if (route.proxy().type() != Proxy.Type.DIRECT) return false; + if (this.route.proxy().type() != Proxy.Type.DIRECT) return false; + if (!this.route.socketAddress().equals(route.socketAddress())) return false; + + // 3. This connection's server certificate's must cover the new host. + if (route.address().hostnameVerifier() != OkHostnameVerifier.INSTANCE) return false; + if (!supportsUrl(address.url())) return false; + + // 4. Certificate pinning must match the host. + try { + address.certificatePinner().check(address.url().host(), handshake().peerCertificates()); + } catch (SSLPeerUnverifiedException e) { + return false; + } + + return true; // The caller's address can be carried by this connection. + } + + public boolean supportsUrl(HttpUrl url) { + if (url.port() != route.address().url().port()) { + return false; // Port mismatch. + } + + if (!url.host().equals(route.address().url().host())) { + // We have a host mismatch. But if the certificate matches, we're still good. + return handshake != null && OkHostnameVerifier.INSTANCE.verify( + url.host(), (X509Certificate) handshake.peerCertificates().get(0)); + } + + return true; // Success. The URL is supported. } public HttpCodec newCodec( diff --git a/okhttp/src/main/java/okhttp3/internal/connection/StreamAllocation.java b/okhttp/src/main/java/okhttp3/internal/connection/StreamAllocation.java index fbe83067ab85..211181961f3c 100644 --- a/okhttp/src/main/java/okhttp3/internal/connection/StreamAllocation.java +++ b/okhttp/src/main/java/okhttp3/internal/connection/StreamAllocation.java @@ -158,7 +158,7 @@ private RealConnection findConnection(int connectTimeout, int readTimeout, int w } // Attempt to get a connection from the pool. - Internal.instance.get(connectionPool, address, this); + Internal.instance.get(connectionPool, address, this, null); if (connection != null) { return connection; } @@ -171,15 +171,21 @@ private RealConnection findConnection(int connectTimeout, int readTimeout, int w selectedRoute = routeSelector.next(); } - // Create a connection and assign it to this allocation immediately. This makes it possible for - // an asynchronous cancel() to interrupt the handshake we're about to do. RealConnection result; synchronized (connectionPool) { + if (canceled) throw new IOException("Canceled"); + + // Now that we have an IP address, make another attempt at getting a connection from the pool. + // This could match due to connection coalescing. + Internal.instance.get(connectionPool, address, this, selectedRoute); + if (connection != null) return connection; + + // Create a connection and assign it to this allocation immediately. This makes it possible + // for an asynchronous cancel() to interrupt the handshake we're about to do. route = selectedRoute; refusedStreamCount = 0; result = new RealConnection(connectionPool, selectedRoute); acquire(result); - if (canceled) throw new IOException("Canceled"); } // Do TCP + TLS handshakes. This is a blocking operation. diff --git a/okhttp/src/main/java/okhttp3/internal/http/RealInterceptorChain.java b/okhttp/src/main/java/okhttp3/internal/http/RealInterceptorChain.java index d5326c64f5e7..398f29d661da 100644 --- a/okhttp/src/main/java/okhttp3/internal/http/RealInterceptorChain.java +++ b/okhttp/src/main/java/okhttp3/internal/http/RealInterceptorChain.java @@ -18,10 +18,10 @@ import java.io.IOException; import java.util.List; import okhttp3.Connection; -import okhttp3.HttpUrl; import okhttp3.Interceptor; import okhttp3.Request; import okhttp3.Response; +import okhttp3.internal.connection.RealConnection; import okhttp3.internal.connection.StreamAllocation; /** @@ -32,13 +32,13 @@ public final class RealInterceptorChain implements Interceptor.Chain { private final List interceptors; private final StreamAllocation streamAllocation; private final HttpCodec httpCodec; - private final Connection connection; + private final RealConnection connection; private final int index; private final Request request; private int calls; public RealInterceptorChain(List interceptors, StreamAllocation streamAllocation, - HttpCodec httpCodec, Connection connection, int index, Request request) { + HttpCodec httpCodec, RealConnection connection, int index, Request request) { this.interceptors = interceptors; this.connection = connection; this.streamAllocation = streamAllocation; @@ -68,13 +68,13 @@ public HttpCodec httpStream() { } public Response proceed(Request request, StreamAllocation streamAllocation, HttpCodec httpCodec, - Connection connection) throws IOException { + RealConnection connection) throws IOException { if (index >= interceptors.size()) throw new AssertionError(); calls++; // If we already have a stream, confirm that the incoming request will use it. - if (this.httpCodec != null && !sameConnection(request.url())) { + if (this.httpCodec != null && !this.connection.supportsUrl(request.url())) { throw new IllegalStateException("network interceptor " + interceptors.get(index - 1) + " must retain the same host and port"); } @@ -104,9 +104,4 @@ public Response proceed(Request request, StreamAllocation streamAllocation, Http return response; } - - private boolean sameConnection(HttpUrl url) { - return url.host().equals(connection.route().address().url().host()) - && url.port() == connection.route().address().url().port(); - } } diff --git a/okhttp/src/main/java/okhttp3/internal/tls/OkHostnameVerifier.java b/okhttp/src/main/java/okhttp3/internal/tls/OkHostnameVerifier.java index a85df784d792..7441abadade5 100644 --- a/okhttp/src/main/java/okhttp3/internal/tls/OkHostnameVerifier.java +++ b/okhttp/src/main/java/okhttp3/internal/tls/OkHostnameVerifier.java @@ -139,7 +139,7 @@ private static List getSubjectAltNames(X509Certificate certificate, int * @param pattern domain name pattern from certificate. May be a wildcard pattern such as {@code * *.android.com}. */ - private boolean verifyHostname(String hostname, String pattern) { + public boolean verifyHostname(String hostname, String pattern) { // Basic sanity checks // Check length == 0 instead of .isEmpty() to support Java 5. if ((hostname == null) || (hostname.length() == 0) || (hostname.startsWith("."))