发布于 ,更新于 

HTP 笑传:扔掉 UDP,试试并不特殊的低精度时间同步

万恶之源

众所周知,NTPNetwork Time Protocol)是互联网上最古老的一个协议之一,以下是维基百科对这个条目的介绍:

中文 Wikipedia 对于 NTP 协议的解释

网络时间协议(英语:Network Time Protocol,缩写:NTP)是在数据网络潜伏时间可变的计算机系统之间通过分组交换进行时钟同步的一个网络协议,位于OSI模型的应用层。自1985年以来,NTP是目前仍在使用的最古老的互联网协议之一。NTP由特拉华大学的大卫·米尔斯(David L. Mills)设计。
NTP意图将所有参与计算机的协调世界时(UTC)时间同步到几毫秒的误差内。它使用Marzullo算法的修改版来选择准确的时间服务器,其设计旨在减轻可变网络延迟造成的影响。NTP通常可以在公共互联网保持几十毫秒的误差,并且在理想的局域网环境中可以实现超过1毫秒的精度。不对称路由和拥塞控制可能导致100毫秒(或更高)的误差。
该协议通常描述为一种主从式架构,但它也可以用在点对点网络中,对等体双方可将另一端认定为潜在的时间源。发送和接收时间戳采用用户数据报协议(UDP)的端口123实现。这也可以使用广播或多播,其中的客户端在最初的往返校准交换后被动地监听时间更新。NTP提供一个即将到来闰秒调整的警告,但不会传输有关本地时区或夏时制的信息。

注意观察如上加粗的一行字:NTP 基于 UDP 协议实现。

而众所周知,国内对于 UDP 协议的劣化又不是一天两天了,因为网管不会配网/防火墙/故意等原因,在网络栈中直接干掉 UDP 协议包的情况也大有人在。

于是在今天午后:TUNA 技术群里出现了这样一幕:

拿这个当引子:

  • 如果仅讨论不用 UDP 实现时间同步的方法,议题范围未免有些过大。最常见的流派是「新协议派」,能想到的方式放在文末。

  • 不妨从更简单又 Dirty 的方面出发,把 NTP 改名为 HTPHTTP Time Protocol),梳理一下通过 HTTP 同步时间的几种脏玩法。

在此预先再放个引子,在下述方法的说明中不再赘述:

  • 因为在进行时间同步时,我们应当默认被操作服务器的时钟是爆炸的,由此也能推断:HTTPS 里的证书有效期验证多半也是爆炸的
  • 理论上我们可以通过 curl -k 等方式绕过证书验证正常发送请求,但这对于某些关心请求纯洁性的人可能就有些抵触。
  • 但又换个角度来想,通过 HTTPS 协议,其中的握手和加密时间无形中也是产生延迟的损耗,这么看可能还是 HTTP 更低延迟些。到服务器的 Ping 应该也要考虑在内。
  • 但话又说回来,这就是篇整活的文章,还是不想 HTTP 和 HTTPS 的事了吧。

Header 里的 Date

Date Header 作为一个上古规范,被定义在了 RFC9110 里,并被赋予了较严格的实践规范:

  • 在 Fetch 里,它被定义成禁止修改的标头 ,人工构建的 Date 标头在符合规范的客户端中会被丢弃。
  • 在 RFC 规范里,它被要求设置为服务器所获取的最为精确的时间,且不允许人工生成。

由此来看,从 HTTP Header 中拿时间是一种很容易想到的方式。

这个方案适用于互联网世界上的大部分服务器,而且无关乎 HTTP 和 HTTPS,以本站为例,就连强制转 HTTPS 的 301 报文里都有 Date

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ curl -vvv http://blog.hanlin.press
* Host blog.hanlin.press:80 was resolved.
* Connected to blog.hanlin.press port 80
> GET / HTTP/1.1
> Host: blog.hanlin.press
> User-Agent: curl/8.9.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 301 Moved Permanently
< Server: nginx
< Date: Wed, 16 Oct 2024 16:40:17 GMT
< Content-Type: text/html
< Content-Length: 162
< Connection: keep-alive
< Location: https://blog.hanlin.press/

继续以本站为例,使用如下指令就可以提取 Date 标头里的时间:

1
curl --head -s http://blog.hanlin.press | grep -i "Date: " | cut -d' ' -f2-

date -s 可以用于从字符串设置时间:

1
-s, --set=STRING           set time described by STRING

组合一下,用下述指令就可以设置时间了:

1
date -s "`curl --head -s http://blog.hanlin.press | grep -i "Date: " | cut -d' ' -f2-`"

针对这个奇葩需求,GitHub 上还有人搞了个叫 htpdate 的项目,把上述命令可能会出现的问题(比如说原服务不可用)擦了个屁股,还支持多个服务器,README 最后还有一串衍生项目,可以关注一下。

