关于 Wireshark 分析 https 的 tcpdump 中的 Encrypted Alert

使用 Wireshark 分析 https 数据的时候, 尤其是分析网络为什么 reset 的时候, 经常看到有些数据行标注为: "Encrypted Alert", 紧接着就会看到连接主动/被动关闭或者 reset. 比如下图:
encryptedAlert.png

看到这 2 个单词, 我们很自然想到是不是加密过程出了什么问题? 为什么它提示 Alert? 今天我们具体去研究一下, 这个 "Encrypted Alert" 到底是什么, 跟连接断掉有什么关系?

首先, 我们研究一下这个 tcp 包的数据, 从下图可以看到, 它其实是传输层的数据, 没有应用层数据. 传输层的具体数据是: 它的内容类型(Content Type)是 Alert, 定义的 ID 是 21, 具体的 Alert Messge 就是: "Encrypted Alert". 从这里我们只能看到这个加密的会话出现了一个加密相关的告警, 没有其它可以告诉我们的.
ssl.png

另外, 在第一个截图中, 我们看到在 60 结尾的主机发 FIN 之前, 先发了一个 "Encrypted Alert" 消息, 接着发了 FIN. 之后 236 结尾的主机, 发了一个 "Encrypted Alert", 还没有发 FIN, 60 结尾的主机立马回了一个 RST.

TLS 协议

首先, TLS 协议根据内容类型(Content Type)有四种可能的记录(record)类型.
tlsP.png
根据 TLS 1.2 RFC5246, 这 4 种记录类型的编码分别是: 20, 21, 22, 23

enum {
          change_cipher_spec(20), alert(21), handshake(22),
          application_data(23), (255)
      } ContentType;

具体到 TLS 的 record, alert(21) 类型的数据, 只用到了 Content Type, TLS 协议大小版本号, 数据长度, 数据.
alert_record.png

对应我们上面截图中的数据:

  1. Content Type 是 21, 16 进制就是 0x15, 对应的是 Alert;
  2. 我们使用的版本号是 1.2, 对应的是 0x0303;
  3. 数据长度是 26 字节, 对应的 16 进制就是 0x001a;
  4. 对应的 payload 就是剩余的黄色里面的 26 字节的数据;
    alertPro.png

TLS 协议中的 Alert 数据

根据 TLS 1.2 RFC5246, Alert 的数据分为 AlertLevel 和 AlertDescription 2 部分. AlertLevel描述严重性: Warning 和 Fatal. AlertDescription 描述具体的 Alert 原因. 不过他们都是 enum 类型. 具体的数据结构是:

      enum { warning(1), fatal(2), (255) } AlertLevel;

      enum {
          close_notify(0),
          unexpected_message(10),
          bad_record_mac(20),
          decryption_failed_RESERVED(21),
          record_overflow(22),
          decompression_failure(30),
          handshake_failure(40),
          no_certificate_RESERVED(41),
          bad_certificate(42),
          unsupported_certificate(43),
          certificate_revoked(44),
          certificate_expired(45),
          certificate_unknown(46),
          illegal_parameter(47),
          unknown_ca(48),
          access_denied(49),
          decode_error(50),
          decrypt_error(51),
          export_restriction_RESERVED(60),
          protocol_version(70),
          insufficient_security(71),
          internal_error(80),
          user_canceled(90),
          no_renegotiation(100),
          unsupported_extension(110),
          (255)
      } AlertDescription;

      struct {
          AlertLevel level;
          AlertDescription description;
      } Alert;

那么有了上面比较全面的 Alert 的数据可能值, 我们能不能看出, 我们上面例子中具体的 Alert leve 和 alert decription 呢? 我们把看到的26 字节的数据和上面的任意一个对比, 都不能找到匹配的, 为什么呢?

根据上面的协议描述, 我们知道 Alert 的数据部分(level 和 description) 属于 payload 的部分, 而 payload 部分根据 RFC5246 是被加密和压缩的, 所以我们不能像找 header 部分的对应关系一样, 找到对应的数据.

