Gopher指北

Gopher指北 查看完整档案

上海编辑武汉理工大学  |  物联网工程 编辑  |  填写所在公司/组织 isites.github.io/ 编辑
编辑

欢迎关注公众号: Gopher指北

个人动态

Gopher指北 发布了文章 · 2月10日

1分钟内的Linux性能分析法

来自公众号:新世界杂货铺
本着“拿来主义”的精神,吸收他人长处为己用。老许翻译一篇Linux性能分析相关的文章分享给各位读者,同时也加深自己的印象。

你登录到具有性能问题的Linux服务器时,第一分钟要检查什么?

在Netflix,我们拥有庞大的Linux EC2云实例,以及大量的性能分析工具来监视和调查它们的性能。这些工具包括AtlasVectorAtlas用于全云监控,Vector用于按需实例分析。这些工具能帮助我们解决大部分问题,但有时候我们仍需登录实例并运行一些标准的Linux性能工具。

Atlas:根据github上面的文档老许简单说一下自己的认知。一个可以管理基于时间维度数据的后端,同时具有内存存储功能可以非常快速地收集和报告大量指标。

Vector:Vector是一个主机上的性能监视框架,它可以将各种指标展示在工程师的浏览器上面。

总结

在这篇文章中,Netflix性能工程团队将向您展示通过命令行进行性能分析是,前60秒应该使用那些Linux标准工具。在60秒内,你可以通过以下10个命令来全面了解系统资源使用情况和正在运行的进程。首先寻找错误和饱和指标,因为他们很容易理解,然后是资源利用率。饱和是指资源负载超出其处理能力,其可以表现为一个请求队列的长度或者等待时间。

uptime
dmesg | tail
vmstat 1
mpstat -P ALL 1
pidstat 1
iostat -xz 1
free -m
sar -n DEV 1
sar -n TCP,ETCP 1
top

其中一些命令需要安装sysstat软件包。这些命令暴露的指标是一种帮助你完成USE Method(Utilization Saturation and Errors Method)——一种查找性能瓶颈的方法。这涉及检查所有资源(CPU、内存、磁盘等)利用率,饱和度和错误等指标。同时还需注意通过排除法可以逐步缩小资源检查范围。

以下各节通过生产系统中的示例总结了这些命令。这些命令的更多信息,请参考使用手册。

uptime

$ uptime 
23:51:26 up 21:31, 1 user, load average: 30.02, 26.43, 19.02

这是一种快速查看平均负载的方法,它指示了等待运行的进程数量。在Linux系统上,这些数字包括要在CPU上运行的进程以及处于I/O(通常是磁盘I/O)阻塞的进程。这提供了资源负载的大概状态,没有其他工具就无法理解更多。仅值得一看。

这三个数字分别代表着1分钟、5分钟和15分钟内的平均负载。这三个指标让我们了解负载是如何随时间变化的。例如,你被要求检查有问题的服务器,而1分钟的值远低于15分钟的值,则意味着你可能登录的太晚而错过了问题现场。

在上面的例子中,最近的平均负载增加,一分钟值达到30,而15分钟值达到19。数字如此之大意味着很多:可能是CPU需求(可以通过后文中介绍的vmstat或mpstat命令来确认)。

dmesg | tail

$ dmesg | tail
[1880957.563150] perl invoked oom-killer: gfp_mask=0x280da, order=0, oom_score_adj=0
[...]
[1880957.563400] Out of memory: Kill process 18694 (perl) score 246 or sacrifice child
[1880957.563408] Killed process 18694 (perl) total-vm:1972392kB, anon-rss:1953348kB, file-rss:0kB
[2320864.954447] TCP: Possible SYN flooding on port 7001. Dropping request.  Check SNMP counters.

如果有消息,它将查看最近的10条系统消息。通过此命令查找可能导致性能问题的错误。上面的示例包括oom-killer和TCP丢弃请求。

不要错过这一步!dmesg始终值得被检查。

vmstat 1

$ vmstat 1
procs ---------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
34  0    0 200889792  73708 591828    0    0     0     5    6   10 96  1  3  0  0
32  0    0 200889920  73708 591860    0    0     0   592 13284 4282 98  1  1  0  0
32  0    0 200890112  73708 591860    0    0     0     0 9501 2154 99  1  0  0  0
32  0    0 200889568  73712 591856    0    0     0    48 11900 2459 99  0  0  0  0
32  0    0 200890208  73712 591860    0    0     0     0 15898 4840 98  1  1  0  0
^C

vmstat是虚拟内存状态的缩写。它在每一行上打印关键服务的统计信息。

vmstat在参数1下运行,以显示一秒钟的摘要。在某些版本中,第一行的某些列展示的是自启动以来的平均值,而不是前一秒的平均值。现在请跳过第一行,除非你想学习并记住那一列是那一列。

要检查的列:

  • r:在CPU上运行并等待切换的进程数。这为确定CPU饱和比平均负载提供了更好的信号,因为它不包括I/O。简单来说就是:r的值大于CPU数量即为饱和状态。
  • free:可用内存以字节为单位,如果数字很大,则说明你有足够的可用内存。free -m命令能够更好的描述此状态。
  • si, so:swap-ins和swap-outs. 如果这两个值不为0,则说明内存不足。
  • us, sy, id, wa, st:这是总CPU时间的百分比。他们分别是用户时间、系统时间(内核)、空闲时间(包括I/O等待)、I/O等待和被盗时间(虚拟机所消耗的时间)。

image

最后关于us, sy, id, wa, st的解释和原文不太一样,所以老许贴一下vmstat手册中的解释。

通过用户时间+系统时间来确认CPU是否繁忙。如果有持续的等待I/O,意味着磁盘瓶颈。这是CPU空闲的时候,因为任务等待I/O被阻塞。你可以将I/O等待视为CPU空闲的另一种形式,同时它也提供了CPU为什么空闲的线索。

I/O处理需要消耗系统时间。一个系统时间占比较高(比如超过20%)值得进一步研究,可能是内核处理I/O的效率低下。

在上面的例子中,CPU时间几乎完全处于用户级别,即CPU时间几乎被应用程序占用。CPU平均利用率也超过90%,这不一定是问题,还需要通过r列的值检查饱和度。

mpstat -P ALL 1

$ mpstat -P ALL 1
Linux 3.13.0-49-generic (titanclusters-xxxxx)  07/14/2015  _x86_64_ (32 CPU)

07:38:49 PM  CPU   %usr  %nice   %sys %iowait   %irq  %soft  %steal  %guest  %gnice  %idle
07:38:50 PM  all  98.47   0.00   0.75    0.00   0.00   0.00    0.00    0.00    0.00   0.78
07:38:50 PM    0  96.04   0.00   2.97    0.00   0.00   0.00    0.00    0.00    0.00   0.99
07:38:50 PM    1  97.00   0.00   1.00    0.00   0.00   0.00    0.00    0.00    0.00   2.00
07:38:50 PM    2  98.00   0.00   1.00    0.00   0.00   0.00    0.00    0.00    0.00   1.00
07:38:50 PM    3  96.97   0.00   0.00    0.00   0.00   0.00    0.00    0.00    0.00   3.03
[...]

此命令用于显示每个CPU的CPU时间明细,可用于检查不平衡的情况。单个热CPU可能是因为存在一个单线程应用。

pidstat 1

$ pidstat 1
Linux 3.13.0-49-generic (titanclusters-xxxxx)  07/14/2015    _x86_64_    (32 CPU)

07:41:02 PM   UID       PID    %usr %system  %guest    %CPU   CPU  Command
07:41:03 PM     0         9    0.00    0.94    0.00    0.94     1  rcuos/0
07:41:03 PM     0      4214    5.66    5.66    0.00   11.32    15  mesos-slave
07:41:03 PM     0      4354    0.94    0.94    0.00    1.89     8  java
07:41:03 PM     0      6521 1596.23    1.89    0.00 1598.11    27  java
07:41:03 PM     0      6564 1571.70    7.55    0.00 1579.25    28  java
07:41:03 PM 60004     60154    0.94    4.72    0.00    5.66     9  pidstat

07:41:03 PM   UID       PID    %usr %system  %guest    %CPU   CPU  Command
07:41:04 PM     0      4214    6.00    2.00    0.00    8.00    15  mesos-slave
07:41:04 PM     0      6521 1590.00    1.00    0.00 1591.00    27  java
07:41:04 PM     0      6564 1573.00   10.00    0.00 1583.00    28  java
07:41:04 PM   108      6718    1.00    0.00    0.00    1.00     0  snmp-pass
07:41:04 PM 60004     60154    1.00    4.00    0.00    5.00     9  pidstat
^C

pidstat有点像top的每个进程摘要,但是会打印滚动摘要,而不是清除屏幕。这对于观察随时间变化的模式很有用,还可以将看到的内容记录下来。

上面的示例中,两个java进程消耗了大部分CPU时间。%CPU这一列是所有CPU的总和。1591%意味着java进程几乎耗尽了16个CPU。

iostat -xz 1

$ iostat -xz 1
Linux 3.13.0-49-generic (titanclusters-xxxxx)  07/14/2015  _x86_64_ (32 CPU)

avg-cpu:  %user   %nice %system %iowait  %steal   %idle
          73.96    0.00    3.73    0.03    0.06   22.21

Device:   rrqm/s   wrqm/s     r/s     w/s    rkB/s    wkB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
xvda        0.00     0.23    0.21    0.18     4.52     2.08    34.37     0.00    9.98   13.80    5.42   2.44   0.09
xvdb        0.01     0.00    1.02    8.94   127.97   598.53   145.79     0.00    0.43    1.78    0.28   0.25   0.25
xvdc        0.01     0.00    1.02    8.86   127.79   595.94   146.50     0.00    0.45    1.82    0.30   0.27   0.26
dm-0        0.00     0.00    0.69    2.32    10.47    31.69    28.01     0.01    3.23    0.71    3.98   0.13   0.04
dm-1        0.00     0.00    0.00    0.94     0.01     3.78     8.00     0.33  345.84    0.04  346.81   0.01   0.00
dm-2        0.00     0.00    0.09    0.07     1.35     0.36    22.50     0.00    2.55    0.23    5.62   1.78   0.03
[...]
^C

这是一个非常好的工具,不仅可以了解块设备(磁盘)的工作负载还可以了解其性能。

  • r/s, w/s, rkB/s, wkB/s:分别表示每秒交付给设备的读写请求数和每秒读写的KB数。这些可以描述设备的工作负载。性能问题可能仅仅是由于施加了过多的负载。
  • await:I/O处理时间(毫秒为单位),这包括队列中请求所花费的时间以及为请求服务所花费的时间。如果值大于预期的平均时间,可能是因为设备已经饱和或设备出现问题。
  • avgqu-sz:发送给设备请求的平均队列长度。该值大于1表明设备已达饱和状态(尽管设备通常可以并行处理请求,尤其是有多个后端磁盘的虚拟设备)。
  • %util:设备利用率。这是一个显示设备是否忙碌的百分比,其含义为设备每秒的工作时间占比。该值大于60%时通常会导致性能不佳(可以在await中看出来),不过它也和具体的设备有关。值接近100%时,意味着设备已饱和。

image

关于avgqu-sz的解释和原文不太一样,所以老许贴一下iostat手册中的解释。

如果存储设备是位于很多磁盘前面的逻辑磁盘设备,则100%利用率可能仅仅意味着所有时间都在处理I/O,但是后端磁盘可能远远还没有饱和,而且还能处理更多的工作。

请记住,磁盘I/O性能不佳不一定是应用程序的问题。通常使用许多技术来异步执行I/O,以保证应用程序不被阻塞或直接遭受延迟(例如,预读用于读取,缓冲用于写入)。

free -m

$ free -m
             total       used       free     shared    buffers     cached
Mem:        245998      24545     221453         83         59        541
-/+ buffers/cache:      23944     222053
Swap:            0          0          0

看最右边两列:

  • buffers:缓冲区缓存,用于块设备I/O。
  • cached:页缓存,用于文件系统。

我们检查他们的值是否接近0,接近0会导致更高的磁盘I/O(可以通过iostat来确认)以及更糟糕的磁盘性能。上面的示例看起来不错,每个值都有许多兆字节。

-/+ buffers/cache为已用内存和可用内存提供更加清晰的描述。Linux将部分空闲内存用作缓存,但是在应用程序需要时可以快速回收。因此,用作缓存的内存应该应该以某种方式包含在free这一列,-/+ buffers/cache这一行就是做这个事情的。

上面这一段翻译,可能比较抽象,感觉说的不像人话,老许来转述成人能理解的话:

total = used + free

used = (-/+ buffers/cache这一行used对应列) + buffers + cached

=> 24545 = 23944 + 59 + 541

free = (-/+ buffers/cache这一行free对应列) - buffers - cached

=> 221453 = 222053 - 59 - 541

如果在Linux使用了ZFS会令人更加疑惑(就像我们对某些服务所做的一样),因为ZFS有自己的文件系统缓存。而free -m并不能正确反应该文件系统缓存。它可能表现为,系统可用内存不足,而实际上该内存可根据需要从ZFS缓存中使用。

ZFS: Zettabyte File System,也叫动态文件系统,更多信息见百度百科

sar -n DEV 1

$ sar -n DEV 1
Linux 3.13.0-49-generic (titanclusters-xxxxx)  07/14/2015     _x86_64_    (32 CPU)

12:16:48 AM     IFACE   rxpck/s   txpck/s    rxkB/s    txkB/s   rxcmp/s   txcmp/s  rxmcst/s   %ifutil
12:16:49 AM      eth0  18763.00   5032.00  20686.42    478.30      0.00      0.00      0.00      0.00
12:16:49 AM        lo     14.00     14.00      1.36      1.36      0.00      0.00      0.00      0.00
12:16:49 AM   docker0      0.00      0.00      0.00      0.00      0.00      0.00      0.00      0.00

12:16:49 AM     IFACE   rxpck/s   txpck/s    rxkB/s    txkB/s   rxcmp/s   txcmp/s  rxmcst/s   %ifutil
12:16:50 AM      eth0  19763.00   5101.00  21999.10    482.56      0.00      0.00      0.00      0.00
12:16:50 AM        lo     20.00     20.00      3.25      3.25      0.00      0.00      0.00      0.00
12:16:50 AM   docker0      0.00      0.00      0.00      0.00      0.00      0.00      0.00      0.00
^C

可以用这个工具检查网络接口的吞吐量: rxkB/s和txkB/s。作为工作负载的度量,还可以检查吞吐量是否达到上限。在上面的列子中,eth0的接受速度达到22Mbyte/s(176Mbit/s),该值远低于1Gbit/s的限制。

原文中无rxkB/s和txkB/s的解释,老许特意找了使用手册中的说明。

image

