SteveHawk's Blog

用 netcat 实现简单 webhook



需求

最近在折腾 DIY 异地备份,从本地 NAS 上远程备份到一个装着 SSD 的单板机上。出于温度、功耗、风扇寿命等等各种考量,希望在每次备份任务结束以后远程系统能自动关机。

(搭配了一个米家智能插座,定时上电单板机会自动启动,然后搭配充电保护功能,在单板机关机后自动关闭电源,这样一来自动化流程完美闭环。)

因为备份软件支持在任务结束以后调用自定义脚本,于是很容易地想到可以开一个 webhook,任务结束以后调用一下关机接口。现成的方案很成熟,比如这个项目 adnanh/webhook,但是对于我这简单到不能再简单的需求来说,这个“轻量级”的 webhook 实现还是略显沉重。之前浅浅了解过 netcat 这个工具,正好借这个机会深入了解一下,手搓一个 webhook 吧!

Netcat 入门

https://en.wikipedia.org/wiki/Netcat

最初 netcat 是由一位名为 Hobbit 的开发者在 1995 年推出的一个 Unix 网络调试工具(最终版 1.10 于 1996 年),后来被多次重写并移植到很多系统上。比较有名的衍生版本包括了 GNU NetcatOpenBSD 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 a FIN packet from the client. (Unless the server already sent a FIN 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 packet

With option -d stdin is ignored and nc behaves as if it encountered EOF on stdin.

Option -N always implies sending FIN after encountering EOF 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

https://github.com/tailscale/tailscale/issues/11504

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

↪ comment
↪ reply by email