Like other messages, alert messages are encrypted and compressed, as specified by the current connection state

如何找出具体的level 和 description 对应关系

我们知道, 可以通过 log sslkey 的方式, 把加密数据的关键key 记录下来, 然后让 Wireshark 解密的时候使用. 参考:
MAC 上解密 https 协议
Windows 上解密 https 协议

如果你使用 curl, 那就更方便了:

  1. 设置 key 文件的位置
    export SSLKEYLOGFILE=~/Downloads/keylog.log
  2. 开始抓包

    $ host www.tianxiaohui.com
    www.tianxiaohui.com has address 103.144.218.5
    $ sudo tcpdump host 103.144.218.5 -w ~/Download/tianxiaohui.pcap
  3. 访问 https 网站
    curl -vvv https://www.tianxiaohui.com/
  4. 设置 Wireshark
    菜单 -> 选项 -> 协议 -> TLS -> (Pre)-Master-Secret log filename 为我们上面的 key 文件

如此设置之后, 我们就看到了解密之后的 payload: 它的 level 是 warning, 对应 0x1, 它的 description 是 0x0, 对应 close_notify.
clear_msg.png

TLS 协议的结束消息

看到这里, 大家可能突然明白, 针对我们最后使用 curl 访问 https 的这个例子, 这里的 close_notify 原来就是 TLS 协议的结束消息, 告诉对方, 这个 TLS session 要结束了. 类似于 tcp 协议的 FIN 消息. 当然 Alert 类型的里面, 还有更多的不同的 description, 不过我们这里的正好是 close_notify.
这样印证了, 在 Wireshark 知道 key 之前, 它根本无法知道具体的 alert 是什么. 所以, 我们仅仅根据 "Encrypted Alert" 这个消息, 也无法推断具体的 alert 是什么类型, 到底发生了什么.

参考:

  1. RFC5246 https://datatracker.ietf.org/doc/html/rfc5246#section-6.2.1
  2. https://hpbn.co/transport-layer-security-tls/
  3. https://slideplayer.com/slide/8107590/
  4. https://www.openssl.org/docs/manmaster/man3/SSL_shutdown.html

kubectl 常见命令

官方 kubectl 参考在这里: https://kubernetes.io/docs/reference/generated/kubectl/kubectl-commands

有关 Cluster 的

kubectl cluster-info
kubectl top pod
kubectl top node 
kubectl certificate
kubectl cordon
kubectl uncordon
kubectl drain
kubectl taint

有关 troubleshooting

kubectl describe # 比 get 得到更多信息
kubectl logs
kubectl attach 
kubectl exec
kubectl port-forward
kubectl proxy
kubectl cp
kubectl auth can-i list deployments --all-namespaces

有关 api-resource

kubectl api-resources //列出所有 resource 类型
kubectl explain pods
kubectl explain pod.spec
kubectl explain pod.spec.containers.securityContext
kubectl get pod -n myns --cluster clusterName
kubectl get pod -n myns --context clusterName

create resource

# kubectl apply 用于创建或更新资源,适合持续部署和配置管理。
# kubectl create 仅用于创建新资源,如果资源已经存在,命令将失败。
tess kubectl create -f FILENAME [options]
tess kubectl apply (-f FILENAME | -k DIRECTORY) [options]

Java 语言中的 assert 关键字

我们读有些 Java 代码的时候, 通常会遇到使用 assert 关键字的代码, 比如:
assert Thread.holdsLock(this);
那么这个 assert 在这里有什么用呢? 我们要不要使用它呢?

assert 是 JDK 1.4 引入的, 它通常有下面 2 种使用方式:
assert 以 boolen 值为结果的表达式;
或
assert 以 boolen 值为结果的表达式 : "某个字符串, 告诉为什么出错了";

默认情况下, 即便你们代码里写了 assert 关键字的代码, 它们也不会被执行. 通过下面 2 个启动参数, 我们可以控制是不是需要执行 assert 的代码:
-enableassertions 或 -ea
-disableassertions 或 -da