借力 Cloudflare

本段灵感同样来自上文截图。

凡是使用了 Cloudflare 的 CDN,都会有一个 /cdn-cgi/trace 的接口,从他们自家的 1.1.1.1 DNS,到他们的合作产品,通通都有覆盖。

根据 GitHub 上的分析/cdn-cgi/trace 所包含的信息主要有如下这些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fl=Cloudflare WebServer Instance
h=WebServer Hostname
ip=IP Address of client
ts=Epoch Time in seconds.millis (Similar to `date +%s.%3N` in bash)
visit_scheme=https or http
uag=User Agent
colo=IATA location identifier
sliver=Whether the request is splitted
http=HTTP Version
loc=Country Code
tls=TLS or SSL Version
sni=Whether SNI encrypted or plaintext
warp=Whether client over Cloudflares Wireguard VPN
gateway=Whether client over Cloudflare Gateway
rbi=Whether client over Cloudflares Remote Browser Isolation
kex=Key exchange method for TLS

在这里我们要使用的,便是上面的 ts 选项,如上所述,他提供了毫秒级的时间戳,也可以喂给 date -s 校准用(时间戳的话前面需要加个 @)。

通过这个方法,大部分情况下就要 HTTPS 派了,因为这个需要获取实际内容,而套了 Cloudflare 的网站一般都伴随着强制 HTTPS 的,但是别急,采用了 Cloudflare 的网站一抓一大把,我们来在北方移动的网络下一个个溜一下:

  • Cloudflare DNS:
    • 1.1.1.1 :平均延迟 86ms,有强制 HTTPS。
    • 1.0.0.1 :平均延迟 202ms,有强制 HTTPS。
  • 采用了 Cloudflare 的典型大客户 japan.com:平均延迟 162ms,无强制 HTTPS
  • Cloudflare 官网 www.cloudflare.com :平均延迟 171ms,无强制 HTTPS
  • Cloudflare 与京东云合作的京东云星盾
    • Cloudflare 中国官网 www.cloudflare-cn.com平均延迟 22ms无强制 HTTPS
    • Cloudflare China Network 接入所用 NS www.cf-ns.com平均延迟 17ms无强制 HTTPS
      • 在备案号京ICP备2020045912号下还有一车神奇域名,请举一反三。
      • 值得注意的是, cf-ns.com 没接入 Cloudflare。
    • 京东云方的京东云星盾业务:

由此可见,在国内使用 Cloudflare 系进行时间同步的话,使用其国内业务是最优之选。

以上面最快的 www.cf-ns.com 为例,运行这个命令便可以得到一个毫秒级的时间戳:

1
curl -s www.cf-ns.com/cdn-cgi/trace | grep -oP '(?<=ts=)\d+\.\d+'

前加 @ 即可完成时间更新:

1
date -s "@`curl -s www.cf-ns.com/cdn-cgi/trace | grep -oP '(?<=ts=)\d+\.\d+'`"

这时候有的观众可能就要问了,Cloudflare 的上一个合作商百度云加速现在是什么状态呢?

很不幸,他们似乎自己搓了一套:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
curl -vvv http://su.baidu.com/cdn-cgi/trace
* Host su.baidu.com:80 was resolved.
* Request completely sent off
< HTTP/1.1 200 OK
< Server: yunjiasu
< Date: Wed, 16 Oct 2024 18:49:27 GMT
< Content-Type: text/plain
< Transfer-Encoding: chunked
< Connection: keep-alive
<
s=12702
h=su.baidu.com
t=2024-10-17 02:49:27
v=HTTP/1.1
ua=curl/8.9.1

还是看看购物网站们吧

本段的灵感在于我看完楼上的消息后,打开了中国科学院国家授时中心的官网,发现页面下方有个显示「北京时间」的区域:

俺寻思:这里的北京时间应该不是从本地获取的吧?说不定授时中心自己有套返回时间的 API 呢?

然后使用 F12 打开页面看源码,看一眼后当场去世:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// var ws = new WebSocket("ws://223.0.12.10/time");

// ws.onopen = function(evt) { //绑定连接事件
//   console.log("Connection open ...");
// };

// ws.onmessage = function(evt) {//绑定收到消息事件
// $("#cTime").html('<span class="bdr4">'+evt.data.substring(0,2)+'</span>:<span class="bdr4">'+evt.data.substring(3,5)+'</span>:<span class="bdr4">'+evt.data.substring(6,8)+'</span>');

// };