这个版本还有%ifutil作设备利用率,这也是我们使用Brendan的nicstat工具来测量的。和nicstat工具一样,这很难正确,而且本例中看起来该值并不起作用。

老许试了一下自己的云服务发现%ifutil指标并不一定都有。

image

sar -n TCP,ETCP 1

$ sar -n TCP,ETCP 1
Linux 3.13.0-49-generic (titanclusters-xxxxx)  07/14/2015    _x86_64_    (32 CPU)

12:17:19 AM  active/s passive/s    iseg/s    oseg/s
12:17:20 AM      1.00      0.00  10233.00  18846.00

12:17:19 AM  atmptf/s  estres/s retrans/s isegerr/s   orsts/s
12:17:20 AM      0.00      0.00      0.00      0.00      0.00

12:17:20 AM  active/s passive/s    iseg/s    oseg/s
12:17:21 AM      1.00      0.00   8359.00   6039.00

12:17:20 AM  atmptf/s  estres/s retrans/s isegerr/s   orsts/s
12:17:21 AM      0.00      0.00      0.00      0.00      0.00
^C

这是一些关键TCP指标的总结。其中包括:

  • active/s:本地每秒启动的TCP连接数(例如,通过connect())。
  • passive/s:远程每秒启动的TCP连接数(例如,通过accept())
  • retrans/s:TCP每秒重传次数。

active和passive连接数通常用于服务器负载的粗略度量。将active视为向外的连接,passive视为向内的连接可能会有帮助,但这样区分并不严格(例如,localhost连接到localhost)。

重传是网络或服务器出问题的迹象。它可能是不可靠的网络(例如,公共Internet),也可能是由于服务器过载并丢弃了数据包。上面的示例显示每秒仅一个新的TCP连接。

top

$ top
top - 00:15:40 up 21:56,  1 user,  load average: 31.09, 29.87, 29.92
Tasks: 871 total,   1 running, 868 sleeping,   0 stopped,   2 zombie
%Cpu(s): 96.8 us,  0.4 sy,  0.0 ni,  2.7 id,  0.1 wa,  0.0 hi,  0.0 si,  0.0 st
KiB Mem:  25190241+total, 24921688 used, 22698073+free,    60448 buffers
KiB Swap:        0 total,        0 used,        0 free.   554208 cached Mem

   PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
 20248 root      20   0  0.227t 0.012t  18748 S  3090  5.2  29812:58 java
  4213 root      20   0 2722544  64640  44232 S  23.5  0.0 233:35.37 mesos-slave
 66128 titancl+  20   0   24344   2332   1172 R   1.0  0.0   0:00.07 top
  5235 root      20   0 38.227g 547004  49996 S   0.7  0.2   2:02.74 java
  4299 root      20   0 20.015g 2.682g  16836 S   0.3  1.1  33:14.42 java
     1 root      20   0   33620   2920   1496 S   0.0  0.0   0:03.82 init
     2 root      20   0       0      0      0 S   0.0  0.0   0:00.02 kthreadd
     3 root      20   0       0      0      0 S   0.0  0.0   0:05.35 ksoftirqd/0
     5 root       0 -20       0      0      0 S   0.0  0.0   0:00.00 kworker/0:0H
     6 root      20   0       0      0      0 S   0.0  0.0   0:06.94 kworker/u256:0
     8 root      20   0       0      0      0 S   0.0  0.0   2:38.05 rcu_sched

top命令包含我们之前检查的许多指标。运行它可以很方便地查看是否有任何东西和之前的命令结果差别很大。

top的缺点是随着时间推移不能看到相关变化,像vmstat和pidstat之类提供滚动输出的工具则能体现的更加清楚。如果你没有足够快地暂停输出(Ctrl-S暂停, Ctrl-Q继续),随着屏幕的清除间歇性问题的证据很有可能丢失。

最后,衷心希望本文能够对各位读者有一定的帮助。

翻译原文

https://netflixtechblog.com/l...

查看原文

赞 6 收藏 6 评论 0

Gopher指北 发布了文章 · 1月18日

Go中的SSRF攻防战

来自公众号:新世界杂货铺

写在最前面

“年年岁岁花相似,岁岁年年人不同”,没有什么是永恒的,很多东西都将成为过去式。比如,我以前在文章中自称“笔者”,细细想来这个称呼还是有一定的距离感,经过一番深思熟虑后,我打算将文章中的自称改为“老许”。

关于自称,老许就不扯太远了,下面还是回到本篇的主旨。

什么是SSRF

SSRF英文全拼为Server Side Request Forgery,翻译为服务端请求伪造。攻击者在未能取得服务器权限时,利用服务器漏洞以服务器的身份发送一条构造好的请求给服务器所在内网。关于内网资源的访问控制,想必大家心里都有数。

image

上面这个说法如果不好懂,那老许就直接举一个实际例子。现在很多写作平台都支持通过URL的方式上传图片,如果服务器对URL校验不严格,此时就为恶意攻击者提供了访问内网资源的可能。

“千里之堤,溃于蚁穴”,任何可能造成风险的漏洞我们程序员都不应忽视,而且这类漏洞很有可能会成为别人绩效的垫脚石。为了不成为垫脚石,下面老许就和各位读者一起看一下SSRF的攻防回合。

回合一:千变万化的内网地址

为什么用“千变万化”这个词?老许先不回答,请各位读者耐心往下看。下面,老许用182.61.200.7(www.baidu.com的一个IP地址)这个IP和各位读者一起复习一下IPv4的不同表示方式。

image.png

注意⚠️:点分混合制中,以点分割地每一部分均可以写作不同的进制(仅限于十、八和十六进制)。

上面仅是IPv4的不同表现方式,IPv6的地址也有三种不同表示方式。而这三种表现方式又可以有不同的写法。下面以IPv6中的回环地址0:0:0:0:0:0:0:1为例。

image.png

注意⚠️:冒分十六进制表示法中每个X的前导0是可以省略的,那么我可以部分省略,部分不省略,从而将一个IPv6地址写出不同的表现形式。0位压缩表示法和内嵌IPv4地址表示法同理也可以将一个IPv6地址写出不同的表现形式。

讲了这么多,老许已经无法统计一个IP可以有多少种不同的写法,麻烦数学好的算一下。

内网IP你以为到这儿就完了嘛?当然不!不知道各位读者有没有听过xip.io这个域名。xip可以帮你做自定义的DNS解析,并且可以解析到任意IP地址(包括内网)。

image

我们通过xip提供的域名解析,还可以将内网IP通过域名的方式进行访问。

关于内网IP的访问到这儿仍将继续!搞过Basic验证的应该都知道,可以通过http://user:passwd@hostname/进行资源访问。如果攻击者换一种写法或许可以绕过部分不够严谨的逻辑,如下所示。

image

关于内网地址,老许掏空了所有的知识储备总结出上述内容,因此老许说一句千变万化的内网地址不过分吧!

此时此刻,老许只想问一句,当恶意攻击者用这些不同表现形式的内网地址进行图片上传时,你怎么将其识别出来并拒绝访问。不会真的有大佬用正则表达式完成上述过滤吧,如果有请留言告诉我让小弟学习一下。

花样百出的内网地址我们已经基本了解,那么现在的问题是怎么将其转为一个我们可以进行判断的IP。总结上面的内网地址可分为三类:一、本身就是IP地址,仅表现形式不统一;二、一个指向内网IP的域名;三、一个包含Basic验证信息和内网IP的地址。根据这三类特征,在发起请求之前按照如下步骤可以识别内网地址并拒绝访问。

  1. 解析出地址中的HostName。
  2. 发起DNS解析,获得IP。
  3. 判断IP是否是内网地址。

上述步骤中关于内网地址的判断,请不要忽略IPv6的回环地址和IPv6的唯一本地地址。下面是老许判断IP是否为内网IP的逻辑。

// IsLocalIP 判断是否是内网ip
func IsLocalIP(ip net.IP) bool {
    if ip == nil {
        return false
    }
    // 判断是否是回环地址, ipv4时是127.0.0.1;ipv6时是::1
    if ip.IsLoopback() {
        return true
    }
    // 判断ipv4是否是内网
    if ip4 := ip.To4(); ip4 != nil {
        return ip4[0] == 10 || // 10.0.0.0/8
            (ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31) || // 172.16.0.0/12
            (ip4[0] == 192 && ip4[1] == 168) // 192.168.0.0/16
    }
    // 判断ipv6是否是内网
    if ip16 := ip.To16(); ip16 != nil {
        // 参考 https://tools.ietf.org/html/rfc4193#section-3
        // 参考 https://en.wikipedia.org/wiki/Private_network#Private_IPv6_addresses
        // 判断ipv6唯一本地地址
        return 0xfd == ip16[0]
    }
    // 不是ip直接返回false
    return false
}

下图为按照上述步骤检测请求是否是内网请求的结果。

image

小结:URL形式多样,可以使用DNS解析获取规范的IP,从而判断是否是内网资源。

回合二:URL跳转

如果恶意攻击者仅通过IP的不同写法进行攻击,那我们自然可以高枕无忧,然而这场矛与盾的较量才刚刚开局。

我们回顾一下回合一的防御策略,检测请求是否是内网资源是在正式发起请求之前,如果攻击者在请求过程中通过URL跳转进行内网资源访问则完全可以绕过回合一中的防御策略。具体攻击流程如下。

image

如图所示,通过URL跳转攻击者可获得内网资源。在介绍如何防御URL跳转攻击之前,老许和各位读者先一起复习一下HTTP重定向状态码——3xx。

根据维基百科的资料,3xx重定向码范围从300到308共9个。老许特意瞧了一眼go的源码,发现官方的http.Client发出的请求仅支持如下几个重定向码。

301:请求的资源已永久移动到新位置;该响应可缓存;重定向请求一定是GET请求。

302:要求客户端执行临时重定向;只有在Cache-Control或Expires中进行指定的情况下,这个响应才是可缓存的;重定向请求一定是GET请求。

303:当POST(或PUT / DELETE)请求的响应在另一个URI能被找到时可用此code,这个code存在主要是为了允许由脚本激活的POST请求输出重定向到一个新的资源;303响应禁止被缓存;重定向请求一定是GET请求。

307:临时重定向;不可更改请求方法,如果原请求是POST,则重定向请求也是POST。

308:永久重定向;不可更改请求方法,如果原请求是POST,则重定向请求也是POST。

3xx状态码复习就到这里,我们继续SSRF的攻防回合讨论。既然服务端的URL跳转可能带来风险,那我们只要禁用URL跳转就完全可以规避此类风险。然而我们并不能这么做,这个做法在规避风险的同时也极有可能误伤正常的请求。那到底该如何防范此类攻击手段呢?

看过老许“Go中的HTTP请求之——HTTP1.1请求流程分析”这篇文章的读者应该知道,对于重定向有业务需求时,可以自定义http.Client的CheckRedirect。下面我们先看一下CheckRedirect的定义。

CheckRedirect func(req *Request, via []*Request) error

这里特别说明一下,req是即将发出的请求且请求中包含前一次请求的响应,via是已经发出的请求。在知晓这些条件后,防御URL跳转攻击就变得十分容易了。

  1. 根据前一次请求的响应直接拒绝307308的跳转(此类跳转可以是POST请求,风险极高)。
  2. 解析出请求的IP,并判断是否是内网IP。

根据上述步骤,可如下定义http.Client

client := &http.Client{
    CheckRedirect: func(req *http.Request, via []*http.Request) error {
        // 跳转超过10次,也拒绝继续跳转
        if len(via) >= 10 {
            return fmt.Errorf("redirect too much")
        }
        statusCode := req.Response.StatusCode
        if statusCode == 307 || statusCode == 308 {
            // 拒绝跳转访问
            return fmt.Errorf("unsupport redirect method")
        }
        // 判断ip
        ips, err := net.LookupIP(req.URL.Host)
        if err != nil {
            return err
        }
        for _, ip := range ips {
            if IsLocalIP(ip) {
                return fmt.Errorf("have local ip")
            }
            fmt.Printf("%s -> %s is localip?: %v\n", req.URL, ip.String(), IsLocalIP(ip))
        }
        return nil
    },
}

如上自定义CheckRedirect可以防范URL跳转攻击,但此方式会进行多次DNS解析,效率不佳。后文会结合其他攻击方式介绍更加有效率的防御措施。

小结:通过自定义http.ClientCheckRedirect可以防范URL跳转攻击。

回合三:DNS Rebinding

众所周知,发起一次HTTP请求需要先请求DNS服务获取域名对应的IP地址。如果攻击者有可控的DNS服务,就可以通过DNS重绑定绕过前面的防御策略进行攻击。

具体流程如下图所示。

image

验证资源是是否合法时,服务器进行了第一次DNS解析,获得了一个非内网的IP且TTL为0。对解析的IP进行判断,发现非内网IP可以后续请求。由于攻击者的DNS Server将TTL设置为0,所以正式发起请求时需要再次进行DNS解析。此时DNS Server返回内网地址,由于已经进入请求资源阶段再无防御措施,所以攻击者可获得内网资源。

额外提一嘴,老许特意看了Go中DNS解析的部分源码,发现Go并没有对DNS的结果作缓存,所以即使TTL不为0也存在DNS重绑定的风险。

在发起请求的过程中有DNS解析才让攻击者有机可乘。如果我们能对该过程进行控制,就可以避免DNS重绑定的风险。对HTTP请求控制可以通过自定义http.Transport来实现,而自定义http.Transport也有两个方案。

方案一

dialer := &net.Dialer{}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
    host, port, err := net.SplitHostPort(addr)
    // 解析host和 端口
    if err != nil {
        return nil, err
    }
    // dns解析域名
    ips, err := net.LookupIP(host)
    if err != nil {
        return nil, err
    }
    // 对所有的ip串行发起请求
    for _, ip := range ips {
        fmt.Printf("%v -> %v is localip?: %v\n", addr, ip.String(), IsLocalIP(ip))
        if IsLocalIP(ip) {
            continue
        }
        // 非内网IP可继续访问
        // 拼接地址
        addr := net.JoinHostPort(ip.String(), port)
        // 此时的addr仅包含IP和端口信息
        con, err := dialer.DialContext(ctx, network, addr)
        if err == nil {
            return con, nil
        }
        fmt.Println(err)
    }

    return nil, fmt.Errorf("connect failed")
}
// 使用此client请求,可避免DNS重绑定风险
client := &http.Client{
    Transport: transport,
}

transport.DialContext的作用是创建未加密的TCP连接,我们通过自定义此函数可规避DNS重绑定风险。另外特别说明一下,如果传递给dialer.DialContext方法的地址是常规IP格式则可使用net包中的parseIPZone函数直接解析成功,否则会继续发起DNS解析请求。

方案二

