Eric 发布的文章

通过 HTTP connect 建立的代理及 Java 中如何使用这种代理

代理的分类

我们通常根据代理协议所在的 OSI 模型中的层级, 分为 HTTP 代理和 SOCKS (v4 or v5) 代理;
根据代理是在客户端还是在服务端, 分为 正向代理(Forward proxy 如: Squid) 和 反向代理(reverse proxy 如: NGINX);
根据客户端是否意识到在使用代理分为一般代理(Common proxy) 和 透明代理(Transparent Proxy);

HTTP CONNECT

对于开发人员常见的 HTTP 协议来说, 除了有 GET, POST, PUT, DELETE, HEAD, PATCH, OPTIONS 等方法(method)之外, 其实还有一个 CONNECT 方法. HTTP CONNECT
HTTP CONNECT 方法主要用来建立一个双向通信的隧道(tunnel). 比如我们想通过代理 proxy.tianxiaohui.com:8080 访问 https://www.google.com. 那么我们看到的过程是这样的:

  1. 客户端先跟 proxy.tianxiaohui.com 通过三次握手建立 tcp 连接;
  2. 客户端发送一个 HTTP CONNECT 请求给 proxy.tianxiaohui.com:8080, HTTP header 里面通过 host header 包含想要真正想要访问网站的主机和端口 www.google.com:80
  3. 代理收到这个 HTTP CONNECT 请求之后, 通过三次握手跟 www.google.com 建立 tcp 连接, 然后返回给客户端 200 response. 从此开始代理服务器对于这个双向通信隧道只转发 tcp 包, 不管任何内容.
  4. 客户端收到 200 response, 这时一个从客户端到 www.google.com 主机的隧道建立. 相当于没有代理的情况下, tcp 连接建立.
  5. 之后客户端就类似刚建立 tcp 连接一样, 开始 https 协商, 然后 ssl 协商和证书验证之后, 开始发送真正的 HTTP 请求.

客户端抓包验证

在客户端, 可以通过 curl 来实验这个过程, 然后通过 tcpdump 抓包验证.

curl -x 'http://proxy.tianxiaohui.com:8080' https://www.google.com

通过 wireshark 看到的 HTTP 请求和回复是:

CONNECT www.google.com:443 HTTP/1.1
Host: www.google.com:443
User-Agent: curl/7.64.1
Proxy-Connection: Keep-Alive

HTTP/1.1 200 Connection established

详细如图:
proxyCapture.png

通过上面的截图可以看到, 一旦隧道建立好之后, 客户端就尝试和真正的服务器简历 https 连接, 代理只是无脑转发. 即使代理抓包截获数据, 也无法看到真正的请求包里的内容.

https 代理和 https 请求

我们可以通过代理走 http 的请求 和 https 的请求. 同样代理即可以是是 http 协议的代理, 也可以是 https 的代理. 如下图:
proxyAndServer.png

那么一个复杂的例子就是, 客户端走 https 代理, 然后发送 https 请求. 这时候,我们可以看到 HTTP CONNECT 请求是在客户端和代理服务器之间先建立 https 连接后, 然后通过 https 协议发出去的. 之后代理建立和真正的远程服务器建立 tcp 连接, 发送的 http response 也是在 https 协议收到. 到此, 一个隧道建立. 然后客户端和真正的服务器在这个隧道里面再建立真正新的到真正服务器的内层 https 连接, 连接建立好之后, 发送真正的请求.

curl -x 'https://userName:password@proxy.tianxiaohui.com:8443' https://www.gooogle.com

如下图, 由于我们看不到内层的 https 请求, 但是从外层看这个证书验证, 我们可以看到它是代理服务器的证书.
httpsproxycert.png

代理的认证

有些代理需要认证, 这时候需要在 HTTP CONNECT 的时候, 发送 Proxy-Authorization header. 从非加密的但是需要认证的代理服务器发送的请求, 我们看一看到:

curl -x 'http://userName:password@proxy.tianxiaohui.com:8080' https://www.gooogle.com