// ws.onclose = function(evt) { //绑定关闭或断开连接事件
//   console.log("Connection closed.");
// };

})
function getTime(){
$.ajax({
url: '//quan.suning.com/getSysTime.do',
type: 'GET',
async:false,
dataType: 'json',
})
.done(function(res) {
var date = new Date(res.sysTime2);
var hours = date.getHours()>9?date.getHours():('0'+date.getHours());
var minutes = date.getMinutes()>9?date.getMinutes():('0'+date.getMinutes());
var seconds = date.getSeconds()>9?date.getSeconds():('0'+date.getSeconds());
$("#cTime").html('<span class="bdr4">'+hours+'</span>:<span class="bdr4">'+minutes+'</span>:<span class="bdr4" id="seconds">'+seconds+'</span>');
});
}
getTime();
var seconds = "";
var thisSeconds ="";
function timeAdd(){
thisSeconds = Number($("#seconds").text())+1;
if(thisSeconds==60){
getTime();
}else{
seconds = thisSeconds>9?thisSeconds:'0'+thisSeconds;
$("#seconds").html(seconds);
}

从源码中可以看出,它注释了一个看上去已经不可用的 WebSocket API(这个 IP 反查后为 gdlhz.ac.cn ,打开后是「先进能源科学与技术广东省实验室」),转而使用了一个来自于苏宁商城的 API quan.suning.com/getSysTime.do

为白天发的推送校正一下,这个 API 并不吃 Referer 校验,他只是限流策略狠了一些,一秒访问一次。

它同样不强制 HTTPS,访问返回回来的信息长这样:

1
{"sysTime2":"2024-10-17 02:20:06","sysTime1":"20241017022006"}

然后授时中心的网站就用这玩意儿来显示时间,世界就是一个巨大的草台班子。

这就给我一个启示:购物网站类的秒杀类是最强调时间精确的,何不从这些领域找返回时间的 API 呢?

由此从搜索引擎和人工派整理了一些,供参考:

  • 苏宁的另一个 API:http://f.m.suning.com/api/ct.do 平均延迟 10ms,秒级,无强制 HTTPS

  • 小米商城:http://time.hd.mi.com/gettimestamp 平均延迟 15ms,秒级,无强制 HTTPS

  • 华为商城:http://openapi.vmall.com/serverTime.json 平均延迟 15ms,秒级,无强制 HTTPS

  • 拼多多:http://api.pinduoduo.com/api/server/_stm 平均延迟 21ms毫秒级无强制 HTTPS

  • VIVO 商城:http://mshopact.vivo.com.cn/tool/config 平均延迟 3ms毫秒级无强制 HTTPS

  • 腾讯视频:http://vv.video.qq.com/checktime?otype=json 平均延迟 3ms,秒级,无强制 HTTPS

我们以上面看上去表现最好的 VIVO 商城为例,它的 API 格式大约长这样:

1
{"code":200,"msg":"正常!","data":{"tempCookies":"{\"cookieId\":\"xxx\",\"fakeSessionId\":\"xxx\"}","imgHostUrl":"https://shopact-vivofs.vivo.com.cn/campaign/","vivoShopUrlPrefix":"//shop.vivo.com.cn/wap","callAppSwitch":"0","nowTime":1729104354677,"prdHost":"//mshopact.vivo.com.cn"},"success":true}

我们所需要的是 data 里的 nowTime ,而这可以通过正则或是 jq 提取,我们在这里选简单的后者:

运行这个指令就可以得到一个毫秒级的时间戳:

1
curl -s http://mshopact.vivo.com.cn/tool/config | jq .data.nowTime

date -s 不支持毫秒形式的时间戳,需要转换成秒级。直接按这种方式转换会丢失小数部分:

1
$[$(curl -s http://mshopact.vivo.com.cn/tool/config | jq .data.nowTime) / 1000]

通过字符串截断,我们可以这样拼一个小数版出来:

1
tms=$(curl -s http://mshopact.vivo.com.cn/tool/config | jq .data.nowTime); echo ${tms:0:$((${#tms}-3))}.${tms:$((${#tms}-3))}

合并上之前的 date -s @

1
date -s "@`tms=$(curl -s http://mshopact.vivo.com.cn/tool/config | jq .data.nowTime); echo ${tms:0:$((${#tms}-3))}.${tms:$((${#tms}-3))}`"

顺利在理论上的最短延迟内设置时间。

Bonus

回到文章最头的问题:如果我们不使用 HTTP 整活,有没有方法能够通过 TCP 的方式同步时间?

那肯定是有。

RFC 867 Daytime ProtocolRFC 868 Time Protocol 都支持采用 TCP 来同步时间(但他们都只支持到秒),而 rdate 就是实现了后者 RFC868 的一个客户端。

目前最广为人知的公共服务是 NIST.GOV ,故可以通过 time.nist.gov 来同步时间。

运行如下指令可以通过仅打印的方式测试是否到该服务器联通:

1
rdate -p time.nist.gov

RFC868 运行在 37 端口,但很不幸,目前中文互联网上公开的服务少之又少,如果有了解相关信息的读者可以在评论区留言。

最后,感谢 「TUNA 技术群」集思广益提供的灵感。