dialer := &net.Dialer{}
dialer.Control = func(network, address string, c syscall.RawConn) error {
    // address 已经是ip:port的格式
    host, _, err := net.SplitHostPort(address)
    if err != nil {
        return err
    }
    fmt.Printf("%v is localip?: %v\n", address, IsLocalIP(net.ParseIP(host)))
    return nil
}
transport := http.DefaultTransport.(*http.Transport).Clone()
// 使用官方库的实现创建TCP连接
transport.DialContext = dialer.DialContext
// 使用此client请求,可避免DNS重绑定风险
client := &http.Client{
    Transport: transport,
}

dialer.Control在创建网络连接之后实际拨号之前调用,且仅在go版本大于等于1.11时可用,其具体调用位置在sock_posix.go中的(*netFD).dial方法里。

image

上述两个防御方案不仅仅可以防范DNS重绑定攻击,也同样可以防范其他攻击方式。事实上,老许更加推荐方案二,简直一劳永逸!

小结

  1. 攻击者可以通过自己的DNS服务进行DNS重绑定攻击。
  2. 通过自定义http.Transport可以防范DNS重绑定攻击。

个人经验

1、不要下发详细的错误信息!不要下发详细的错误信息!不要下发详细的错误信息!

如果是为了开发调试,请将错误信息打进日志文件里。强调这一点不仅仅是为了防范SSRF攻击,更是为了避免敏感信息泄漏。例如,DB操作失败后直接将error信息下发,而这个error信息很有可能包含SQL语句。

再额外多说一嘴,老许的公司对打进日志文件的某些信息还要求脱敏,可谓是十分严格了。

2、限制请求端口。

在结束之前特别说明一下,SSRF漏洞并不只针对HTTP协议。本篇只讨论HTTP协议是因为go中通过http.Client发起请求时会检测协议类型,某P*P语言这方面检测就会弱很多。虽然http.Client会检测协议类型,但是攻击者仍然可以通过漏洞不断更换端口进行内网端口探测。

最后,衷心希望本文能够对各位读者有一定的帮助。

  1. 写本文时, 笔者所用go版本为: go1.15.2
  2. 文章中所用完整例子:https://github.com/Isites/go-...
查看原文

赞 4 收藏 2 评论 0

Gopher指北 发布了文章 · 2020-12-24

灵魂一问:数据库连接池到底该怎么配?

来自公众号:新世界杂货铺
好家伙,我直接好家伙!GitHub不愧是全球最大的同性交友网站,资源丰富且质量高!

连接池的配置应该按照什么原则来?这个问题在笔者心中疑惑良久,直到在GitHub上发现了About Pool Sizing这篇文章。看完之后一扫笔者心中阴霾,神清气爽。妈妈再也不用担心我在项目中瞎配连接池啦!

以下内容为笔者根据原文翻译总结所得。

原文提到开发人员经常会将连接池配置错误,而要想正确配置连接池需要理解一些原则,即使这些原则可能违反人类直觉。

1万并发用户访问

假设你有一个需要每秒处理2万事务的网站,你的连接池应该配置多大?令人惊讶的是,这个问题不应该是连接池配置多大而是应该配置多小。

下面这个视频是Oracle Real-World Performance组发布的(笔者建议各位读者架个梯子亲自看看):

https://www.youtube.com/watch...

下面笔者对视频中的内容进行简单的概括。

注:视频中是对Oracle数据库进行测试,笔者在开发/线上环境均使用Mysql数据库,但触类旁通,该测试结果仍具有很高的参考价值。

压测初始配置如下:

并发线程数9600
每两次访问数据库之间Sleep550ms
初始线程池大小2048

笔者随机截取视频中第一次压测结果如下:

image

Sessions达到2048时,Queue-ms(每个请求在队列中的等待时间)为30ms,Run-ms(SQL执行时间)为71ms,同时还有很多buffer busy waits

接下来其他条件不动,仅将初始线程池大小设置为1024得到如下结果:

image

Sessions达到1024时,Queue-ms(38ms)和第一次相比可以认为几乎没有变化,Run-ms(30ms)和第一次相比明显减少,buffer busy waits和第一次相比也明显减少。

最后,将线程池大小设置为96,其他条件不变得到如下结果:

image

此时,Queue-ms和Run-ms耗时极短并且看不到任何的buffer busy waits。好家伙,原作者对此时的结果直接打了一个✅。

最后,一起对比一下三次压测结果的吞吐量:

image

图中上半部分为耗时因子,下半部分为吞吐量。红框、蓝框和黄框分别代表着链接池大小为2048、1024和96的吞吐量。由折线图知当连接池大小为96时吞吐量明显上升。

没有做任何其他调整,仅减小连接池大小就可将应用程序的性能提升近50倍!

But why?

为什么nginx只用4个线程发挥出的性能就大大超越了100个进程的Apache Web服务器?回想一下计算机科学的基础知识,答案其实是很明显的。

即使是单核的计算机也可以“同时”支持数十个或数百个线程。但是我们都应该知道,这仅仅是操作系统通过时间分片交替执行的一个小把戏,实际上,单个内核一次只能执行一个线程,然后操作系统切换上下文执行另一个线程的代码,依此类推。给定一颗CPU核心,其顺序执行AB永远比通过时间分片“同时”执行A和B要快,这是一条计算机科学的基本法则。一旦线程的数量超过了CPU核心的数量,再增加线程数系统就只会更慢,而不是更快。

笔者认为上述A,B在没有I/O阻塞时,顺序执行才比同时执行更快。

有限的资源

当我们排查数据库的性能瓶颈时,它们可以概括为三个类别:CPU,磁盘,网络。内存和磁盘、网络相比,带宽高出好几个数量级故忽略此排查方向。

如果我们忽略磁盘和网络,那就更加简单了。在一个8核的服务器上,将连接数设置为8将提供最佳性能,再增加连接数就会因上下文切换的损耗导致性能下降。但是我们不能忽视磁盘和网络。

数据库通常将数据存储在磁盘上,对于老式的机械硬盘存在寻址时间成本和旋转时间成本。在这段时间内( I/O等待),连接/查询/线程被阻塞以等待磁盘,此时操作系统控制CPU执行其他线程代码以更好地利用CPU资源。所以,由于线程在I/O上阻塞,我们可以让线程/连接数比CPU核心多一些,这样能够在同样的时间内完成更多的工作。

那连接数具体应该设置多少呢?这取决于磁盘。因为新型的SSD不需要寻址和旋转开销。此时不要想当然地认为“SSD速度更快,所以我们应该有更多的线程数”,恰好相反,更快意味着更少的阻塞,因此越接近核心数量的线程将会发挥最优的性能。只有当阻塞创造了更多的执行机会时,更多的线程数才能发挥出更好的性能

网络类似于磁盘。当发送/接收缓冲区填满并停止时,通过以太网接口在线路写入数据也会导致阻塞。 10G宽带延迟小于1G宽带,而1G宽带的延迟又小于100M宽带。就阻塞而言,网络通常是放在第三位考虑的,但是仍然有人会在性能计算中忽略它。

说实话,下面这张图笔者研究了半天也不明白它的意义,不过既然原文贴出来了,笔者只好照搬不误。

image

在上述PostgreSQL基准测试中可以看到,TPS速率在大约50个连接处开始趋于平稳。 在上面的Oracle视频中,将连接数从2048下调至96。但实际上,96也很高了,除非服务器使用的是16或32核的处理器。

计算公式

虽然下面的公式是PostgreSQL提供的,不过我们认为该公式可以广泛地适用于不同的数据库。你可以利用该公式计算一个初始值,以此初始值为基准模拟负载并调整连接数大小从而找到一个合适的连接数。

连接数 = ((核心数 * 2) + 有效磁盘数)

在多年的基准测试中,保持最优吞吐量的活跃连接数都是接近该公式的计算结果。 核心数不应包含超线程,即使启用了超线程也是如此。如果活跃数据全部被缓存了,那么有效磁盘数是0,随着缓存命中率的下降,有效磁盘数将逐渐趋近于实际的磁盘数。

特别注意:目前为止,还没有任何关于该公式作用于SSD的效果分析。

这个公式意味着什么?假如你有一个服务器,该服务器具有一块磁盘和一个4核的i7处理器,那么此服务器的连接池大小应该是:9 =((4 * 2)+1),取个整数的话就是10。是不是看起来很小?但是请尝试一下,我们敢打赌在这样的设置下,它可以轻松搞定3000个前端用户以6000TPS的速率执行简单查询。如果你增加连接池的大小并运行负载测试,你会发现前端响应时间增加的同时TPS速率开始下降。

公理:你需要一个小的充满了等待连接的线程队列

如果你有10000个用户,设置一个10000的连接池基本等于疯了,1000仍然很恐怖,即使是100也太多了。你最多需要一个十几个连接的小型池,其余的业务线程则被阻塞直到有可用连接。连接池中的连接数量应该等于你的数据库能够有效同时进行的查询任务数(通常不会高于2*CPU核心数)。

我们经常见到一些小规模的web应用,应付着大约十来个的并发用户,却使用着一个100连接数的连接池。不要过度配置数据库。

笔者想到一个案例:曾经有一个读者将HTTP连接池的最大空闲连接设置为300,后来发现经常出问题,最后在笔者的劝说下使用了默认配置之后就再也没找过我。

避免死锁的连接池大小

单个线程同时需要多个连接可能会造成死锁。这在很大程度上是一个业务上的问题,该问题可以通过增加连接池大小来解决。但是在增加连接池大小之前,我们还是强烈建议您首先检查在业务方面可以做什么。

为避免死锁,计算连接池有一个简单的计算公式:

连接数 = 最大线程数 * (单线程需要的最大连接数 - 1) + 1

假如你有3个线程,每个线程需要4个连接来执行某些任务。确保永不发生死锁所需的池大小为:

3 x (4-1) + 1 = 10

👉这不一定是最佳的连接池大小,而是避免死锁所需的最小的连接池大小。

笔者根据自己的开发经验在此特意提醒:

  1. Go中单个协程尽量不要同时使用多个连接进行操作。
  2. 执行事务期间不要通过非当前事务连接获取数据, 请使用当前事务连接直接获取数据(如果在执行事务期间使用非当前事务连接获取数据相当于同时使用两个连接)。

忠告

连接池的大小最终与系统特性有关。

例如,一个混合了长时事务和短事务的系统是非常难以使用连接池进行调优的。通常做法是, 使用两个连接池,一个用于长时事务,一个用于实时查询。

在主要运行长时事务的系统中,连接池大小通常存在外部约束。例如,一个任务执行队列只允许固定数量的任务同时运行。此时,任务队列的大小应该去适应连接池的大小,而不是反过来。

最后,衷心希望笔者的翻译能够对各位读者有一定的帮助。

查看原文

赞 0 收藏 0 评论 0

Gopher指北 发布了文章 · 2020-12-16

码了2000多行代码就是为了讲清楚TLS握手流程(续)

来自公众号:新世界杂货铺

在“码了2000多行代码就是为了讲清楚TLS握手流程”这一篇文章的最后挖了一个坑,今天这篇文章就是为了填坑而来,因此本篇主要分析TLS1.2的握手流程。

在写前一篇文章时,笔者的Demo只支持解析TLS1.3握手流程中发送的消息,写本篇时,笔者的Demo已经可以解析TLS1.x握手流程中的消息,有兴趣的读者请至文末获取Demo源码。

结论先行

为保证各位读者对TLS1.2的握手流程有一个大概的框架,本篇依旧结论先行。

单向认证

单向认证客户端不需要证书,客户端验证服务端证书合法即可访问。

下面是笔者运行Demo打印的调试信息:

image

根据调试信息知,TLS1.2单向认证中总共收发数据四次,Client和Server从这四次数据中分别读取不同的信息以达到握手的目的。

笔者将调试信息转换为下述时序图,以方便各位读者理解。

image

双向认证

双向认证不仅服务端要有证书,客户端也需要证书,只有客户端和服务端证书均合法才可继续访问(笔者的Demo如何开启双向认证请参考前一篇文章中HTTPS双向认证部分)。

下面是笔者运行Demo打印的调试信息:

image

同单向认证一样,笔者将调试信息转换为下述时序图。

image
双向认证和单向认证相比,Server发消息给Client时会额外发送一个certificateRequestMsg消息,Client收到此消息后会将证书信息(certificateMsg)和签名信息(certificateVerifyMsg)发送给Server。

双向认证中,Client和Server发送的消息变多了,但是总的数据收发仍然只有四次

总结

1、单向认证和双向认证中,总的数据收发仅四次(比TLS1.3多一次数据收发),单次发送的数据中包含一个或者多个消息。

2、TLS1.2中除了finishedMsg其余消息均未加密。

3、在TLS1.2中,ChangeCipherSpec消息之后的所有数据均会做加密处理,它的作用在TLS1.2中更像是一个开启加密的开关(TLS1.3中忽略此消息,并不做任何处理)。

和TLS1.3的比较

消息格式的变化

对比本篇的时序图和前篇的时序图很容易发现部分消息格式发生了变化。下面是certificateMsgcertificateMsgTLS13的定义:

// TLS1.2
type certificateMsg struct {
    raw          []byte
    certificates [][]byte
}
// TLS1.3
type certificateMsgTLS13 struct {
    raw          []byte
    certificate  tls.Certificate
    ocspStapling bool
    scts         bool
}

其他消息的定义笔者就不一一列举了,这里仅列出格式发生变化的消息。

TLS1.2TLS1.3
certificateRequestMsgcertificateRequestMsgTLS13
certificateMsgcertificateMsgTLS13

消息类型的变化

TLS1.2和TLS1.3有相同的消息类型也有各自独立的消息类型。下面是笔者例子中TLS1.2和TLS1.3各自独有的消息类型:

TLS1.2TLS1.3
serverKeyExchangeMsg-
clientKeyExchangeMsg-
serverHelloDoneMsg-
-encryptedExtensionsMsg

消息加密的变化

前篇中提到,TLS1.3中除了clientHelloMsgserverHelloMsg其他消息均做了加密处理,且握手期间和应用数据使用不同的密钥加密。

TLS1.2中仅有finishedMsg做了加密处理,且应用数据也使用该密钥加密。

TLS1.3会计算两次密钥,Client和Server读取对方的HelloMsgfinishedMsg之后即可计算密钥。

“Client和Server会各自计算两次密钥,计算时机分别是读取到对方的HelloMsg和finishedMsg之后”,这是前篇中的描述,计算时机描述不准确以上面为准。

TLS1.2只计算一次密钥,Client和Server分别收到serverKeyExchangeMsgclientKeyExchangeMsg之后即可计算密钥,和TLS1.3不同的是TLS1.2密钥计算后并不会立即对接下来发送的数据进行加密,只有当发送/接受ChangeCipherSpec消息后才会对接下来的数据进行加解密。

生成密钥过程

TLS1.2和TLS1.3生成密钥的过程还是比较相似的, 下图为Client读取serverKeyExchangeMsg之后的部分处理逻辑:

image

图中X25519是椭圆曲线迪菲-赫尔曼(Elliptic-curve Diffie–Hellman ,缩写为ECDH)密钥交换方案之一,这在前篇已经提到过故本篇不再赘述。

根据Debug结果,本例中ka.preMasterSecret和TLS1.3中的共享密钥生成逻辑完全一致。不仅如此,在后续的代码分析中,笔者发现TLS1.2也使用了AEAD加密算法对数据进行加解密(AEAD在前篇中已经提到过故本篇不再赘述)。

下图为笔者Debug结果:

image

图中prefixNonceAEAD即为TLS1.2中AEAD加密算法的一种实现。

这里需要注意的是TLS1.3也会计算masterSecret。为了方便理解,我们先回顾一下TLS1.3中生成masterSecret的部分源码:

// 基于共享密钥派生hs.handshakeSecret
hs.handshakeSecret = hs.suite.extract(hs.sharedKey,
    hs.suite.deriveSecret(earlySecret, "derived", nil))
// 基于hs.handshakeSecret 派生hs.masterSecret
hs.masterSecret = hs.suite.extract(nil,
    hs.suite.deriveSecret(hs.handshakeSecret, "derived", nil))

由上易知,TLS1.3先通过共享密钥派生出handshakeSecret,最后通过handshakeSecret派生出masterSecret。与此相比,TLS1.2生成masterSecret仅需一步:

hs.masterSecret = masterFromPreMasterSecret(c.vers, hs.suite, preMasterSecret, hs.hello.random, hs.serverHello.random)

masterFromPreMasterSecret函数的作用是利用HMAC(HMAC在前篇中已经提到故本篇不再赘述)算法对Client和Server的随机数以及共享密钥进行摘要,从而计算得到masterSecret

masterSecret在后续的过程中并不会用于数据加密,下面笔者带各位读者分别看一下TLS1.3和TLS1.2生成数据加密密钥的过程。

TLS1.3生成数据加密密钥(以Client计算serverSecret为例):

serverSecret := hs.suite.deriveSecret(hs.masterSecret,
    serverApplicationTrafficLabel, hs.transcript)
c.in.setTrafficSecret(hs.suite, serverSecret)

前篇中提到hs.suite.deriveSecret内部会通过hs.transcript计算出消息摘要从而重新得到一个serverSecretsetTrafficSecret方法内部会对serverSecret计算得到AEAD加密算法所需要的key和iv(初始向量:Initialization vector)。

因此可知TLS1.3计算密钥和Client/Server生成的随机数无直接关系,而与Client/Server当前收发的所有消息的摘要有关。

补充:
IV通常是随机或者伪随机的。它和数据加密的密钥一起使用可以增加使用字典攻击的攻击者破解密码的难度。例如,如果加密数据中存在重复的序列,则攻击者可以假定消息中相应的序列也是相同的,而IV就是为了防止密文中出现相应的重复序列。

参考:

https://whatis.techtarget.com...
https://en.wikipedia.org/wiki...

TLS1.2生成数据加密密钥:

clientMAC, serverMAC, clientKey, serverKey, clientIV, serverIV :=
            keysFromMasterSecret(tr.vers, suite, p.masterSecret, tr.clientHello.random, tr.serverHello.random, suite.macLen, suite.keyLen, suite.ivLen)
serverCipher = hs.suite.aead(serverKey, serverIV)
c.in.prepareCipherSpec(c.vers, serverCipher, serverHash)

前文中提到masterSecret的生成与Client和Server的随机数有关,而通过keysFromMasterSecret计算AEAD所需的key和iv依旧与随机数有关。

小结

1、本例中TLS1.2和TLS1.3均使用X25519算法计算共享密钥。

2、本例中TLS1.2和TLS1.3均使用AEAD进行数据加解密。

3、TLS1.3通过共享密钥派生两次才得到masterSecret,而TLS1.2以共享密钥、Client和Server的随机数一起计算得到masterSecret

4、TLS1.3通过消息的摘要再次计算得到一个数据加密密钥,而TLS1.2直接通过masterSecret计算得到AEAD所需的key和iv。

TLS1.1和TLS1.0不支持HTTP2

在前面提到本文的例子已经支持解析TLS1.x的握手流程,这个时候笔者突然很好奇浏览器还支持那些版本的TLS协议。

然后笔者在谷歌浏览器上首先测试了TLS1.1的服务,为了方便测试笔者改造了之前服务器推送的案例

server := &http.Server{Addr: ":8080", Handler: nil}
server.TLSConfig = new(tls.Config)
server.TLSConfig.PreferServerCipherSuites = true
server.TLSConfig.NextProtos = append(server.TLSConfig.NextProtos, "h2", "http/1.1")
// 服务端支持的最大tls版本调整为1.1
server.TLSConfig.MaxVersion = tls.VersionTLS11
server.ListenAndServeTLS("ca.crt", "ca.key")

运行Demo后得到如下截图:

image

图中红框部分obsolete的意思笔者也不知,正好学习一波(技术人的英语大概就是这样慢慢积累起来的吧)。

image

这下笔者明白了,TLS1.1已经不被支持所以页面才无法正常访问,然而事实真是如此嘛?

直到几天后笔者开始写这篇文章时,内心仍是十分疑惑,于是使用了curl命令再次访问。

image

图中蓝框部分正是TLS1.1的握手流程,有兴趣的读者可以使用笔者的例子和curl -v命令进行双向验证。

图中红框部分提示说“HTTP2的数据发送失败”,笔者才恍然大悟,将上述代码作如下微调后页面可正常访问。

server.TLSConfig.NextProtos = append(server.TLSConfig.NextProtos, "http/1.1")

经过笔者的测试,TLS1.0同TLS1.1一样均不支持HTTP2协议,当然这两个协议也不推荐继续使用。

写在最后

“纸上得来终觉浅,绝知此事需躬行”。笔者不敢保证把TLS握手流程的每个细节都讲的十分清楚,所以建议各位读者去github克隆代码,然后自己一步一步Debug必然能够加深印象并彻底理解。当然,顺便关注或者star一下这种随手为之的小事,笔者相信各位读者还是十分乐意的~

最后,衷心希望本文能够对各位读者有一定的帮助。

  1. 写本文时, 笔者所用go版本为: go1.15.2
  2. 文章中所用完整例子:https://github.com/Isites/go-...
查看原文

赞 1 收藏 0 评论 0

Gopher指北 发布了文章 · 2020-12-11

区分Protobuf 3中缺失值和默认值

来自公众号:新世界杂货铺

这两天翻了翻以前的项目,发现不同项目中关于Protobuf 3缺失值和默认值的区分居然有好几种实现。今天笔者冷饭新炒,结合项目中的实现以及切身经验共总结出如下六种方案。

增加标识字段

众所周知,在Go中数字类型的默认值为0(这里仅以数字类型举例),这在某些场景下往往会引起一定的歧义。

is_show字段为例,如果没有该字段表示不更新DB中的数据,如果有该字段且值为0则表示更新DB中的数据为不可见,如果有该字段且值为1则表示更新DB中的数据为可见。

上述场景中,实际要解决的问题是如何区分默认值和缺失字段。增加标识字段是通过额外增加一个字段来达到区分的目的。

例如:增加一个has_show_field字段标识is_show是否为有效值。如果has_show_fieldtrueis_show为有效值,否则认为is_show未设置值。

此方案虽然直白,但每次设置is_show的值时还需设置has_show_field的值,甚是麻烦故笔者十分不推荐。

字段含义和默认值区分

字段含义和默认值区分即不使用对应类型的默认值作为该字段的有效值。接着前面的例子继续描述,is_show为1时表示展示,is_show为2时表示不展示,其他情况则认为is_show未设置值。

此方案笔者还是比较认可的,唯一问题就是和开发者的默认习惯略微不符。

使用oneof

oneof 的用意是达到 C 语言 union 数据类型的效果,但是诸多大佬还是发现它可以标识缺失字段。

message Status {
  oneof show {
    int32 is_show = 1;
  }
}
message Test {
    int32 bar = 1;
    Status st = 2;
}

上述proto文件生成对应go文件后,Test.StStatus的指针类型,故通过此方案可以区分默认值和缺失字段。但是笔者认为此方案做json序列化时十分不友好,下面是笔者的例子:

// oneof to json
ot1 := oneof.Test{
  Bar: 1,
  St: &oneof.Status{
    Show: &oneof.Status_IsShow{
      IsShow: 1,
    },
  },
}
bts, err := json.Marshal(ot1)
fmt.Println(string(bts), err)
// json to oneof failed
jsonStr := `{"bar":1,"st":{"Show":{"is_show":1}}}`
var ot2 oneof.Test
fmt.Println(json.Unmarshal([]byte(jsonStr), &ot2))

上述输出结果如下:

{"bar":1,"st":{"Show":{"is_show":1}}} <nil>
json: cannot unmarshal object into Go struct field Status.st.Show of type oneof.isStatus_Show

通过上述输出知,oneof的json.Marshal输出结果会额外多一层,而json.Unmarshal还会失败,因此使用oneof时需谨慎。

使用wrapper类型

这应该是google官方提出的解决方案,我们看看下面的例子:

import "google/protobuf/wrappers.proto";
message Status {
    google.protobuf.Int32Value is_show = 1;
}
message Test {
    int32 bar = 1;
    Status st = 2;
}

使用此方案需要引入google/protobuf/wrappers.proto。此方案生成对应go文件后,Test.St也是Status的指针类型。同样,我们也看一下它的json序列化效果:

wra1 := wrapper.Test{
  Bar: 1,
  St: &wrapper.Status{
    IsShow: wrapperspb.Int32(1),
  },
}
bts, err = json.Marshal(wra1)
fmt.Println(string(bts), err)
jsonStr = `{"bar":1,"st":{"is_show":{"value":1}}}`
// 可正常转json
var wra2 wrapper.Test
fmt.Println(json.Unmarshal([]byte(jsonStr), &wra2))

上述输出结果如下:

{"bar":1,"st":{"is_show":{"value":1}}} <nil>
<nil>

和oneof方案相比wrapper方案的json反序列化是没问题的,但是json.Marshal的输出结果也会额外多一层。另外,经笔者在本地试验,此方案无法和gogoproto一起使用。

允许proto3使用optional标签

前面几个方案估计在实践中还是不够尽善尽美。于是2020年5月16日protoc v3.12.0发布,该编译器允许proto3的字段也可使用 optional修饰。

下面看看例子:

message Status {
  optional int32 is_show = 1;
}
message Test {
    int32 bar = 1;
    Status st = 2;
}

此方案需要使用新版本的protoc且必须使用--experimental_allow_proto3_optional开启此特性。protoc升级教程见https://github.com/protocolbu...。下面继续看看该方案的json序列化效果

var isShow int32 = 1
p3o1 := p3optional.Test{
  Bar: 1,
  St:  &p3optional.Status{IsShow: &isShow},
}
bts, err = json.Marshal(p3o1)
fmt.Println(string(bts), err)
var p3o2 p3optional.Test
jsonStr = `{"bar":1,"st":{"is_show":1}}`
fmt.Println(json.Unmarshal([]byte(jsonStr), &p3o2))

上述输出结果如下:

{"bar":1,"st":{"is_show":1}} <nil>
<nil>

据上述结果知,此方案与oneof以及wrapper方案的json序列化相比更加符合预期,同样,经笔者在本地试验,此方案无法和gogoproto一起使用。

proto2和proto3结合使用

作为一个gogoproto的忠实用户,笔者希望在能区分默认值和缺失值的同时还可以继续使用gogoproto的特性。于是便产生了proto2和proto3结合使用的野路子。

// proto2
message Status {
    optional int32 is_show = 2;
}
// proto3
message Test {
    int32 bar = 1 [(gogoproto.moretags) = 'form:"more_bar"', (gogoproto.jsontag) = 'custom_tag'];
    p3p2.Status st = 2;
}

需要区分缺失字段和默认值的message定义在语法为proto2的文件中,proto3通过import导入proto2的message以达区分目的。

optional修饰的字段在Go中会生成指针类型,因此区分缺失值和默认值就变的十分容易了。下面看看此方案的json序列化效果:

// p3p2 to json
p3p21 := p3p2.Test{
  Bar: 1,
  St:  &p3p2.Status{IsShow: &isShow},
}
bts, err = json.Marshal(p3p21)
fmt.Println(string(bts), err)
var p3p22 p3p2.Test
jsonStr = `{"custom_tag":1,"st":{"is_show":1}}`
fmt.Println(json.Unmarshal([]byte(jsonStr), &p3p22))

上述输出结果如下:

{"custom_tag":1,"st":{"is_show":1}} <nil>
<nil>

根据上述结果知,此方案不仅能够活用gogoproto的各种tag,其结果也和在proto3中直接使用optional效果一致。虽然笔者已经在自己的项目中使用了此方案,但是仍然要提醒一句:“写本篇文章时,笔者特意去github看了gogoproto的发布日志,gogoproto最新一个版本发布时间为2019年10月14日,笔者大胆预言gogoproto以后不会再更新了,所以此方案还请大家酌情使用”。

最后,衷心希望本文能够对各位读者有一定的帮助。

注:

  1. 文中笔者所用go版本为:go1.15.2
  2. 文中笔者所用protoc版本为:3.14.0
  3. 文章中所用完整例子:https://github.com/Isites/go-...
查看原文

赞 0 收藏 0 评论 0

Gopher指北 发布了文章 · 2020-12-07

线上数据被回滚两次我都做了哪些不正确的操作

来自公众号:新世界杂货铺

程序猿最大的悲哀是什么!

经历了这两次事故后,笔者觉得最大的悲哀莫过于半夜打电话给DBA请求帮忙恢复数据。程序猿和PM之间的战斗往往还有来有回,而笔者碰上DBA之后,那可真是求人办事,怎么怂怎么来,只要DBA大爷高兴!

为了以后尽量少跪舔DBA大爷,笔者将亲身经历的两次事故记录下来以提醒自己。

第一次数据回滚

PM是需求的生产者,程序猿是需求的消费者,这二者就是典型的生产者与消费者模型。因此本次事故的根因还是PM提出了需求,故笔者认为只要PM不再提需求就不再有事故。

唉!快醒醒,别做梦了!

image

回到事故的本身,笔者先描述一下当时的背景。

PM有大量的数据需要紧急更新到线上。这需求有多紧急呢?PM要绕过QA验证,直接在线上先用少量数据进行测试,少量数据验证通过后就更新所有剩余的数据。

结合笔者所在公司的业务场景,笔者按照以下步骤完成了本次数据更新。

1、将需要更新的数据使用mysqldump进行备份。

mysqldump --replace -f --single-transaction -t \
-h hostname -u user -P 3936 -p dbname tablename  \
--where="id in (1,2,3)"  > tablename.sql

