用 netcat 实现简单 webhook
需求
最近在折腾 DIY 异地备份,从本地 NAS 上远程备份到一个装着 SSD 的单板机上。出于温度、功耗、风扇寿命等等各种考量,希望在每次备份任务结束以后远程系统能自动关机。
(搭配了一个米家智能插座,定时上电单板机会自动启动,然后搭配充电保护功能,在单板机关机后自动关闭电源,这样一来自动化流程完美闭环。)
因为备份软件支持在任务结束以后调用自定义脚本,于是很容易地想到可以开一个 webhook,任务结束以后调用一下关机接口。现成的方案很成熟,比如这个项目 adnanh/webhook,但是对于我这简单到不能再简单的需求来说,这个“轻量级”的 webhook 实现还是略显沉重。之前浅浅了解过 netcat 这个工具,正好借这个机会深入了解一下,手搓一个 webhook 吧!
Netcat 入门
最初 netcat 是由一位名为 Hobbit 的开发者在 1995 年推出的一个 Unix 网络调试工具(最终版 1.10 于 1996 年),后来被多次重写并移植到很多系统上。比较有名的衍生版本包括了 GNU Netcat,OpenBSD Netcat(支持了 IPv6 和 TLS),mini-netcat(BusyBox 的实现),socat(“netcat++",顾名思义增加了很多针对 socket 的功能),Ncat(Nmap 项目的实现,同样增加了不少功能)。
抛开 socat 和 Ncat 这些改动比较大的现代化版本,目前 Debian/Ubuntu 系的官方 APT 仓库里会提供两种版本的 netcat:netcat-traditional 和 netcat-openbsd。netcat-traditional 就是最原始的那个 Unix 版本的移植,而 netcat-openbsd 则是更强大的 OpenBSD 重写版的移植。这里我选择了使用 OpenBSD 的版本。
调用 netcat 的指令是 netcat
或者 nc
,两者被符号链接到了相同的可执行文件上(nc.openbsd
)。Netcat 主要有三大功能:
-
监听一个本地 TCP/UDP 端口,并做一些数据交互:(以下样例加
-u
代表 UDP 模式)1# -l 代表 listen,监听本地 1234 TCP 端口(收到的数据会打印在终端,也可以用键盘发送数据) 2nc -l localhost 1234 3 4# 监听 1234 TCP 端口,把得到的数据存到文件(不指定 localhost 表示监听本地所有网卡) 5nc -l -p 1234 > download.txt 6 7# 监听 1234 TCP 端口,给所有连接发送这份数据(不指定 localhost 表示监听本地所有网卡) 8cat upload.txt | nc -l -p 1234 9nc -l -p 1234 < upload.txt
-
连接一个远程 TCP/UDP 端口,并做数据交互:(以下样例加
-u
代表 UDP 模式)1# 向远程 example.com:1234 TCP 端口建立连接 2nc example.com 1234 3 4# 从本地 1234 TCP 端口下载数据 5nc localhost 1234 > download.txt 6 7# 向本地 1234 TCP 端口上传数据 8cat upload.txt | nc localhost 1234 9nc localhost 1234 < upload.txt
-
端口扫描(以下样例加
-u
代表 UDP 模式)1# -z 扫描模式,-v verbose 详细日志,扫描本地 1234 TCP 端口是否开放 2nc -zv localhost 1234 3 4# -w 超时秒数,扫描远程 example.com 1234-1334 范围 TCP 端口 5nc -zv -w 5 example.com 1234-1334
总体还是非常简单明了的嘛!
Netcat 服务器
有了以上基础知识,我们已经知道怎么用 nc
在一个端口上收发数据了。那让我们来实现一个简单的服务器吧!
1while true; do
2 req=$(nc -l localhost 8888)
3 echo "Request: $req"
4done
试着请求一下:
1$ echo "test" | nc localhost 8888
2
两边都卡住了!没有任何输出被打印出来。直到请求方 ctrl-c 终止,服务端才打印出了期待的 Request: test
。怎么回事?
快速翻阅一下 man nc
能够发现两个有趣的 flag:
-N shutdown(2) the network socket after EOF on the input. Some servers require this to finish their work. -q seconds after EOF on stdin, wait the specified number of seconds and then quit. If seconds is negative, wait forever (default). Specifying a non-negative seconds implies -N.
意思就是说,默认情况下 netcat 接收到请求方的 EOF 以后,并不会退出执行,而是会继续监听。所以在上面的例子里,服务端和客户端都没有退出,而是继续在等待对方的数据传输。所以解决方法很简单,在服务端加入 -N
选项就可以了,right?…right?
可惜事情没有那么简单,加上 -N
以后两边依然会卡住。我在随意尝试中发现两边都传送信息的话好像才行,例如 echo "123" | nc -l -N localhost 8888
,但是服务端和客户端两边的选项组合非常诡异。于是我尝试了所有的组合,列出了下面这个表格:
server \ client | default | echo | -N | echo + -N |
---|---|---|---|---|
default | X | X | X | ✓ |
echo | X | X | X | ✓ |
-N | X | X | X | ✓ |
echo + -N | X | ✓ | X | ✓ |
为什么这个表格不是对角对称的?百思不得其解。客户端组合 echo
和 -N
选项的时候无论如何都能正确结束连接,但是服务端组合两个选项的时候,客户端也需要 echo
些什么才能结束连接。
于是我从互联网深处挖到了这个回答:
networking - BSD nc (netcat) does not terminate on EOF - Server Fault
https://serverfault.com/a/905462
I, too, was puzzled by netcat’s behavior, so I dug into the code. Here’s the whole story:
nc servers (
nc -l
) and clients only exit after the mutual connection was closed. That is, if each of the parties sent a FIN packet to the other party.A server always sends a
FIN
packet after receiving aFIN
packet from the client. (Unless the server already sent aFIN
packet.)A client sends a FIN packet either:
- after
EOF
on stdin, when run with argument-N
- after
EOF
on stdin, when the server already sent a FIN packetWith option
-d
stdin is ignored andnc
behaves as if it encounteredEOF
on stdin.Option
-N
always implies sendingFIN
after encounteringEOF
on stdin.
翻译一下,nc
需要服务端和客户端各向对方发送一个 FIN 包以后才会终止连接。服务端总会在客户端发出 FIN 以后回复一个 FIN,除非服务端已经发过 FIN 了;客户端在 stdin 上读到 EOF 以后,如果有 -N
选项则会发送 FIN,没有的话会在服务端发送 FIN 后也发送 FIN。
所以根据这个回答,我一开始的解读 “默认情况下 netcat 接收到请求方的 EOF 以后” 是错误的,这个 EOF 其实指的是来自服务端和客户端各自 stdin 的 EOF。后面的组合表格也能够解释了,如果客户端组合两个选项,意味着一定会在结束的时候发送 FIN,而服务端接收到 FIN 则会回复 FIN,连接结束;如果客户端不主动发送 FIN,则服务端需要主动发送 FIN,即需要结合完整的两个选项,而且在这个情况下客户端也需要在 stdin 上有输入才会正确回复 FIN(不然没有 EOF)。
回答的最后也提到了 -d
选项,根据 man nc
这个选项意思是 “Do not attempt to read from stdin.”。这样客户端就算没有 stdin,也会认为读到了 EOF,解决了客户端不发送信息终止不了连接的情况。幸好我们有验证机制的需要(下一章)一定会发送一些信息,所以这里不用 -d
,直接在 stdin 上传输一些数据就好啦。但是服务端可以用上这个选项,不需要非得返回些信息啦。
综合以上,现在的服务器长这样:
1while true; do
2 req=$(nc -l -d -N localhost 8888)
3 echo "Request: $req"
4done
请求一下:
1$ echo "test" | nc localhost 8888
2$
成了。两边的连接正确终止了,服务端也正确打出了 Request: test
。
既然用 nc
也只是往这个端口发送一点数据,那最最有名的请求工具 cURL 调用一下应该也可以吧?
1$ curl localhost:8888
2curl: (52) Empty reply from server
看来 cURL 不喜欢已读不回。服务端改成 echo "hello" | nc -l -N localhost 8888
(有 stdin 就可以去掉 -d
了) 试试?
1$ curl localhost:8888
2curl: (1) Received HTTP/0.9 when not allowed
出现了全新的报错!原来,cURL 实际是工作在 HTTP 协议上的,随便在 TCP 端口上返回些字符串可不是 HTTP 协议(或者可以说是 HTTP/0.9,不过 cURL 也说了这不允许)。从服务端的日志里我们可以看到,cURL 发出了一个 GET / HTTP/1.1
的请求,所以解决方法,就是返回一个最简单的 HTTP/1.1 回复报文 HTTP/1.1 200 OK
:
1while true; do
2 req=$(echo "HTTP/1.1 200 OK" | nc -l -N localhost 8888)
3 echo "Request: $req"
4done
再试一下 cURL:
1$ curl -v localhost:8888
2* Trying 127.0.0.1:8888...
3* Connected to localhost (127.0.0.1) port 8888 (#0)
4> GET / HTTP/1.1
5> Host: localhost:8888
6> User-Agent: curl/7.81.0
7> Accept: */*
8>
9* Mark bundle as not supporting multiuse
10< HTTP/1.1 200 OK
11* Connection #0 to host localhost left intact
一样成了。试一下 echo "test" | nc localhost 8888
,依然符合预期是兼容的。
(严格来说这里应该用 printf "HTTP/1.1 200 OK \r\n"
,按 HTTP/1.1 RFC 2616 规范,换行应该用 CRLF 而不是 LF。但是实际使用上没有区别,大概因为 cURL 实现了对 LF 的兼容,所以这里图方便就用 echo
了。)
一个歪打正着的点是,如果服务端 nc
没有使用 echo
/-d
+ -N
的话,cURL 也是会卡住的。所以上面的那些挣扎也相当于是提前解决了一些未来的问题….
最后提一嘴先前 man nc
里被忽略的那个 -q
选项。一开始我其实被误导走弯路用了 -q 0
而不是 -N
,确实也能用(毕竟 -q
包含了 -N
),但是会偶然出现一个问题:客户端认为请求正确完成了,但是服务端没有收到客户端发来的消息。现在我们也很容易解释这个问题了,因为 -q 0
意味着服务端读完 stdin 以后等待 0 秒立刻终止。如果服务端先读取完了 stdin,这时候客户端请求还没进来,那这时候连接就会直接终止,服务端认为没有接收到东西。网上还能搜到这个因为默认 -q 0
而导致问题的上古 bug。
认证机制
真是老不容易终于写出了个能用的服务器。但是我们当然不希望随便谁都能请求,所以下一步,来实现一个简单的认证机制吧。
最简单的想法就是两方定义好一个秘密字符串,客户端直接发送到服务端匹配是否正确。但是这样做有一些安全风险:因为请求全都是明文,所以可以轻松地被中间人和其他能够嗅探流量的人看光光。这样密钥就直接被人偷走了。
那考虑加密一下流量?可惜 Debian/Ubuntu 移植的这个版本的 netcat-openbsd 并不支持在服务端使用 HTTPS,就算支持我也会觉得有点太重(要额外管理证书签发,建立连接也更费时费力)。手搓一个类似 TLS 或者 WPA2/WPA3 的加密也比较困难,因为 netcat 很难在 bash 脚本的环境下做同一连接下多个来回的数据交互。(我相信有 bash 脚本神仙能写出来,但是到这份上我不如去用 Python 了?)
所以最后我决定采用类似 TOTP (Time-based one-time password) 的方式来做认证。倒也并不需要原封不动采用 TOTP 算法,我用类似的概念实现了一个简化版,直接拿密钥连上时间哈希一下:
$$ OTP(K)=SHA256(K \_ \lfloor T/10 \rfloor) $$用 bash 脚本实现则是:
1 echo "<SECRET>_$(($(date +%s)/10))" | sha256sum | cut -d " " -f 1
这里 $(($(date +%s)/10))
计算的是 Unix 时间戳整除 10,也就是每 10 秒内相同的时间戳。把密钥和时间戳字符串相接后 SHA256 哈希一下,就是我们用于传输的 token 了。cut -d " " -f 1
是用来切掉 sha256sum
最后输出的文件名信息。
用 10 秒作为窗口粒度主要是为了缓解客户端和服务端的时间精度问题。如果能够确保两端时间非常准确地同步,直接用秒级甚至更精确的时间戳自然也是可以。
这样一来,每一个 token 只有 10s 的有效期,反正在这个窗口里重复请求关机并不会产生区别,所以就算被人窃取也没关系(算是一定程度上抵抗重放攻击)。偷听到的 token 也无法反向推出原始的密钥,足够安全了。
于是现在的服务器可以这样来写:
1while true; do
2 req=$(echo "HTTP/1.1 200 OK" | nc -l -N localhost 8888)
3 secret=$(echo "PASSWORD_$(($(date +%s)/10))" | sha256sum | cut -d " " -f 1)
4 if [[ "$req" = *"$secret"* ]]; then
5 echo "Secret correct"
6 # sudo shutdown
7 else
8 echo "Secret wrong: $req"
9 fi
10done
因为 cURL 请求的时候请求体里会有很多其他的内容,所以直接用了暴力匹配 [[ "$req" = *"$secret"* ]]
,只要整个请求体里存在这个字符串就判真。反正这个哈希字符串很长很随机,不可能会出现在正常的 cURL 请求里,所以这样没什么问题。
试试用 cURL 和 netcat 请求一下:
1curl localhost:8888/$(echo "PASSWORD_$(($(date +%s)/10))" | sha256sum | cut -d " " -f 1)
2sleep 1
3echo "PASSWORD_$(($(date +%s)/10))" | sha256sum | cut -d " " -f 1 | nc localhost 8888
服务端的输出能够看到符合预期的两个 Secret correct
,两种方法都能调用得通。不过因为服务端代码是单线程串行而且没有那么快,所以如果两个请求先后紧挨着调用的话,之间需要 sleep
一下,不然第二个请求可能会打不通。
这里 cURL 的请求,我取巧把 token 直接塞在了 GET 的 path 参数里,也可以选择放在头(-H
)或者 POST 参数(-d
)里,或者任何地方。我这样的暴力字符串匹配的好处就是,请求体里密钥随便塞哪儿都能行。
Systemd 服务
服务器写好了,是时候把它部署成一个系统服务了。感谢 systemd,现代 Linux 系统里创建服务那是易如反掌。
编辑 /etc/systemd/system/shutdown-webhook.service
:(记得把 <PASSWORD>
改成其他的随机字符串!)
1[Unit]
2Description=System shutdown webhook
3After=network.target
4
5[Service]
6Type=exec
7ExecStart=/bin/bash -ec 'while true; do req=$(echo "HTTP/1.1 200 OK" | nc -l -N 8888); secret=$(echo "<PASSWORD>_$(($(date +%%s)/10))" | sha256sum | cut -d " " -f 1); if [[ "$req" = *"$secret"* ]]; then echo "Secret correct, shutting down..."; shutdown; else echo "Secret wrong: $req"; fi; done'
8Restart=on-failure
9
10[Install]
11WantedBy=multi-user.target
这里把整个服务器的脚本写在了同一行里。相当的轻量和简洁,除了几个基础工具以外没有任何额外的依赖。完美!这样一来开机就会自启动这么一个 webhook 接口,随便用什么工具请求一下(当然得带 token),就可以触发远程关机了。
我这里选择使用默认的 root 用户运行这个服务,这样 shutdown
就不需要额外权限。如果想要更安全一点,也可以指定使用普通用户,然后在 sudoers 文件里允许这个用户无密码执行 sudo shutdown
或者其他需要 sudo 的指令。另外 shutdown
没有加 now,也是增加了一分钟的缓冲时间,给系统一些处理后台任务的时间,如果是误操作了也能来得及取消。
我也把这套简单 webhook 服务去掉了关机相关的特定细节,开源在了这里:https://gist.github.com/SteveHawk/454f8060cf955f60e2bee2f79ead0e35
支线:一个 Tailscale 的坑
在实际部署的过程中,还踩到了一个 tailscale 的坑,在这里记录一下。
因为希望这个 webhook 只能够被 tailscale 内网上的设备请求,所以我的服务端 netcat 指定绑定了 tailscale0 网卡的 IP。但是刚开机的时候 tailscale 可能还没有开始运行,所以需要额外配置一个依赖,也就是 Wants=tailscaled.service
。这样应该就好了吧,right?…RIGHT?
1[Unit]
2Description=System shutdown webhook
3After=network.target tailscaled.service
4Wants=tailscaled.service
5
6[Service]
7Type=exec
8ExecStart=/bin/bash -ec 'while true; do req=$(echo "HTTP/1.1 200 OK" | nc -l -N <TAILSCALE_IP> 8888); secret=$(echo "<PASSWORD>_$(($(date +%%s)/10))" | sha256sum | cut -d " " -f 1); if [[ "$req" = *"$secret"* ]]; then echo "Secret correct, shutting down..."; shutdown; else echo "Secret wrong: $req"; fi; done'
9Restart=on-failure
10
11[Install]
12WantedBy=multi-user.target
开机以后请求不通。journalctl -b -u shutdown-webhook
:
Jul 24 18:13:38 remote-sbc systemd[1]: Starting shutdown-webhook.service - System shutdown webhook...
Jul 24 18:13:38 remote-sbc systemd[1]: Started shutdown-webhook.service - System shutdown webhook.
Jul 24 18:13:38 remote-sbc bash[1124]: nc: Cannot assign requested address
Jul 24 18:13:38 remote-sbc systemd[1]: shutdown-webhook.service: Main process exited, code=exited, status=1/FAILURE
Jul 24 18:13:38 remote-sbc systemd[1]: shutdown-webhook.service: Failed with result 'exit-code'.
Jul 24 18:13:38 remote-sbc systemd[1]: shutdown-webhook.service: Scheduled restart job, restart counter is at 1.
Jul 24 18:13:38 remote-sbc systemd[1]: Stopped shutdown-webhook.service - System shutdown webhook.
Jul 24 18:13:38 remote-sbc systemd[1]: Starting shutdown-webhook.service - System shutdown webhook...
Jul 24 18:13:38 remote-sbc systemd[1]: Started shutdown-webhook.service - System shutdown webhook.
Jul 24 18:13:38 remote-sbc bash[1150]: nc: Cannot assign requested address
Jul 24 18:13:38 remote-sbc systemd[1]: shutdown-webhook.service: Main process exited, code=exited, status=1/FAILURE
Jul 24 18:13:38 remote-sbc systemd[1]: shutdown-webhook.service: Failed with result 'exit-code'.
Jul 24 18:13:39 remote-sbc systemd[1]: shutdown-webhook.service: Scheduled restart job, restart counter is at 2.
Jul 24 18:13:39 remote-sbc systemd[1]: Stopped shutdown-webhook.service - System shutdown webhook.
Jul 24 18:13:39 remote-sbc systemd[1]: Starting shutdown-webhook.service - System shutdown webhook...
Jul 24 18:13:39 remote-sbc systemd[1]: Started shutdown-webhook.service - System shutdown webhook.
Jul 24 18:13:39 remote-sbc bash[1170]: nc: Cannot assign requested address
Jul 24 18:13:39 remote-sbc systemd[1]: shutdown-webhook.service: Main process exited, code=exited, status=1/FAILURE
Jul 24 18:13:39 remote-sbc systemd[1]: shutdown-webhook.service: Failed with result 'exit-code'.
Jul 24 18:13:39 remote-sbc systemd[1]: shutdown-webhook.service: Scheduled restart job, restart counter is at 3.
Jul 24 18:13:39 remote-sbc systemd[1]: Stopped shutdown-webhook.service - System shutdown webhook.
Jul 24 18:13:39 remote-sbc systemd[1]: Starting shutdown-webhook.service - System shutdown webhook...
Jul 24 18:13:39 remote-sbc systemd[1]: Started shutdown-webhook.service - System shutdown webhook.
Jul 24 18:13:39 remote-sbc bash[1188]: nc: Cannot assign requested address
Jul 24 18:13:39 remote-sbc systemd[1]: shutdown-webhook.service: Main process exited, code=exited, status=1/FAILURE
Jul 24 18:13:39 remote-sbc systemd[1]: shutdown-webhook.service: Failed with result 'exit-code'.
Jul 24 18:13:40 remote-sbc systemd[1]: shutdown-webhook.service: Scheduled restart job, restart counter is at 4.
Jul 24 18:13:40 remote-sbc systemd[1]: Stopped shutdown-webhook.service - System shutdown webhook.
Jul 24 18:13:40 remote-sbc systemd[1]: Starting shutdown-webhook.service - System shutdown webhook...
Jul 24 18:13:40 remote-sbc systemd[1]: Started shutdown-webhook.service - System shutdown webhook.
Jul 24 18:13:40 remote-sbc bash[1232]: nc: Cannot assign requested address
Jul 24 18:13:40 remote-sbc systemd[1]: shutdown-webhook.service: Main process exited, code=exited, status=1/FAILURE
Jul 24 18:13:40 remote-sbc systemd[1]: shutdown-webhook.service: Failed with result 'exit-code'.
Jul 24 18:13:40 remote-sbc systemd[1]: shutdown-webhook.service: Scheduled restart job, restart counter is at 5.
Jul 24 18:13:40 remote-sbc systemd[1]: Stopped shutdown-webhook.service - System shutdown webhook.
Jul 24 18:13:40 remote-sbc systemd[1]: shutdown-webhook.service: Start request repeated too quickly.
Jul 24 18:13:40 remote-sbc systemd[1]: shutdown-webhook.service: Failed with result 'exit-code'.
Jul 24 18:13:40 remote-sbc systemd[1]: Failed to start shutdown-webhook.service - System shutdown webhook.
(这里还可以提一嘴关于 bash -e
:我一开始只用了 bash -c
执行那个单行服务器脚本。结果 netcat 报错以后脚本会继续执行,报 Secret wrong:
然后继续循环执行,不会退出。于是几秒里打出来好几百条日志,systemd 也不会判定服务失败,因为并没有退出 while true 循环。加入 -e
以后,bash -ec
会在任何指令返回非 0 返回码,即报错的情况下退出,于是这里能够正确的落到 systemd 头上帮我们做兜底。更多关于 bash 的 -e
以及恶魔般的 -Eeuxo pipefail
,详见这个 gist: bash_strict_mode.md)
可以看到 netcat 并不能成功绑定到指定的 IP 上,重试 5 次以后 systemd 认为服务启动失败了。为什么?明明我们指定依赖了 tailscaled.service 呀?
这次终于不是我的锅了。看起来,tailscale 在真的准备好之前,就通知 systemd 它准备好了:
Tailscaled tells systemd that it is ready before its ip address is bindable · Issue #11504 · tailscale/tailscale
Issue 里也有热心大佬给出了解决方法,增加一个 ExecStartPre=/bin/bash -c 'until tailscale status; do sleep 1; done'
前置检查即可。Systemd 会在启动服务之前先检查 tailscale 是不是真的有可绑定的 IP 了,然后再启动服务。在 tailscale 修复这个问题之前,只能这么 workaround 了。
另外,为什么 systemd 只重试了 5 次就判定服务启动失败,然后就不重试了?这就要提到三个 systemd 的配置项:RestartSec=
,StartLimitIntervalSec=interval, StartLimitBurst=burst
。在 /etc/systemd/system.conf
里可以看到,默认的重试间隔是 100ms,限制窗口是 10s,重试限制是 5 次。意思是说,每个 10 秒的窗口内如果重启超过 5 次,那 systemd 就会判定服务挂掉不再重启。而这里我们每次启动立刻就会报错退出,间隔 100ms,那马上就会用光 5 次机会。所以另一个防止 systemd 判定失败的方法就是加上 RestartSec=2
,这样就算一直重启,也不会在 10 秒的窗口里超过 5 次的限制,会一直重试下去。
结合这两个 workaround,最终版的服务出炉了:
1[Unit]
2Description=System shutdown webhook
3After=network.target tailscaled.service
4Wants=tailscaled.service
5
6[Service]
7Type=exec
8# https://github.com/tailscale/tailscale/issues/11504
9ExecStartPre=/bin/bash -c 'until tailscale status; do sleep 1; done'
10ExecStart=/bin/bash -ec 'while true; do req=$(echo "HTTP/1.1 200 OK" | nc -l -N <TAILSCALE_IP> 8888); secret=$(echo "<PASSWORD>_$(($(date +%%s)/10))" | sha256sum | cut -d " " -f 1); if [[ "$req" = *"$secret"* ]]; then echo "Secret correct, shutting down..."; shutdown; else echo "Secret wrong: $req"; fi; done'
11Restart=on-failure
12RestartSec=2
13
14[Install]
15WantedBy=multi-user.target
#tech notes
6122 words