语法:

java [ -enableassertions | -ea  ] [:<package name>"..." | :<class name> ]

官方文档: https://docs.oracle.com/javase/7/docs/technotes/guides/language/assert.html

Java socket timeout 在 Linux 的实现

Block IO (SocketInputStream)

我们从 java.net.SocketInputStream 看起. 它的 read 方法传入 timeout 的值. 如果不传入 timeout 值, 则这个 timeout 的值是通过 setOption 设置的, 若没有设置过, 那么它的值就是 0.

也就是说每次 read 方法被调用, 都针对这次 read 操作有一个重新计时的 timeout时钟. 如果一个网络数据要读好几次, 每次 read 都会重新计时这个 timeout.

那么这个 timeout 最终在哪里使用的呢? 下面是每一步调用的栈:

  1. java.net.SocketInputStream.read(byte b[], int off, int length, int timeout)
  2. java.net.SocketInputStream.socketRead(FileDescriptor fd, byte b[], int off, int length, int timeout)
  3. native java.net.SocketInputStream.socketRead0(FileDescriptor fd, byte b[], int off, int length, int timeout)
  4. SocketInputStream.c
    Java_java_net_SocketInputStream_socketRead0(JNIEnv *env, jobject this, jobject fdObj, jbyteArray data, jint off, jint len, jint timeout) 点过上面链接, 仔细查看路径会发现, 这个SocketInputStream.c既不是 shared 代码, 也不是 Linux 的代码, 而是 Solaris, 而我们讨论的是 Linux 的代码, 为什么给出的是 Solaris 代码呢? 这个问答给出了明确的答案: SocketInputStream.c 包含了 net_util.h, 而 net_util.h 又包含了 net_util_md.h. 而 net_util_md.h 里面使用 #if defined(__linux__) || defined(MACOSX) 来区分不同的 OS.
  5. 上面的 SocketInputStream.c 又把 timeout 参数传给了 NET_Timeout 函数. NET_Timeout 在 linux_close.c (注意还是 Solaris 目录)里面定义:

    /*
    * Wrapper for poll(s, timeout).
    * Auto restarts with adjusted timeout if interrupted by
    * signal other than our wakeup signal.
    */
    int NET_Timeout(int s, long timeout) {
  6. 上面的注释中很好的说明了 它只是用来封装 poll 系统调用, 具体的调用就是:
    在一个无限循环中, 每次用剩余的 timeout 时间去看看有没有可读数据( poll()), 直到读到, 若没读到, 则计算剩余时间, 继续用剩余时间去读, 直到读到, 或者 timeout 没有剩余.

    if (timeout > 0) {
       gettimeofday(&t, NULL);
       prevtime = t.tv_sec * 1000  +  t.tv_usec / 1000;
     }
    
     for(;;) {
       struct pollfd pfd;
       int rv;
       threadEntry_t self;
    
       /*
        * Poll the fd. If interrupted by our wakeup signal
        * errno will be set to EBADF.
        */
       pfd.fd = s;
       pfd.events = POLLIN | POLLERR;
    
       startOp(fdEntry, &self);
       rv = poll(&pfd, 1, timeout);
       endOp(fdEntry, &self);
    
       /*
        * If interrupted then adjust timeout. If timeout
        * has expired return 0 (indicating timeout expired).
        */
       if (rv < 0 && errno == EINTR) {
           if (timeout > 0) {
               gettimeofday(&t, NULL);
               newtime = t.tv_sec * 1000  +  t.tv_usec / 1000;
               timeout -= newtime - prevtime;
               if (timeout <= 0) {
                   return 0;
               }
               prevtime = newtime;
           }
       } else {
           return rv;
       }
    
     }

    NIO (SocketChannel)

    Java New IO 可以选择是否是 block 模式.
    socketChannel.configureBlocking(false);
    这主要影响到这 3 个 API 的操作

  7. connect();
  8. read();
  9. write();

    注意到, 他们和之前 Blocking IO 的区别: 他们都没有地方设置 timeout 的值. 
    connect(): 若同步, 则等到建立连接或者 IO error 产生; 若异步, 则立马返回, 后续通过一直调用 finishConnect() 来测试是不是已经建立连接;
    read(): 若同步, 直到读到数据或 IO 出错; 若异步, 读一次, 立马返回, 不管是不是读到数据, 通过返回值判断读多少;
    write(): 若同步, 直到写完或者 IO 出错; 若异步, 写一次, 立马返回, 不管是不是有数据写出, 通过返回值判断写多少;

sun.nio.ch.SocketChannelImpl 里面的read()实现(写类似):

if (blocking) {
     do {
          n = IOUtil.read(fd, buf, -1, nd);
     } while (n == IOStatus.INTERRUPTED && isOpen());
} else {
     n = IOUtil.read(fd, buf, -1, nd);
}

即便使用 non-blocking 的方式, 为了保证连接建立好, 读完整, 写完数据, 就要业务线程使用 loop 的方式一直检查这些连接操作,读操作, 写操作是不是好了. 这样并不能带来更好的性能, 相反, 需要更多的 CPU 时间.

所以, 为了更好的性能, 就单独找个线程, 来负责所有这些 IO 操作的连接, 读, 写操作. 其它线程就去做其它事情去了, 这个负责 IO 操作的线程为了更好的同时处理多个 IO, 就使用 Selector 的方式, 相当于一个人看守多个流水线.

到此, 我们还没有涉及 timeout, 并且上面 API 中也根本没有提到 timeout, 那么 non-blocking 的方式中, timeout 是怎么实现的呢? 通常都是负责 IO 的线程来实现的, 它每次批量 select() 之后, 就会查看一遍是不是已经有超时的 IO. 比如 JDK 自带的 jdk.internal.net.http.HttpClientImpl 中的 purgeTimeoutsAndReturnNextDeadline 方法, 就负责每次 select() 完查看是否有 timeout 的 IO.
https://github.com/AdoptOpenJDK/openjdk-jdk11/blob/master/src/java.net.http/share/classes/jdk/internal/net/http/HttpClientImpl.java#L1210

Blocking IO 里面的 timeout 每次调用 read/write API 的时候, 直接传到 epoll() 方法去的, 也就是说如果一个 response 调用了 read() 多次, 那么每次 read() 都是重新计时 timeout 的值. 但是在 NIO 里面, 我们看到 JDK 提供的这个HttpClientImpl 实现里面, 使用 NIO 的方式, 它每次调用 read() 方法是不会 reset timeout 值的, 这个 timeout 值的使用方式是: 从一开始便扣减里面剩余的时间. 这 2 种方式对于读一个很大数据流是有影响的.

另外这篇文章详细介绍了各种 http client 是否支持 sync, async, 是使用 future 的方式, 还是使用 callback 的方式:
https://www.mocklab.io/blog/which-java-http-client-should-i-use-in-2020/

可以调节下面代码的 connect timeout 和 read timeout 值, 来观察或者 debug 是如何实现的 timeout:

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpClient.Version;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.time.Duration;

public class NioTest {

    public static void main(String[] args) throws IOException, InterruptedException {
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create("https://www.tianxiaohui.com/"))
                .timeout(Duration.ofMillis(1000))
                .header("Content-Type", "application/json")
                 .GET()
                .build();
        HttpClient client = HttpClient.newBuilder()
                  .version(Version.HTTP_1_1)
                  .connectTimeout(Duration.ofMillis(1000))
                  .build();
        
        client.sendAsync(request, BodyHandlers.ofString())
                .thenApply(HttpResponse::body)
                .thenAccept(System.out::println)
                .exceptionally(t -> {System.out.println(t.getMessage());t.printStackTrace(); return null;}); 
           
           try {
            Thread.sleep(600000);
            System.out.println();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}