2、开发一个脚本直接调用线上已有更新数据的接口(开发时笔者已经在测试环境自测)。

3、在线上先更新少量数据,并更新修改数据部分的缓存,PM对少量数据进行验证。

4、PM确认该部分数据验证通过后,开始对剩余数据进行线上更新操作。

初看上面的步骤好像没什么大问题,但实际结果却是狠狠地打了笔者的脸。下面,笔者就好好掰扯掰扯到底是哪些原因造成了本次事故。

1、更新接口逻辑没有理清楚,导致线上数据更新错误。

该接口是一个比较老的服务且相关文档少,笔者因为没有梳理清楚所有逻辑,调用接口时部分数据参数传递有误,导致线上数据更新错误。

2、更新接口实现有问题,调用服务后,删除了关联表的数据,所以需要恢复。

如果只是上述第一个问题,笔者自己备份的replace into语句就可完成数据的恢复,但很明显问题不止于此。当事故发生后笔者开始对该服务逻辑进行二次梳理,发现此接口对主表的关联表也进行了更新而且更新逻辑为先删除关联数据然后插入新的关联数据。只是如此倒也罢了,关键是该接口的实现者将所有请求参数作为一个关联数组并将此关联数组传递给所有函数。好家伙,各个具有不同业务功能的函数传递的参数都是一样的,这导致笔者第一次梳理逻辑时无法完全理清楚各个业务函数真实需要的数据到底是什么。

关联数据被删除笔者也没有备份,最后只好跪舔DBA大大帮忙进行数据回滚。

警告⚠️:代码不清晰,程序猿泪两行!

3、未经过QA的保证,就直接在线上测试。

笔者自己虽然在测试环境进行了简单测试,但是程序猿的本职还是开发不能耗费过多的精力去完成QA的工作,而PM很明显也不够专业,这才在质量保证环节出了错并扩大了线上的错误范围。

4、测试时未在无缓存环境下进行验证。

初始,PM对少量数据的验证结果是没有问题的,但是当所有数据更新完成后缓存已经开始逐步重建,数据有误和数据被删的问题就开始暴露了。这是因为笔者只更新了PM想要验证的数据的缓存,却没更新关联数据部分的缓存,因此只有等这部分缓存自然失效问题才逐渐显现。

后续

DBA对数据进行回滚后,批量更新数据还得继续啊!狠心的PM愣是逼着笔者大半夜修好问题继续验证,唯一值得高兴的可能就是这次仅更新少量数据第二天继续更新剩余数据。最后,笔者修好问题并成功地更新完全部数据。

第二次数据回滚

PM又又又提出批量更新数据的需求了,不过这次笔者信心满满,毕竟这次需求和第一次需求几乎一样,唯一的区别是PM指定部分数据不需要更新(这部分PM给到的数据是有问题的,所以不更新)。

但是人怎么可能不犯错呢,笔者忘记了部分数据不需要更新这个点,最后正确的和不正确的数据都更新至线上。万万没想到,经历了第一次数据回滚之后还能遭遇第二次数据回滚,笔者心态是真的崩了。

image

事情已经发生,笔者也只能想办法解决了,下面是笔者基于实际业务场景想到的两个数据恢复方案:

方案一

1、先通过数据ID确认哪些数据需要修复(笔者在执行脚本时记录了数据ID的log日志)。

2、解析备份SQL中需要恢复的数据并拼接为新的恢复SQL。

3、调用服务删除新增的数据(数据更新接口在修改数据的同时会新增其他关联表的数据)。

4、执行步骤2中生成的SQL恢复数据。

方案二

寻求DBA大爷的帮助恢复数据。

方案一可以自己恢复数据,而且正确的数据会保留,但操作麻烦且恢复过程可能产生新的问题,所以最后还是厚颜无耻地去找DBA恢复数据。

DBA恢复数据后还给笔者发了下面恢复线上数据的SQL:

alter table table_a rename to table_a_bk_2;
alter table table_a_bk rename to table_a;

好家伙,DBA暗示已经这么明显了嘛,笔者二话不说默默地发了一封邮件准备申请一个具有DDL权限的账号。笔者现在想的十分清楚,以后再有这种批量更新线上数据的操作一定好好全表备份数据而不是使用仅有读权限的账号备份replace into语句。

-- 全表备份sql语句
CREATE TABLE table_a_bk AS SELECT * FROM table_a;

总结

下面是这两次事故发生后笔者的一些心得,希望可以给大家提供参考。

1、代码逻辑要清楚,函数参数命名要语义清晰。一个参数就包含了所有需要的数据是十分不正确的行为同时代码中尽可能多些注释。

2、对线上数据充满敬畏,操作数据时要理清楚业务逻辑。

3、准备操作线上数据前,尽量先在无缓存环境下进行数据预验证。

4、人都有可能会犯错,所以还需要QA进行双重保证。

5、笔者就是吃了紧急需求的亏才导致这两次事故,其他情况请务必按照正常流程进行数据操作。

6、备份真的很重要!这两次事故后笔者认为前文提到的全表数据备份方案相对合理且易恢复。

最后,衷心希望本文能够对各位读者有一定的帮助

查看原文

赞 0 收藏 0 评论 0

Gopher指北 发布了文章 · 2020-11-28

码了2000多行代码就是为了讲清楚TLS握手流程

来自公众号:新世界杂货铺

前言

呼,这篇文章的准备周期可谓是相当的长了!原本是想直接通过源码进行分析的,但是发现TLS握手流程调试起来非常不方便,笔者怒了,于是实现了一个极简的net.Conn接口以方便调试。码着码着,笔者哭了,因为现在这个调试Demo已经达到2000多行代码了!

image

虽然码了两千多行代码,但是目前只能够解析TLS1.3握手流程中发送的消息,因此本篇主要分析TLS1.3的握手流程。

特别提醒:有想在本地调试一番的小伙伴请至文末获取本篇源码。

结论先行

鉴于本文篇幅较长,笔者决定结论先行,以助各位读者理解后文详细的分析内容。

HTTPS单向认证

单向认证客户端不需要证书,客户端只要验证服务端证书合法即可访问。

下面是笔者运行Demo打印的调试信息:

image

根据调试信息知,在TLS1.3单向认证中,总共收发数据三次,Client和Server从这三次数据中分别读取不同的信息以达到握手的目的。

注意:TLS1.3不处理ChangeCipherSpec类型的数据,而该数据在TLS1.2中是需要处理的。因本篇主要分析TLS1.3握手流程,故后续不会再提及ChangeCipherSpec,同时时序图中也会忽略此消息

笔者将调试信息转换为下述时序图,以方便各位读者理解。

image

HTTPS双向认证

双向认证不仅服务端要有证书,客户端也需要证书,只有客户端和服务端证书均合法才可继续访问。

笔者在这里特别提醒,开启双向认证很简单,在笔者的Demo中取消下面代码的注释即可。

// sconf.ClientAuth = tls.RequireAndVerifyClientCert

另外,笔者在main.go同目录下留有测试用的根证书、服务端证书和客户端证书,为了保证双向认证的顺利运行请将根证书安装为受用户信任的证书。

下面是笔者运行Demo打印的调试信息:

image

同单向认证一样,笔者将调试信息转换为下述时序图。

image

双向认证和单向认证相比,Server发消息给Client时会额外发送一个certificateRequestMsgTLS13消息,Client收到此消息后会将证书信息(certificateMsgTLS13)和签名信息(certificateVerifyMsg)发送给Server。

双向认证中,Client和Server发送消息变多了,但是总的数据收发仍然只有三次

总结

1、TLS1.3和TLS1.2握手流程是有区别的,这一点需要注意。

2、单向认证和双向认证中,总的数据收发仅三次,单次发送的数据中包含一个或者多个消息。

3、clientHelloMsgserverHelloMsg未经过加密,之后发送的消息均做了加密处理。

4、Client和Server会各自计算两次密钥,计算时机分别是读取到对方的HelloMsgfinishedMsg之后。

:上述第3点和第4点分析过程详见后文。

Client发送HelloMsg

在TLS握手过程中的第一步是Client发送HelloMsg,所以针对TLS握手流程的分析也从这一步开始。

Server对于Client的基本信息了解完全依赖于Client主动告知Server,而其中比较关键的信息分别是客户端支持的TLS版本客户端支持的加密套件(cipherSuites)客户端支持的签名算法客户端支持的密钥交换协议以及其对应的公钥

客户端支持的TLS版本:

客户端支持的TLS版本主要通过tls包中(*Config).supportedVersions方法计算。对TLS1.3来说默认支持的TLS版本如下:

var supportedVersions = []uint16{
    VersionTLS13,
    VersionTLS12,
    VersionTLS11,
    VersionTLS10,
}

在发起请求时如果用户手动设置了tls.Config中的MaxVersion或者MinVersion,则客户端支持的TLS版本会发生变化。

例如发起请求时,设置了conf.MaxVersion = tls.VersionTLS12,此时(*Config).supportedVersions返回的版本为:

[]uint16{
    VersionTLS12,
    VersionTLS11,
    VersionTLS10,
}

ps: 如果有兴趣的小伙伴可以在克隆笔者的demo后手动设置Config.MaxVersion,设置后可以调试TLS1.2的握手流程。

客户端支持的加密套件(cipherSuites):

说实话,加密套件已经进入笔者的知识盲区了,其作用笔者会在下一小节讲明白,故本小节笔者直接贴出计算后的结果。

image

图中篮框部分为当前Client支持加密套件Id,红框部分为计算逻辑。

客户端支持的签名算法:

客户端支持的签名算法,仅在客户端支持的最大TLS版本大于等于TLS1.2时生效。此时客户端支持的签名算法如下:

var supportedSignatureAlgorithms = []SignatureScheme{
    PSSWithSHA256,
    ECDSAWithP256AndSHA256,
    Ed25519,
    PSSWithSHA384,
    PSSWithSHA512,
    PKCS1WithSHA256,
    PKCS1WithSHA384,
    PKCS1WithSHA512,
    ECDSAWithP384AndSHA384,
    ECDSAWithP521AndSHA512,
    PKCS1WithSHA1,
    ECDSAWithSHA1,
}

客户端支持的密钥交换协议及其对应的公钥:

这一块儿逻辑仅在客户端支持的最大TLS版本是TLS1.3时生效。

if hello.supportedVersions[0] == VersionTLS13 {
    hello.cipherSuites = append(hello.cipherSuites, defaultCipherSuitesTLS13()...)

    curveID := config.curvePreferences()[0]
    if _, ok := curveForCurveID(curveID); curveID != X25519 && !ok {
        return nil, nil, errors.New("tls: CurvePreferences includes unsupported curve")
    }
    params, err = generateECDHEParameters(config.rand(), curveID)
    if err != nil {
        return nil, nil, err
    }
    hello.keyShares = []keyShare{{group: curveID, data: params.PublicKey()}}
}

上述代码中,方法config.curvePreferences的逻辑为:

var defaultCurvePreferences = []CurveID{X25519, CurveP256, CurveP384, CurveP521}
func (c *Config) curvePreferences() []CurveID {
    if c == nil || len(c.CurvePreferences) == 0 {
        return defaultCurvePreferences
    }
    return c.CurvePreferences
}

在本篇中,笔者未手动设置优先可供选择的曲线,故curveID的值为X25519

上述代码中,generateECDHEParameters函数的作用是根据曲线Id生成一种椭圆曲线密钥交换协议的实现。

如果客户端支持的最大TLS版本是TLS1.3时,会为Client支持的加密套件增加TLS1.3默认的加密套件,同时还会选择Curve25519密钥交换协议生成keyShare

小结:本节介绍了在TLS1.3中Client需要告知Server客户端支持的TLS版本号、客户端支持的加密套件、客户端支持的签名算法和客户端支持的密钥交换协议。

Server读HelloMsg&发送消息

Server读到clientHelloMsg之后会根据客户端支持的TLS版本和本地支持的TLS版本做对比,得到Client和Server均支持的TLS版本最大值,该值作为后续继续通信的标准。本篇中Client和Server都支持TLS1.3,因此Server进入TLS1.3的握手流程。

处理clientHelloMsg

Server进入TLS1.3握手流程之后,还需要继续处理clientHelloMsg,同时构建serverHelloMsg

Server支持的TLS版本:

进入TLS1.3握手流程之前,Server已经计算出两端均支持的TLS版本,但是Client还无法得知Server支持的TLS版本,因此开始继续处理clientHelloMsg时,Server将已经计算得到的TLS版本赋值给supportedVersion以告知客户端。

// client读取到serverHelloMsg后,通过读取此字段计算两端均支持的TLS版本
hs.hello.supportedVersion = c.vers

Server计算两端均支持的加密套件

clientHelloMsg中含有Client支持的加密套件信息,Server读取该信息并和本地支持的加密套件做对比计算出两端均支持的加密套件。

这里需要注意的是,如果Server的tls.Config.PreferServerCipherSuitestrue则选择Server第一个在两端均支持的加密套件,否则选择Client第一个在两端均支持的加密套件。笔者通过Debug得到两端均支持的加密套件id为4865(其常量为tls.TLS_AES_128_GCM_SHA256),详情见下图:

image

上图中的mutualCipherSuiteTLS13函数会从cipherSuitesTLS13变量中选择匹配的加密套件。

var cipherSuitesTLS13 = []*cipherSuiteTLS13{
    {TLS_AES_128_GCM_SHA256, 16, aeadAESGCMTLS13, crypto.SHA256},
    {TLS_CHACHA20_POLY1305_SHA256, 32, aeadChaCha20Poly1305, crypto.SHA256},
    {TLS_AES_256_GCM_SHA384, 32, aeadAESGCMTLS13, crypto.SHA384},
}

结合前面的Debug信息知,hs.suitecipherSuiteTLS13结构体的变量且其值为cipherSuitesTLS13切片的第一个。cipherSuiteTLS13结构体定义如下:

type cipherSuiteTLS13 struct {
    id     uint16
    keyLen int
    aead   func(key, fixedNonce []byte) aead
    hash   crypto.Hash
}

至此,Server已经计算出双端均支持的加密套件,Server通过设置cipherSuite将双端均支持的加密套件告知Client:

hs.hello.cipherSuite = hs.suite.id
hs.transcript = hs.suite.hash.New()

在后续计算密钥时需要对Client和Server之间的所有消息计算Hash摘要。根据前面计算出的加密套件知,本篇中计算消息摘要的Hash算法为SHA256,此算法的实现赋值给hs.transcript变量,后续计算消息摘要时均通过该变量实现。

Server计算双端均支持的密钥交换协议以及对应的公钥

clientHelloMsg.keyShares变量记录着Client支持的曲线Id以及对应的公钥。Server通过对比本地支持的曲线Id计算出双端均支持的密钥交换协议。根据前面Client发送HelloMsg这一小节的内容以及笔者实际调试的结果,双端均支持的曲线为Curve25519