wireshark 看到的包内容:

CONNECT www.gooogle.com:443 HTTP/1.1
Host: www.gooogle.com:443
Proxy-Authorization: Basic eGse3hbjo4NTEyMTZ2dnZ2Y2NmaGloamhldGmZw==
User-Agent: curl/7.64.1
Proxy-Connection: Keep-Alive

HTTP/1.1 200 Connection established

如果不给认证 header, 就会收到 http status 407 的 response.

Java 程序中使用代理

有些时候, 我们看到Java 程序使用环境变量设置代理, 比如下面这种方式:

System.setProperty("proxyHost", PROXY_HOST);
System.setProperty("proxyPort",  PROXY_PORT);

又或者通过启动参数设置环境变量:

java -Dhttp.proxyHost=proxyhostURL  -Dhttp.proxyPort=proxyPortNumber -jar my.jar

这种方式其实是让所有的连接都走系统代理, 这不是我们想讨论的内容.

下面这种只针对某个主机的连接, 而不是全局所有连接的代理, 才是我们想讨论的范围:

Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress("proxy.tianxiaohui.com", 8080));
URL url = new URL("https://www.google.com");
HttpURLConnection uc = (HttpURLConnection)url.openConnection(proxy);
uc.connect();

Java 代理类型不支持 https 代理

现有的 Java 实现 java.net.Proxy 不支持 HTTPS 代理, 如果想实现这种代理方式, 要自己写 Java 代码连接 https 代理, 验证证书,创建 https 连接, 然后发送 HTTP CONNECT 请求.

Java http 代理的用户名密码验证问题

如果我们的代理服务器不需要用户名密码验证, 一切都没问题, 直接上面的方式去连接就好了.
如果我们的代理服务器需要用户名和密码验证, 就要区分我们最终要访问的网站是 http 还是 https 了.

最终网站是 http

直接在我们要访问的网站的请求里面设置header: Proxy-Authorization 就可以了. 比如下面的代码:

URL url = new URL("http://www.tianxiaohui.com");  
InetSocketAddress addr = new InetSocketAddress("proxy.tianxiaohui.com",8080);  
Proxy proxy = new Proxy(Proxy.Type.HTTP, addr);
URLConnection conn = url.openConnection(proxy);  
   
String headerkey = "Proxy-Authorization";  
String headerValue = "Basic "+Base64.encodeToString("userName:password".getBytes(), false);
conn.setRequestProperty(headerkey, headerValue);  
  
InputStream in = conn.getInputStream();  
String s = IOUtils.toString(in, "utf-8");  
System.out.println(s);  

最终网站是 https

如果要访问的网站是 https, 设置代理用户名密码的认证的方式就不一样了. 需要下面的代码:

Authenticator.setDefault(new Authenticator() {
  protected PasswordAuthentication getPasswordAuthentication() {
    return new PasswordAuthentication("userName", "password".toCharArray());
  }
});

如果是 JDK 8 update 111 之后, 还需要在启动的时候或者任何连接建立之前, 执行下面这句

# 作为启动参数: 
-Djdk.http.auth.tunneling.disabledSchemes=""
# 或者再 main 函数里开始的地方设置:
System.setProperty("jdk.http.auth.tunneling.disabledSchemes", "");

关于 HandshakeCompletedNotify-Thread 线程

最近诊断一个线程泄漏问题的时候, 发现大量的 HandshakeCompletedNotify-Thread 线程, 观察它的代码栈, 发现源自于 JDK 代码 sun.security.ssl.TransportContext.finishHandshake.

每次 SSL 连接建立都创建一个新的线程去做通知, 显然是有性能问题. 可是这个问题尚没有修复, 看上去为了兼容老的 API没人想修复: https://bugs.openjdk.java.net/browse/JDK-8246039

所以尽量连接重用, 减少性能开销.

创建线程栈:

HandshakeCompletedNotify-Thread
java.lang.Thread.start(Thread.java)
sun.security.ssl.TransportContext.finishHandshake(TransportContext.java:620)
sun.security.ssl.Finished$T12FinishedConsumer.onConsumeFinished(Finished.java:546)
sun.security.ssl.Finished$T12FinishedConsumer.consume(Finished.java:515)
sun.security.ssl.SSLHandshake.consume(SSLHandshake.java:377)
sun.security.ssl.HandshakeContext.dispatch(HandshakeContext.java:444)
sun.security.ssl.HandshakeContext.dispatch(HandshakeContext.java:422)
sun.security.ssl.TransportContext.dispatch(TransportContext.java:182)
sun.security.ssl.SSLTransport.decode(SSLTransport.java:156)
sun.security.ssl.SSLSocketImpl.decode(SSLSocketImpl.java:1409)
sun.security.ssl.SSLSocketImpl.readHandshakeRecord(SSLSocketImpl.java:1315)
sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:439)
sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:410)
sun.net.www.protocol.https.HttpsClient.afterConnect(HttpsClient.java:559)
sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.connect(AbstractDelegateHttpsURLConnection.java:197)
sun.net.www.protocol.http.HttpURLConnection.getInputStream0(HttpURLConnection.java:1570)
sun.net.www.protocol.http.HttpURLConnection.getInputStream(HttpURLConnection.java:1498)
sun.net.www.protocol.https.HttpsURLConnectionImpl.getInputStream(HttpsURLConnectionImpl.java:268)

这段代码:

    HandshakeStatus finishHandshake() {
        if (protocolVersion.useTLS13PlusSpec()) {
            outputRecord.tc = this;
            inputRecord.tc = this;
            cipherSuite = handshakeContext.negotiatedCipherSuite;
            inputRecord.readCipher.baseSecret =
                    handshakeContext.baseReadSecret;
            outputRecord.writeCipher.baseSecret =
                    handshakeContext.baseWriteSecret;
        }

        handshakeContext = null;
        outputRecord.handshakeHash.finish();
        inputRecord.finishHandshake();
        outputRecord.finishHandshake();
        isNegotiated = true;

        // Tell folk about handshake completion, but do it in a separate thread.
        if (transport instanceof SSLSocket &&
                sslConfig.handshakeListeners != null &&
                !sslConfig.handshakeListeners.isEmpty()) {
            HandshakeCompletedEvent hce =
                new HandshakeCompletedEvent((SSLSocket)transport, conSession);
            Thread thread = new Thread(
                null,
                new NotifyHandshake(sslConfig.handshakeListeners, hce),
                "HandshakeCompletedNotify-Thread",
                0,
                false);
            thread.start();
        }

        return HandshakeStatus.FINISHED;
    }

关于进来流量从网卡到 kernel 再到 select/poll/epoll 再到 tomcat 的 requst/response

先把这几篇放这里, 等有空再把内容转成图片

网卡到 sk_buffer
https://packagecloud.io/blog/monitoring-tuning-linux-networking-stack-receiving-data/
https://www.bilibili.com/read/cv10228233
https://www.eet-china.com/mp/a32619.html

select/poll/epoll
https://copyconstruct.medium.com/nonblocking-i-o-99948ad7c957
https://copyconstruct.medium.com/the-method-to-epolls-madness-d9d2d6378642

记录一次tcp连接持续断掉的排查过程

有人上报说他们的 app 连接另外一个微服务的时候, 连接经常断掉, 不知道什么原因.

查看网络方面的监控, 网络一切安好.