Server计算出双端均支持的曲线后,调用generateECDHEParameters方法得到对应密钥交换协议的实现,即Curve25519密钥交换协议。

Curve25519是椭圆曲线迪菲-赫尔曼(Elliptic-curve Diffie–Hellman ,缩写为ECDH)密钥交换方案之一,同时也是最快的ECC(Elliptic-curve cryptography)曲线之一。

ECDH可以为Client和Server在不安全的通道上为双方建立共享密钥,并且Client和Server需要各自持有一组椭圆曲线公私密钥对。当Client和Server需要建立共享密钥时仅需要公布各自的公钥,Client和Server通过对方的公钥以及自己的私钥即可计算出相等的密钥。如果公钥被第三方截获也无关紧要,因为第三方没有私钥无法计算出共享密钥除非第三方能够解决椭圆曲线Diffie–Hellman问题。ECDHEECDH的一个变种,其区别仅仅是私钥和公钥在每次建立共享密钥时均需重新生成(以上为笔者对维基百科中ECDH的理解)。

ECDHE有了一定的理解后,我们现在看一下generateECDHEParameters函数中的部分源码:

func generateECDHEParameters(rand io.Reader, curveID CurveID) (ecdheParameters, error) {
    if curveID == X25519 {
        privateKey := make([]byte, curve25519.ScalarSize)
        if _, err := io.ReadFull(rand, privateKey); err != nil {
            return nil, err
        }
        publicKey, err := curve25519.X25519(privateKey, curve25519.Basepoint)
        if err != nil {
            return nil, err
        }
        return &x25519Parameters{privateKey: privateKey, publicKey: publicKey}, nil
    }
  // 此处省略代码
}

每次调用generateECDHEParameters函数时均会生成一组新的椭圆曲线公私密钥对。clientHelloMsg.keyShares变量存有Client的公钥,因此Server已经可以计算共享密钥:

params, err := generateECDHEParameters(c.config.rand(), selectedGroup)
if err != nil {
  c.sendAlert(alertInternalError)
  return err
}
hs.hello.serverShare = keyShare{group: selectedGroup, data: params.PublicKey()}
hs.sharedKey = params.SharedKey(clientKeyShare.data) // 共享密钥

上述代码中Server已经计算出共享密钥,之后可以通过此密钥派生出其他密钥为数据加密。Client因为无Server的公钥还无法计算出共享密钥,所以Server通过设置serverShare变量告知Client服务端的公钥。

至此,Server对Client发来的helloMsg已经处理完毕。笔者在这里额外提醒一句,clientHelloMsgserverHelloMsg中仍然有Client和Server生成的随机数,但是在TLS1.3中这两个随机数已经和密钥交换无关了。

小结:本节介绍了Server读取clientHelloMsg后会计算双端支持的TLS版本以及双端支持的加密套件和密钥交换协议,同时还介绍了共享密钥的生成以及ECDH的概念。

选择合适的证书以及签名算法

在Server选择和当前Client匹配的证书前其实还有关于预共享密钥模式的处理,该模式需要实现ClientSessionCache接口,鉴于其不影响握手流程的分析,故本篇不讨论预共享密钥模式。

一个Server可能给多个Host提供服务,因此Server可能持有多个证书,那么选择一个和当前Client匹配的证书是十分必要的,其实现逻辑参见(*Config).getCertificate方法。本篇中的Demo只有一个证书,故该方法会直接返回此证书。

证书中是包含公钥的,不同的公钥支持的签名算法是不同的,在本例中Server支持的签名算法和最终双端均支持的签名算法见下面的Debug结果:

image

上图中红框部分为Server支持的签名算法,蓝框为选定的双端均支持的签名算法。

小结:本节主要介绍了Server选择匹配当前Client的证书和签名算法。

计算握手阶段的密钥以及发送Server的参数

在这个阶段Server会将serverHelloMsg写入缓冲区,写完之后再写入一个ChangeCipherSpec(TLS1.3不会处理此消息)消息,需要注意的是serverHelloMsg未进行加密发送。

计算握手阶段的密钥

前面提到过计算密钥需要计算消息摘要:

hs.transcript.Write(hs.clientHello.marshal())
hs.transcript.Write(hs.hello.marshal()) // hs.hello为serverHelloMsg

上述代码中hs.transcript在前面已经提到过是SHA256Hash算法的一种实现。下面我们逐步分析源码中Server第一次计算密钥的过程。

首先,派生出handshakeSecret

earlySecret := hs.earlySecret 
if earlySecret == nil {
  earlySecret = hs.suite.extract(nil, nil)
}
hs.handshakeSecret = hs.suite.extract(hs.sharedKey, 
hs.suite.deriveSecret(earlySecret, "derived", nil))

earlySecret和预共享密钥有关,因本篇不涉及预共享密钥,故earlySecretnil。此时,earlySecret会通过加密套件派生出一个密钥。

// extract implements HKDF-Extract with the cipher suite hash.
func (c *cipherSuiteTLS13) extract(newSecret, currentSecret []byte) []byte {
    if newSecret == nil {
        newSecret = make([]byte, c.hash.Size())
    }
    return hkdf.Extract(c.hash.New, newSecret, currentSecret)
}

上述代码中HDKF是一种基于哈希消息身份验证的密钥派生算法,其两个主要用途分别为:一、从较大的随机源中提取更加均匀和随机的密钥;二、将已经合理的随机输入(例如共享密钥)扩展为更大的密码独立输出,从而将共享密钥派生出多个密钥(以上为笔者对维基百科中HKDF的理解)。

上述代码中hs.suite.deriveSecret方法笔者就不列出其源码了,该方法最终会调用hkdf.Expand方法进行密钥派生。

此时再次回顾hs.handshakeSecret的生成正是HKDF算法基于sharedKeyearlySecret计算的结果。

然后,通过handshakeSecret和消息摘要派生出一组密钥。

clientSecret := hs.suite.deriveSecret(hs.handshakeSecret,
    clientHandshakeTrafficLabel, hs.transcript)
c.in.setTrafficSecret(hs.suite, clientSecret)
serverSecret := hs.suite.deriveSecret(hs.handshakeSecret,
    serverHandshakeTrafficLabel, hs.transcript)
c.out.setTrafficSecret(hs.suite, serverSecret)

上述代码中clientHandshakeTrafficLabelserverHandshakeTrafficLabel为常量,其值分别为c hs traffics hs traffichs.suite.deriveSecret方法会在内部调用hs.transcript.Sum(nil)计算出消息的摘要信息,所以clientSecretserverSecretHKDF算法基于handshakeSecret和两个常量以及Server和Client已经发送的消息的摘要派生出的密钥。

clientSecret在服务端用于对收到的数据进行解密,serverSecret在服务端对要发送的数据进行加密。c.inc.out同其语义一样,分别用于处理收到的数据和要发送的数据。

下面看看笔者对setTrafficSecret方法的Debug结果:

image

上图中trafficKey方法使用HKDF算法对密钥进行了再次派生,笔者就不再对其展开。这里需要关注的是红框部分,aes-gcm是一种AEAD加密。

单纯的对称加密算法,其解密步骤是无法确认密钥是否正确的。也就是说,加密后的数据可以用任何密钥执行解密运算,得到一组疑似原始数据,然而并不知道密钥是否是正确,也不知道解密出来的原始数据是否正确。因此,需要在单纯的加密算法之上,加上一层验证手段,来确认解密步骤是否正确,这就是AEAD

至此,Server在握手阶段的密钥生成结束,此阶段之后发送的消息(即serverHelloMsgChangeCipherSpec之后的消息),均通过aes-gcm算法加密。

最后回顾一下加密套件的作用:

1、提供消息摘要的Hash算法。

2、提供加解密的AEAD算法。

最后再顺便提一嘴,笔者Demo中parse.go文件的processMsg方法在处理serverHelloMsg时有计算握手阶段密钥的极简实现。

支持的HTTP协议

Client通过clientHelloMsg.alpnProtocols告知Server客户端支持的HTTP协议,Server通过对比本地支持的HTTP协议,最终选择双端均支持的协议并构建encryptedExtensionsMsg消息告知Client

encryptedExtensions := new(encryptedExtensionsMsg)
if len(hs.clientHello.alpnProtocols) > 0 {
  if selectedProto, fallback := mutualProtocol(hs.clientHello.alpnProtocols, c.config.NextProtos); !fallback {
    encryptedExtensions.alpnProtocol = selectedProto
    c.clientProtocol = selectedProto
  }
}
hs.transcript.Write(encryptedExtensions.marshal())

hs.clientHello.alpnProtocols的数据来源为客户端的tls.Config.NextProtos。在笔者的Demo中,Client和Server均支持h2http1.1这两种协议。

这里顺便强调一下,Client或者Server在获取到对方的helloMsg之后接受/发送的消息均会调用hs.transcript.Write方法,以便计算密钥时可以快速计算消息摘要。

小结

1、本节讨论了握手阶段的密钥生成流程:对消息摘要,然后用HKDF算法对共享密钥和消息摘要派生密钥,最后通过加密套件返回AEAD算法的实现。

2、确认了加密套件的作用。

3、计算两端均支持的HTTP协议。

发送Server证书以及签名

此阶段主要涉及三个消息,分别是certificateRequestMsgTLS13certificateMsgTLS13certificateVerifyMsg

其中certificateRequestMsgTLS13仅在双向认证时才发送给Client,单向认证时Server不发送此消息。这里也再次印证了前面单向认证和双向认证时序图中Server发送的消息数量不一致的原因。

certificateMsgTLS13消息的主体是Server的证书这个没什么好说的,下面着重分析一下certificateVerifyMsg

私钥签名

首先,构建certificateVerifyMsg并设置其签名算法。

certVerifyMsg := new(certificateVerifyMsg)
certVerifyMsg.hasSignatureAlgorithm = true // 没有签名算法无法签名,所以直接写true没毛病
certVerifyMsg.signatureAlgorithm = hs.sigAlg

上述代码中hs.sigAlg选择合适的证书以及签名算法小节选择的签名算法。

然后,通过签名算法计算签名类型以及签名hash,并构建签名选项。以下为笔者Debug结果:

image

由上图知,签名类型为signatureRSAPSS,签名哈希算法为SHA256signedMessage的作用是将消息的摘要和serverSignatureContext(值为TLS 1.3, server CertificateVerify\x00)常量按照固定格式构建为待签名数据。

最后,计算签名并发送消息。

sig, err := hs.cert.PrivateKey.(crypto.Signer).Sign(c.config.rand(), signed, signOpts)
if err != nil {
  // 省略代码
  return errors.New("tls: failed to sign handshake: " + err.Error())
}
certVerifyMsg.signature = sig
hs.transcript.Write(certVerifyMsg.marshal())

特别提醒,私钥加密公钥解密称之为签名。

小结:本节主要介绍了此阶段会发送的三种消息,以及Server签名的过程。

发送finishedMsg并再次计算密钥

发送finishedMsg

finishedMsg的内容非常简单,仅一个字段:

finished := &finishedMsg{
  verifyData: hs.suite.finishedHash(c.out.trafficSecret, hs.transcript),
}

verifyData通过加密套件的finishedHash计算得出,下面我们看看finishedHash的内容:

func (c *cipherSuiteTLS13) finishedHash(baseKey []byte, transcript hash.Hash) []byte {
    finishedKey := c.expandLabel(baseKey, "finished", nil, c.hash.Size())
    verifyData := hmac.New(c.hash.New, finishedKey)
    verifyData.Write(transcript.Sum(nil))
    return verifyData.Sum(nil)
}

HMAC是一种利用密码学中的散列函数来进行消息认证的一种机制,所能提供的消息认证包括两方面内容(此内容摘自百度百科):

消息完整性认证:能够证明消息内容在传送过程没有被修改。

信源身份认证:因为通信双方共享了认证的密钥,接收方能够认证发送该数据的信源与所宣称的一致,即能够可靠地确认接收的消息与发送的一致。

上述代码中,c.expandLabel最种会调用hkdf.Expand派生出新的密钥。最后用新的密钥以及消息摘要通过HMAC算法计算出verifyData

收到finishedMsg一方通过同样的方式在本地计算出verifyData',如果verifyData'verifyData相等,则证明此消息未被修改且来源可信。

再次计算密钥

本次计算密钥的过程和前面计算密钥的流程相似,所以直接上代码:

hs.masterSecret = hs.suite.extract(nil,
    hs.suite.deriveSecret(hs.handshakeSecret, "derived", nil))

hs.trafficSecret = hs.suite.deriveSecret(hs.masterSecret,
    clientApplicationTrafficLabel, hs.transcript)
serverSecret := hs.suite.deriveSecret(hs.masterSecret,
    serverApplicationTrafficLabel, hs.transcript)
c.out.setTrafficSecret(hs.suite, serverSecret)

首先,利用前文已经生成的handshakeSecret 再次派生出masterSecret,然后再从masterSecret派生出trafficSecretserverSecret,最后调用c.out.setTrafficSecret(hs.suite, serverSecret)计算出Server发送数据时的AEAD加密算法。

需要注意的是,此时利用serverSecret生成的AEAD加密算法会用于握手结束后对要发送的业务数据进行加密。

此阶段结束后,Server会调用c.flush()方法,将前面提到的消息一次性发送给Client。

小结

1、本节介绍了finishedMsg的生成过程,其中finishedMsg.verifyData通过HMAC算法计算得出。

2、finishedMsg的作用是确保握手过程中发送的消息未被篡改,且数据来源可信。

3、计算Server发送业务数据时的加密密钥。

Client读消息&发送消息

Client读到serverHelloMsg之后会读取服务端支持的TLS版本并和本地支持的版本做对比,前文已经提到过服务端支持的TLS版本是TLS1.3,因此Client也进入TLS1.3握手流程。

读取serverHelloMsg并计算密钥

Client进入TLS1.3握手流程后,有一系列的检查逻辑,这些逻辑比较长而且笔者也不需要考虑这些异常,因此笔者化繁为简,在下面列出关键逻辑:

selectedSuite := mutualCipherSuiteTLS13(hs.hello.cipherSuites,
    hs.serverHello.cipherSuite) // 结合Server支持的加密套件选择双端均支持的加密套件
hs.suite = selectedSuite
hs.transcript = hs.suite.hash.New()
hs.transcript.Write(hs.hello.marshal()) // hs.hello为clientHelloMsg
hs.transcript.Write(hs.serverHello.marshal())

上面这一段代码逻辑和Server处理加密套件以及通过加密套件构建消息摘要算法的实现逻辑相对应,因此笔者不再过多赘述。

下面我们看一下计算握手阶段的密钥以及masterSecret的生成:

sharedKey := hs.ecdheParams.SharedKey(hs.serverHello.serverShare.data)
earlySecret := hs.earlySecret
if !hs.usingPSK {
  earlySecret = hs.suite.extract(nil, nil)
}
handshakeSecret := hs.suite.extract(sharedKey,
    hs.suite.deriveSecret(earlySecret, "derived", nil)) // 通过共享密钥派生出handshakeSecret

clientSecret := hs.suite.deriveSecret(handshakeSecret,
    clientHandshakeTrafficLabel, hs.transcript) // 通过handshakeSecret派生出clientSecret
c.out.setTrafficSecret(hs.suite, clientSecret)
serverSecret := hs.suite.deriveSecret(handshakeSecret,
    serverHandshakeTrafficLabel, hs.transcript) // 通过handshakeSecret派生出serverSecret
c.in.setTrafficSecret(hs.suite, serverSecret)
hs.masterSecret = hs.suite.extract(nil,
    hs.suite.deriveSecret(handshakeSecret, "derived", nil)) // 通过handshakeSecret派生出masterSecret

这里需要提一嘴的是hs.ecdheParams,该值为Client发送HelloMsg这一小节调用generateECDHEParameters函数生成的params。其他逻辑和Server生成握手阶段的密钥保持一致,硬要说不同的话也就只有masterSecret生成的阶段不同。

最后,clientSecret在客户端用于对要发送的数据进行加密,serverSecret在客户端对收到的数据进行解密。

小结:本节梳理了客户端处理serverHelloMsg的逻辑和生成握手阶段密钥的逻辑。

处理Server发送的参数

在客户端需要处理的Server参数只有一个encryptedExtensionsMsg消息。而且处理逻辑也十分简单:

msg, err := c.readHandshake()
encryptedExtensions, ok := msg.(*encryptedExtensionsMsg)
hs.transcript.Write(encryptedExtensions.marshal())
c.clientProtocol = encryptedExtensions.alpnProtocol

如果客户端读取到encryptedExtensionsMsg消息,则直接将Server支持的HTTP协议赋值给c.clientProtocol。在之后的HTTP请求中会根据TLS握手状态以及服务端是否支持h2决定是否将本次请求升级为http2

验证证书和签名

本小节仍然继续处理Server发送的消息,主要包含certificateRequestMsgTLS13certificateMsgTLS13certificateVerifyMsg,这三个消息均和证书相关。

首先,处理certificateRequestMsgTLS13消息,仅在双向认证时,服务端才发送此消息。在本阶段的处理逻辑也很简单,读取该消息并记录。

msg, err := c.readHandshake()
certReq, ok := msg.(*certificateRequestMsgTLS13)
if ok {
  hs.transcript.Write(certReq.marshal())
  hs.certReq = certReq
  msg, err = c.readHandshake()
}

其次,处理certificateMsgTLS13消息,该消息中主要包含证书信息,Client在获取到证书信息后要校验证书是否过期以及是否可信任。

if err := c.verifyServerCertificate(certMsg.certificate.Certificate); err != nil {
  return err
}

c.verifyServerCertificate的内部逻辑如果各位读者有兴趣可以下载Demo调试一番,笔者在这里就不对该方法做深入的展开和分析了。

最后,处理certificateVerifyMsg消息。前面在处理certificateMsgTLS13时已经验证了证书可信任或者Client可以忽略不受信任的证书,但是Client仍无法确信提供这个证书的服务器是否持有该证书,而验证签名的意义就在于确保该服务确实持有该证书。

在Server发送certificateVerifyMsg消息时已经使用了证书对应的私钥对需要签名的数据进行签名,客户端利用证书的公钥解密该签名并和本地的待签名数据做对比以确保服务端确实持有该证书。

// 根据签名算法返回对应的算法类型和hash算法
sigType, sigHash, err := typeAndHashFromSignatureScheme(certVerify.signatureAlgorithm)
signed := signedMessage(sigHash, serverSignatureContext, hs.transcript)
if err := verifyHandshakeSignature(sigType, c.peerCertificates[0].PublicKey,
    sigHash, signed, certVerify.signature); err != nil {
  c.sendAlert(alertDecryptError)
  return errors.New("tls: invalid signature by the server certificate: " + err.Error())
}

typeAndHashFromSignatureScheme函数和signedMessage函数在前文已经提到过,因此不再做重复叙述。

verifyHandshakeSignature函数的内部实现涉及到非对称加密算法的加解密,因笔者的知识有限,确实无法做更进一步的分析,在这里给各位读者道个歉~

小结:在这一小节简单介绍了客户端证书的验证以及签名的验证。

处理finishedMsg并再次计算密钥

客户端对证书签名验证通过后,接下来还需要验证消息的完整性。

finished, ok := msg.(*finishedMsg)
expectedMAC := hs.suite.finishedHash(c.in.trafficSecret, hs.transcript)
if !hmac.Equal(expectedMAC, finished.verifyData) {
  c.sendAlert(alertDecryptError)
  return errors.New("tls: invalid server finished hash")
}

finishedHash方法说明请参考发送finishedMsg并再次计算密钥这一小节。

只有当客户端计算的expectedMACfinishedMsg.verifyData一致时才可继续后续操作,即客户端二次计算密钥。

hs.trafficSecret = hs.suite.deriveSecret(hs.masterSecret,
    clientApplicationTrafficLabel, hs.transcript)
serverSecret := hs.suite.deriveSecret(hs.masterSecret,
    serverApplicationTrafficLabel, hs.transcript)
c.in.setTrafficSecret(hs.suite, serverSecret)

二次计算密钥时分别派生出trafficSecretserverSecret两个密钥。

需要注意的是,此时利用serverSecret生成的AEAD加密算法会用于握手结束后对收到的业务数据进行解密。

至此,Server发送给客户端的消息已经全部处理完毕。

小结:本节主要介绍了客户端通过HMAC算法确保收到的消息未被篡改以及二次计算密钥。

Client发送最后的消息

客户端已经验证了服务端消息的完整性,但是服务端还未验证客户端消息的完整性,因此客户端还需要发送最后一次数据给服务端。

首先判断是否需要发送证书给Server:

if hs.certReq == nil {
  return nil
}
certMsg := new(certificateMsgTLS13)
// 此处省略代码
certVerifyMsg := new(certificateVerifyMsg)
certVerifyMsg.hasSignatureAlgorithm = true
// 此处省略代码

根据验证证书和签名这一小节的描述,如果服务端要求客户端发送证书则hs.certReq不为nil。

certificateMsgTLS13的主体也是证书,该证书的来源为客户端tls.Config配置的证书,在本例中客户端配置证书逻辑如下:

tlsConf.NextProtos = append(tlsConf.NextProtos, "h2", "http/1.1")
tlsConf.Certificates = make([]tls.Certificate, 1)
if len(certFile) > 0 && len(keyFile) > 0 {
  var err error
  tlsConf.Certificates[0], err = tls.LoadX509KeyPair(certFile, keyFile)
  if err != nil {
    return nil, err
  }
}

既然要发送证书给服务端,那么同服务端逻辑一样也需要发送certificateVerifyMsg提供消息签名的信息。客户端签名逻辑和服务端签名逻辑一致,因此笔者不再赘述。

最后,客户端需要发送finishedMsg给服务端:

finished := &finishedMsg{
  verifyData: hs.suite.finishedHash(c.out.trafficSecret, hs.transcript),
}
hs.transcript.Write(finished.marshal())
c.out.setTrafficSecret(hs.suite, hs.trafficSecret)

需要注意的是hs.trafficSecret在第二次计算密钥时就已经被赋值,当finishedMsg发送后,利用hs.trafficSecret生成的AEAD加密算法会对客户端要发送的业务数据进行加密。

至此,客户端的握手流程全部完成。

小结

1、如果服务端要求客户端发送证书,则客户端会发送certificateMsgTLS13certificateVerifyMsg消息

2、发送finishedMsg消息并设置发送业务数据时的密钥信息。

Server读Client最后的消息

首先,服务端在TLS握手的最后阶段,会先判断是否要求客户端发送证书,如果要求客户端发送证书则处理客户端发送的certificateMsgTLS13certificateVerifyMsg消息。服务端处理certificateMsgTLS13certificateVerifyMsg消息的逻辑和客户端处理这两个消息的逻辑类似。

其次,读取客户端发送的finishedMsg, 并验证消息的完整性,验证逻辑和客户端验证finishedMsg逻辑一致。

最后,设置服务端读取业务数据时的加密信息:

c.in.setTrafficSecret(hs.suite, hs.trafficSecret)

hs.trafficSecret在服务端第二次计算加密信息时就已经赋值,当读完客户端发送的finishedMsg之后再执行此步骤是为了避免无法解密客户端发送的握手信息。

至此,服务端的握手流程全部完成。

握手完成之后

完成上述流程后,笔者还想试试看能不能从握手过程获取的密钥信息对业务数据进行解密。说干就干,下面是笔者在TLS握手完成之后用Client连接发送了一条消息的代码。

// main.go 握手完成之后,client发送了一条数据
client.Write([]byte("点赞关注:新世界杂货铺"))

下面是运行Demo后的输出截图:

image

图中红色箭头部分为在Internet中真实传输的数据,蓝色箭头部分为其解密结果。

一点感慨

关于TLS握手流程的文章笔者想写很久了,现在总算得偿所愿。笔者不敢保证把TLS握手过程的每一个细节都描述清楚,所以如果中间有什么问题还请各位读者及时指出,大家相互学习。

写到这里时笔者的内心也略有忐忑,毕竟这中间涉及了很多密码学相关的知识,而在笔者各种疯狂查资料期间发现国内具有权威性的文章还是太少。像ECDH之类的关键词在百度百科都没有收录,果然维基百科才是爸爸呀。

最后一点感概是关于Go中io.Reader io.Writer这两个接口的,不得不说这两个接口的设计真的很简单但是真的非常通用。笔者的Demo正是基于这两个接口实现,否则笔者的心愿很难完成。

挖坑

在上一篇文章中,笔者给了一条彩蛋——“下一期TLS/SSL握手流程敬请期待”。哇,这可真的是自己坑自己了,本篇文章未完成之前,笔者愣是断更了也没敢发别的文章。果然自己作的死,哭着也要作完。

有了前车之鉴,笔者决定以后不再放彩蛋,而是挖坑(填坑时间待定😊):本篇中主要介绍了TLS1.3的握手流程,那么TLS1.2也快了~

最后,衷心希望本文能够对各位读者有一定的帮助。

  1. 写本文时, 笔者所用go版本为: go1.15.2
  2. 文章中所用完整例子:https://github.com/Isites/go-...
查看原文

赞 1 收藏 0 评论 0

Gopher指北 发布了文章 · 2020-11-23

一个隐藏在方法集和方法调用中且易被忽略的小细节

来自公众号:新世界杂货铺

作为一个长期从事Go语言开发的程序猿,笔者不敢说自己是老油条但也勉强算一个小油条。然而就在今天,笔者研究TLS/SSL握手源码的时候,突然灵光一闪,想到了一个和自己认知不符的现象,于是赶紧写了一个例子验证一番,结果当头一棒直到码这篇文章时依旧懵逼。

话不多说,上锤!

image

不好意思,不是这个锤,是下面这个:

type set interface {
    set1(s string)
    set2(s string)
}
type test struct {
    s string
}
func (t *test) set1(s string) {
    t.s = s
}
func (t test) set2(s string) {
    t.s = s
}
func main() {
    var (
        t1 test
        t2 = new(test)
    )
    t1.set1("1")
    fmt.Print(t1.s)
    t1.set2("2")
    fmt.Print(t1.s)
    t2.set1("3")
    fmt.Print(t2.s)
    t2.set2("4")
    fmt.Print(t2.s)
    fmt.Print(" ")
    _, ok1 := (interface{}(t1)).(set)
    _, ok2 := (interface{}(t2)).(set)
    fmt.Println(ok1, ok2)
}

正确答案笔者就不直接公布了,请各位读者耐心在后文寻找答案。

方法集

根据golang官方文档知道,一个类型有一个与之关联的方法集。接口类型的方法集是接口中定义的方法。

官方文档中特别提到,类型T的方法集包含用T声明为Receiver的所有方法,而指针类型\T的方法集包含用T和\T声明的所有方法。

此时,我们回到上面的例子可以很明显的知道下面这段代码输出为false true

_, ok1 := (interface{}(t1)).(set)
_, ok2 := (interface{}(t2)).(set)
fmt.Println(ok1, ok2)

T类型的方法集不包含\*T类型的方法集,因此t1无法转为set接口类型。

事实上,根据这部分官方文档笔者更加疑惑了,因为上述例子可以正常运行,而且类型为test的变量调用了(*test).set1方法。抱着这样的疑惑笔者疯狂谷狗,最后在stackoverflow的指导下发现了这种情况和方法调用有关。

这里特别感谢一下谷狗和stackoverflow。

方法调用

方法调用笔者在这里仅说明和本篇相关的内容,其他细节相信各位读者都已经了然于胸。

下面,先看看官方文档原文:

A method call x.m() is valid if the method set of (the type of) x contains m and the argument list can be assigned to the parameter list of m. If x is addressable and &x's method set contains m, x.m() is shorthand for (&x).m()

简单来说,如果x可寻址,且&x的方法集包含m,则x.m()(&x).m的缩写。这样前面的例子能够正常运行也在情理之中了。

因此,前面例子的最终输出结果是:1133 false true

如果读者对结构体中的值未发生改变有疑惑,请参考笔者的这篇文章——[为什么go中的receiver name不推荐使用this或者self]()。

你以为你都懂了

写完前面的方法集和方法调用笔者细细思考一番,确认没有其他遗漏的细节,于是放心的上了个厕所。结果厕所还没上完,立马想到一个问题(额外多扯一句,笔者经常在上厕所的时候找到灵感,这可能就是劳逸结合的最佳实践吧):

type los string

func (s los) p1() {
    fmt.Println(s)
}

func (s *los) p2() {
    fmt.Println(s)
}

func main() {
    var s1 los = "1111"
    var s2 *los = &s1
    const s3 los = "3333"
    s1.p1()
    s1.p2()
    s2.p1()
    s2.p2()
    s3.p1()
    s3.p2()
}

如果你对上面的代码没有任何疑问且认为上述代码能够正常运行,那只能说明你对本文的阅读还不够认真。

我们先看看上述代码在vscode中的报错。

image

前面介绍方法调用时,如果x可寻址,则x可以调用&x的类型的方法集。上述代码s3是常量,是不可以寻址的,因此无法调用(*los).p2方法。

以上,就是笔者曾经忽略的细节,现在回过头来看一看倒也充满了乐趣。

彩蛋

本篇是研究TLS/SSL握手流程的副产品,因为TLS/SSL握手流程笔者还在整理中,故这篇文章先行一步给个预告,下一期TLS/SSL握手流程敬请期待。

最后,衷心希望本文能够对各位读者有一定的帮助。