查看日志, 看到一些异常信息: java.nio.channels.ClosedByInterruptException. 顺着这个信息, 我们找到了根本原因.

  1. 既然是 java.nio.channels.ClosedByInterruptException, 那么我们可以用 btrace 查看为什么这个异常被创建, 得到结果如下:

    java.nio.channels.ClosedByInterruptException.<init>(ClosedByInterruptException.java:51)
    java.nio.channels.spi.AbstractInterruptibleChannel.end(AbstractInterruptibleChannel.java:202)
    sun.nio.ch.SocketChannelImpl.write(SocketChannelImpl.java:477)
    com.tianxiaohui.java.BufferedWriteChannel.flush(BufferedWriteChannel.java:281)
    com.tianxiaohui.java.BufferedWriteChannel.write(BufferedWriteChannel.java:223)
    com.tianxiaohui.java.BaseClient.sendBytesActual(BaseClient.java:339)
    com.tianxiaohui.java.BaseClient.sendBytesOverChannel(BaseClient.java:203)
    ...
    rx.internal.operators.OnSubscribeLift.call(OnSubscribeLift.java:48)
    rx.internal.operators.OnSubscribeLift.call(OnSubscribeLift.java:30)
    rx.Observable.unsafeSubscribe(Observable.java:10327) 
    rx.internal.operators.OperatorSubscribeOn$SubscribeOnSubscriber.call(OperatorSubscribeOn.java:100)
    rx.xinternal.schedulers.CachedThreadScheduler$EventLoopWorker$1.call(CachedThreadScheduler.java:230)
    rx.internal.schedulers.ScheduledAction.run(ScheduledAction.java:55)
    ...
     java.lang.Thread.run(Thread.java:748)
  2. 既然是 interrupt 异常, 那么就一定有其它线程给这个线程发 interrupt, 于是通过下面的 btrace 代码, 我们就能找到发 interrupt 的线程

    import org.openjdk.btrace.core.annotations.*;
    import static org.openjdk.btrace.core.BTraceUtils.*;
    
    import org.openjdk.btrace.core.BTraceUtils.Strings;
    
    @BTrace
    public class ThreadInterruptTrace {
    
      @OnMethod( clazz="/java\\.nio\\.channels\\.ClosedByInterruptException/", method="<init>" )
      public static void createException() {
          println(Strings.strcat(name(currentThread()), " --- get ClosedByInterruptException "));
          println(jstackStr());
      }
    
      @OnMethod( clazz="/java\\.lang\\.Thread1/", method="/interrupt/" )
      public static void t1r(@ProbeClassName String probeClass, @Self Thread self) {
          if (Strings.startsWith(name(self), "RxIoScheduler")) {
              println(Strings.strcat(Strings.strcat(name(currentThread()), " try to interrupt ->  "), str(self)));
                println(jstackStr());
            }
        }
    }
  3. 找到发 interrupt 的线程, 那么就去看代码, 找了发生 interrupt 的原因, 最终原因是下面的代码. 每次 create 一个 Observable 都要新起一个线程池做为 scheduler, 其实不需要. 到时每次新建一个 scheduler 线程池导致创建太多线程池, 消耗大量内存, 之后又自动销毁. 然后这些 scheduler 销毁的时候, 还要通知 I/O 线程(这里的 I/O 线程池用的默认的), 也就是给 I/O 线程发一个 interrupt. 最终导致了我们看到的问题.

    Observable.create(new Observable.OnSubscribe<List<T>>() {
      @Override
      public void call(Subscriber<? super List<T>> subscriber) {
               //some code here
          }
      }).}).observeOn(Schedulers.newThread())
           .subscribeOn(Schedulers.io())
           .timeout(threadTimeout, TimeUnit.MILLISECONDS);
    

JVM 的 debug 符号

下面内容是从 https://www.opsian.com/faq/ 页面抄的:

If you've downloaded a JVM from a normal distribution - for example Oracle, Azul or Adopt OpenJDK - then it will come with debug symbols built in. Many distributions do not ship debug symbols with their JDK packages, this will result in some features of the Opsian agent not being available. It is easy to install the debug packages.

Ubuntu - If you are using Java 11 then run apt install openjdk-11-dbg or if you're running Java 8 then run apt install openjdk-8-dbg
Red Hat / Centos - If you are running version 7 or later then you need to Enable the DebugInfo repository on Red Hat or Centos. Then you need to install the appropriate debug info package for your JDK version, for example yum -y install java-1.8.0-openjdk-debuginfo.x86_64 if you're using Java 8.

附带一张 18 年参加 SRE con 上面别人的总结:
srecon18americas_slides_goldshtein_pdf__page_8_of_74_.png