注:

  1. 写本文时, 笔者所用go版本为: go1.15.2
  2. 文章中所用完整例子:https://github.com/Isites/go-...

参考

https://golang.org/ref/spec#M...

https://golang.org/ref/spec#C...

查看原文

赞 0 收藏 0 评论 0

Gopher指北 发布了文章 · 2020-11-17

HTTP2服务器推送的第一次尝试

来自公众号:新世界杂货铺

在HTTP1.x中,访问一个页面,浏览器首先获取HTML资源,然后在解析页面时增量地获取其他资源,服务器必须等待浏览器发出请求后才下发页面内资源。而服务器实际上是知道页面内资源有哪些的,如果服务器能够在浏览器显式请求资源之前就将资源推送到浏览器,页面加载速度将会大大提示,这也是本篇的主旨。

本篇主要分为两个部分,第一部分是用go实现的服务器推送例子,第二部分是自签名证书。为什么会有自签名证书,这里笔者先卖个关子,继续阅读后文将会守得云开见月明。

服务器推送例子

目前仅有HTTP2支持服务器推送,HTTP1.x不支持服务器推送,那我们在代码中应该如何判断当前服务器是否支持推送?

在Go中,我们通过判断http.ResponseWriter是否实现了http.Pusher接口就可以知道当前服务器是否支持推送。

下面为笔者写下的第一个服务器推送例子:

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    if r.URL.Path != "/" {
        http.NotFound(w, r)
        return
    }
    pusher, ok := w.(http.Pusher)
    if ok {
        // 主动推送服务资源
        if err := pusher.Push("/static/app.js", nil); err != nil {
            log.Printf("Failed to push: %v", err)
        }
        if err := pusher.Push("/static/style.css", nil); err != nil {
            log.Printf("Failed to push: %v", err)
        }
    }
    // 下发浏览器首屏资源
    fmt.Fprintf(w, `<html>
    <head>
        <title>新世界杂货铺</title>
        <link rel="stylesheet" href="/static/style.css"">
    </head>
    <body>
        <div>Hello 新世界杂货铺</div>
        <div id="content"></div>
        <script data-original="/static/app.js"></script>
    </body>
    </html>`)
})
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
http.ListenAndServe(":8080", nil)

上述代码中app.js内容如下:

document.getElementById("content").innerHTML = '"新世界杂货铺" from js'

运行上述代码后,在浏览器中访问http://localhost:8080/得到如下结果:

image

我们看上图中标红部分发现js资源和样式资源并不是服务器推送下发的,而且使用的是HTTP1.1,结合笔者前面的文章知道Go是支持HTTP2的且HTTP2需要使用SSL/TLS即HTTPS。因此,笔者修改监听代码如下:

http.ListenAndServeTLS(":8080", "ca.crt", "ca.key", nil)

再次运行上述代码,并在浏览器中访问https://localhost:8080/得到如下结果:

image

我们看图中红色部分知本次请求使用了HTTP2协议,并且静态资源由服务器推送。

上述代码中ca.crtca.key分别为自签名证书以及私钥,该证书及私钥已上传至笔者的github,github链接见文末。

生成自签名证书

注:笔者生成证书环境为macOS

笔者生成自签名证书时,先祭出搜索大法,然后使用网上的命令生成证书,最后证书是生成了,但是运行上述例子后在浏览器中的访问结果却不尽人意。

首先执行下述命令生成证书:

// 生成私钥
openssl genrsa -out old.key 2048
// 生成证书签名请求,此时需要填写一些证书信息
openssl req -new -key old.key -out old.csr
// 签发证书
openssl x509 -req -days 365 -in old.csr -signkey old.key -out old.crt

修改例子中的证书和私钥为old.crtold.key,最后在Chrome浏览器中访问结果如下:

image

上述页面给了不安全提示却无法继续不安全访问(Safari浏览器可以进行不安全地访问),作为一个轻微强迫症患者,笔者的内心有一丝丝难受。

于是笔者翻了openssl官网,最后对生成自签名证书的命令做了改进。

首先在执行命令的目录创建一个ca.cnf(可以随意命名),并在该文件中写入如下内容:

# 更多x509v3_config见https://www.openssl.org/docs/man1.1.1/man5/x509v3_config.html
[ ca_conf ]
extendedKeyUsage = serverAuth # 能够继续进行不安全地访问的关键
basicConstraints = CA:FALSE

改进后的签名命令如下:

// 生成私钥
openssl genrsa -out ca.key 2048
// 生成证书签名请求,此时需要填写一些证书信息
openssl req -new -key ca.key -out ca.csr
// 签发证书
openssl x509 -req -sha256 -extfile ca.cnf -extensions ca_conf -days 365 -in ca.csr -signkey ca.key -out ca.crt

此证书即为前面例子所使用的证书,并且第一次在浏览器中访问会有如下提示:

image

点击图中红框部分即可正常访问页面。

什么时候使用推送

完成上面的例子之后,笔者特意去观察了百度、淘宝和谷歌的首页发现大家均已开始使用HTTP2,但是好像还没有公司使用服务器推送。

因此笔者下面的总结全凭个人经验猜测:

  1. 服务器推送不要滥用,仅推送影响该页面展示的关键资源,毕竟前端的懒加载已经十分成熟。
  2. 浏览器能够对资源进行缓存,对于已经缓存了的资源继续推送没有意义,所以这种场景下要避免二次推送。

最后,衷心希望本文能够对各位读者有一定的帮助。

参考

https://blog.golang.org/h2push

https://blog.csdn.net/qq_4187...

注:

  1. 写本文时, 笔者所用go版本为: go1.15.2
  2. 文章中所用完整例子:https://github.com/Isites/go-...
查看原文

赞 0 收藏 0 评论 0

Gopher指北 发布了文章 · 2020-11-09

又一道比较运算符相关的面试题让我明白基础很重要

来自公众号:新世界杂货铺

比较运算不简单啊

我们先看一下上一期的投票结果:

image

首先,笔者自己选择了true,所以实际结果是41%的读者都选择了错误的答案。看到这个结果,笔者相信上一篇文章还是能够帮助到大家。

经过千辛万苦终于明白了上一道面试题是咋回事儿,这个时候却见面试官微微一笑道:“下面的输出结果是什么”。

type blankSt struct {
    a int
    _ string
}
bst1 := blankSt{1, "333"}
bst2 := struct {
    a int
    _ string
}{1, "555"}
fmt.Println(bst1 == bst2)

这里笔者先留个悬念,结果见后文。

类型声明

注意:本节不介绍语法等基础内容,主要描述一些名词以便于后文的理解。

类型声明将标识符(类型名称)绑定到类型,其两种形式为类型定义和类型别名。

下面我们通过一个例子对标识符类型defined type(后文会使用这个名词)进行解释:

// 类型别名
type t1 = struct{ x, y float64 }
// 类型定义
type t2 struct{ x, y float64 }

标识符(类型名称):在上面的例子中,t1,t2为标识符。

类型struct{ x, y float64 }为类型。

类型别名不会创建新的类型。在上述例子中t1struct{ x, y float64 }是相同的类型。

类型定义会创建新的类型,且这个新类型又被叫做defined type。在上述例子中,新类型t2struct{ x, y float64 }是不同的类型。

underlying type

定理一
每一个类型T都有一个underlying type(笔者称之为原始类型,在后面文章中的原始类型均代表underlying type)。

定理二:如果T是预定义的booleannumericstring类型之一,或者是类型字面量则T的原始类型是其本身,否则T的原始类型为T在其类型声明中引用的类型的原始类型。

Go中的数组、结构体、指针、函数、interface{}、slice、map和channel类型均由类型字面量构成。下面以map为例:

type T map[int]string
var a map[int]string

在上面的例子中,T为map类型,a为map类型的变量,类型字面量均为map[int]string且根据定理二可知T的原始类型为map[int]string

下面再看一个例子加深对原始类型的理解:

type (
    A1 = string
    A2 = A1
)

type (
    B1 string
    B2 B1
    B3 []B1
    B4 B3
)

上述例子中,stringA1A2B1B2的原始类型为string[]B1B3B4的原始类型为[]B1

类型相同

在Go中一个defined type类型总是和其他类型不同。类型相同情况如下:

1、两个数组长度相同且元素类型相同则这两个数组类型相同。

2、两个切片元素类型相同则这两个切片类型相同。

3、两个函数有相同数量的参数和相同数量的返回值,且对应位置的参数类型和返回值类型均相同则这两个函数类型相同。

4、如果两个指针具有相同的基本类型则这两个指针类型相同。

5、如果两个map具有相同类型的key和相同类型的元素则这两个map类型相同。

6、如果两个channel具有相同的元素类型且方向相同则这两个channel类型相同。

7、如果两个结构体具有相同数量的字段,且对应字段名称相同,类型相同并且标签相同则这两个结构体类型相同。对于不同包下面的结构体,只要包含未导出字段则这两个结构体类型不相同。

8、如果两个接口的方法数量和名称均相等,且相同名称的方法具有相同的函数类型则这两个接口类型相同。

类型可赋值

满足下列任意条件时,变量x能够赋值给类型为T的变量。

1、x的类型和T类型相同。

2、x的类型V和T具有相同的原始类型,并且V和T至少有一个不是defined type

type (
    m1 map[int]string
    m2 m1
)
var map1 map[int]string = make(map[int]string)
var map2 m1 = map1
fmt.Println(map2)

map1和map2变量的原始类型为map[int]string,且满足只有map2是defined type,所以能够正常赋值。

var map3 m2 = map1
fmt.Println(map3)
var map4 m2 = map2

map3和map1同样满足条件,所以能够正常赋值。但是map4和map2不满足至少有一个不是defined type这一条件,故会编译报错。

3、T是interface{} 并且x的类型实现了T的所有方法。

4、x是双向通道,T是通道类型,x的类型V和T具有相同的元素类型,并且V和T中至少有一个不是defined type

根据上面我们可以知道一个隐藏逻辑是,双向通道能够赋值给单向通道,但是单向通道不能赋值给双向通道。


var c1 chan int = make(chan int)
var c2 chan<- int = c1
fmt.Println(c2 == c1) // true
c1 = c2 // 编译错误:cannot use c2 (variable of type chan<- int) as chan int value in assignment

因为c1能够正常赋值给c2,所以根据前一篇文章的定理“在任何比较中,至少满足一个操作数能赋值给另一个操作数类型的变量”知c1和c2可比较。

5、x是预声明标识符nil,T是指针、函数、切片、map、channel或interface{}类型。

6、x是可由类型T的值表示的无类型常量。

type (
    str1 string
    str2 str1
)
const s1 = "1111"
var s3 str1 = s1
var s4 str2 = s1
fmt.Println(s3, s4) // 1111 1111

上述例子中,s1是无类型字符串常量故s1可以赋值给类型为str1和str2的变量。

下图是在vscode中当鼠标悬浮在变量s1上时给的提示。

image

注意:笔者在实际的验证过程中发现部分有类型的常量和变量在赋值时会编译报错。

const s2 string = "1111"
var s5 str1 = s2

上述代码在vscode中的错误为cannot use s2 (constant "1111" of type string) as str1 value in variable declaration

看到上述编译报错,笔者顿时惊了,就算不满足第6点也应该满足第2点呀。抱着满是疑惑的心情笔者利用代码跳转,最后在builtin.go发现了type string string这样一条语句。

结合上述代码我们知道str1string是由类型定义创建的新类型即defined type,所以var s5 str1 = s2也不满足第2点。

builtin.go文件对booleannumericstring的类型均做了类型定义,下面以int做近一步验证:

type int1 int
var i1 int = 1
const i2 int = 1
var i3 int1 = i1 // cannot use i1 (variable of type int) as int1 value in variable declaration
var i4 int1 = i2 // cannot use i2 (constant 1 of type int) as int1 value in variable declaration

上述结果符合预期,因此我们在平时的开发中对于变量赋值的细节还需牢记于心。

分析总结

有了前面类型相同类型可赋值两小节的基础知识我们按照下面步骤对本篇的面试题进行分析总结。

1、类型是否相同?

我们先列出面试题中需要比较的两个结构体:

type blankSt struct {
    a int
    _ string
}
struct {
    a int
    _ string
}

根据类型相同小节的第7点知,这两个结构体具有相同数量的字段,且对应字段名称相同、类型相同并且标签也相同,因此这两个结构体类型相同。

2、是否满足可赋值条件?

根据类型可赋值小节的第1点知,这两个结构体类型相同因此满足可赋值条件。

面试题中的两个结构体比较简单,下面笔者对结构体的不同场景进行补充。

  • 结构体tag不同
type blankSt1 struct {
    a int `json:"a"`
    _ string
}
bst11 := struct {
    a int
    _ string
}{1, "555"}
var bst12 blankSt1 = bst11

上述代码在vscode中的报错为cannot use bst11 (variable of type struct{a int; _ string}) as blankSt1 value in variable declaration。两个结构体只要tag不同则这两个结构体类型不同,此时这两个结构体不满足任意可赋值条件。

  • 结构体在不同包,且所有字段均导出
package ttt

type ST1 = struct {
    F string
}
var A = ST1{
    F: "1111",
}

package main

type st1 struct {
    F string
}

var st11 st1 = ttt.A
fmt.Println(st11) // output: {1111}

根据类型相同小节的第7点和类型可赋值小节的第1点知,ST1和st1类型相同且可赋值,因此上述代码能够正常运行

  • 结构体在不同包,且包含未导出字段
package ttt
type ST2 = struct {
    F string
    a string
}
var B = ST2{
    F: "1111",
}
package main
type st2 struct {
    F string
    a string
}
var st21 st2 = ttt.B
fmt.Println(st21)

运行上述代码时出现cannot use ttt.B (type struct { F string; ttt.a string }) as type st2 in assignment错误。

由于st2和ST2类型不同且他们的原始类型分别为struct { F string a string } struct { F string; ttt.a string },所以ttt.b无法赋值给st21。

3、总结

blankStstruct { a int _ string }类型相同且满足可赋值条件,因此根据“在任何比较中,至少满足一个操作数能赋值给另一个操作数类型的变量”这一定理知面试题中的bst1bst2可比较。

接下来根据上一篇文章提到的结构体比较规则知bst1bst2相等,所以面试题最终输出结果为true

如果不是再去研读一篇Go的基础语法,笔者还不知道曾经遗漏了这么多细节。“读书百遍其义自见”,古人诚不欺我!

最后,衷心希望本文能够对各位读者有一定的帮助。

注:

  1. 写本文时, 笔者所用go版本为: go1.14.2
  2. 文章中所用完整例子:https://github.com/Isites/go-...
查看原文

赞 0 收藏 0 评论 0

认证与成就

  • 获得 21 次点赞
  • 获得 2 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 2 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2020-08-24
个人主页被 1.8k 人浏览