阅读约需 18 分钟
前几天在审查一台内网服务器的 ss -tlnp 输出时,我突然意识到一件很让人不安的事:
那台机器上跑着我们的内部 gRPC 管理服务,端口对内网开放。防火墙规则是对的,只允许内网 IP 段访问。但只要攻击者进了内网——无论是通过钓鱼、供应链、或者某个野生的内网服务漏洞——他们能做的第一件事就是 nmap 全扫一遍,然后直接去戳那个 gRPC 端口。
你的应用层协议直接暴露给了他们。没有任何预选,没有任何准入。
这不是一个新问题,只是一个我们已经习惯了的问题。
这篇文章想聊聊我最近发现的一个 Go SDK:libknock,以及它试图在 TCP 层做的事情。
为什么 TLS/mTLS 不够用?
先把一个常见的反驳放在桌面上处理掉:「你用 mTLS 不就行了吗?」
mTLS 确实提供了双向身份认证,而且它在应用层认证这件事上做得很好。但它的认证发生在 TLS 握手阶段,而 TLS 握手本身已经是一次完整的 TCP 连接建立之后的事情了。
在 mTLS 认证之前,你的服务已经:
– 接受了这条 TCP 连接
– 分配了文件描述符和内存
– 开始执行 TLS 握手状态机
这意味着任何能连上你端口的人都能开始消耗你的服务资源,哪怕他没有合法的客户端证书。更关键的是,扫描工具只需要看到 TCP 端口开着、TLS 握手能进行,就能推断出这个端口跑着什么服务,大概是什么版本,进而查 CVE。
TCP 层认证(TCP pre-application authentication)要解决的,是在应用协议开口说话之前就拒掉不合法的连接。
libknock 是什么
用一句话总结:libknock 是一个可嵌入的 Go SDK,在 TCP 握手完成、应用协议开始之前完成一个密码学认证帧的验证,认证通过后才把干净的 net.Conn 交给你的应用层。
它的整个工作过程大概是这样的:
客户端发起 TCP 连接
|
v
[libknock] 读取认证帧(<512 字节,XChaCha20-Poly1305 加密)
|
认证失败 ──────→ 静默关闭连接,不返回任何错误信息给对端
|
认证成功
|
v
你的应用层拿到 net.Conn,开始 TLS / HTTP / gRPC / 自定义协议
注意这个「静默关闭」——认证失败时,服务端什么都不说就关连接,攻击者连「密码错了」还是「账号不存在」都区分不了,更别说推断服务类型了。
协议设计:值得细看
libknock 目前有两个 TCP 认证协议版本,搞清楚它们的差异有助于理解这个库的设计意图。
v1:tcp-auth-frame-v1
经典的固定头部 + AEAD 密封载荷结构:
固定头部 (29 字节):
version[1] // 协议版本
flags[1] // 可选的服务端证明标志
reserved[1] // 保留字段,必须为零
nonce[16] // 每次认证随机生成
key_hint[8] // HMAC 截断的密钥提示,帮助服务端快速筛选候选密钥
sealed_len[2] // 密封载荷长度
密封载荷 (XChaCha20-Poly1305):
client_id_hash // HMAC 截断的客户端标识哈希
timestamp_unix_ms // 时间戳(配合 TimeWindow 防重放)
server_port // 目标端口(绑定到具体端口,防止跨端口重用)
method // 敲门方法
session_id // 可选的会话绑定
extensions // 扩展字段
key_hint 的设计值得注意:服务端可能管理着多个客户端的密钥。如果每个连接都要遍历所有密钥尝试解密,性能开销不小。key_hint 是一个 HMAC(secret, nonce || port) 截断为 64 位的值——服务端收到帧后先用这个提示过滤候选密钥,再尝试 AEAD 解密,极大减少了无效的密码学运算。
v2:tcp-auth-envelope-v2(默认协议)
v2 在 v1 基础上加了两个关键改进:
1. 随机前缀替代固定头部
v1 的 version[0] = 0x01 是固定的,任何抓包工具看到第一个字节是 0x01 就能推断这是一个 libknock 帧。v2 的 24 字节前缀是完全随机的(第一个字节不等于 v1 的版本字节,两者通过这个约束区分)。
2. 固定大小桶填充(Traffic Analysis Resistance)
这是 v2 最有意思的设计。libknock 认证帧的实际大小会因为 method、session_id、extensions 的不同而变化,长度信息本身会泄露上下文。v2 把输出帧大小规整到预定义的桶:128 / 192 / 256 / 384 / 512 字节,然后从满足最小尺寸要求的桶中随机选一个,用真随机字节填充到桶大小。
结果是:你在网络上看到的所有 libknock v2 帧,要么是 128 字节,要么是 192 字节,……都是固定大小,且在合法大小集合里随机分布。从流量分析角度来看,它像噪声多于像协议。
密钥派生使用 HKDF-SHA256,加密使用 XChaCha20-Poly1305。这两个选择都是目前密码工程实践的主流选项,没有任何自制加密算法。
核心 API:两行代码的事
这个库的服务端集成路径极度简洁:
// 原来的代码
ln, err := net.Listen("tcp", ":9000")
// ...
conn, err := ln.Accept()
// 加了 libknock 之后
ln, err := net.Listen("tcp", ":9000")
if err != nil {
log.Fatal(err)
}
ln, err = libknock.NewListener(ln, libknock.ServerConfig{
ServerPort: 9000,
Secrets: libknock.NewStaticSecretResolver(map[string][]byte{
"service-a": []byte("your-32-byte-secret-goes-here!!!"),
}),
})
if err != nil {
log.Fatal(err)
}
// 以下代码完全不变,Accept() 返回的是已认证的连接
conn, err := ln.Accept()
NewListener 把原始 net.Listener 包装成一个并发认证版本:内部有 worker pool(默认 32 个 worker)在后台处理认证,认证通过的连接才进入 ready 队列,调用方的 Accept() 拿到的永远是已认证连接。
客户端同样简洁:
d := libknock.Dialer{
Base: &net.Dialer{Timeout: 5 * time.Second},
Config: libknock.ClientConfig{
ClientID: "service-a",
Secret: []byte("your-32-byte-secret-goes-here!!!"),
ServerPort: 9000,
AuthTimeout: 3 * time.Second,
},
}
conn, err := d.DialContext(ctx, "tcp", "server.example.com:9000")
// conn 是标准 net.Conn,后续完全透明
返回的 conn 就是标准的 net.Conn,之后接 TLS、接 HTTP、接 gRPC,一切照常。libknock 不碰应用层的任何字节。
它解决的真实场景
场景一:保护内部管理端点
几乎每个后端服务都有某种管理端点:debug API、pprof、Admin gRPC 接口。这些接口通常靠 IP 白名单保护,但一旦内网被横向渗透,白名单就失效了。
libknock 在 IP 白名单之外加了密码学屏障:攻击者即使在内网,拿不到对应的客户端密钥,就连 TLS 握手都看不到,更别说 pprof 数据和 Admin 接口了。
场景二:Agent / Collector 基础设施
大量的 SaaS 基础设施依赖遍布各处的 agent:监控 agent、日志收集 agent、备份 agent。这些 agent 需要和控制端建立长连接,控制端的端口通常是公网可达的。
libknock 在 TCP 层提供一道认证,让没有合法密钥的客户端连协议指纹都看不到。配合 RotatingSecrets(多版本密钥轮换),可以实现无中断的密钥轮换。
场景三:gRPC 服务前置认证
这个场景最常见也最直观:
ln, err := net.Listen("tcp", ":50051")
ln, err = libknock.NewListener(ln, knockConfig)
// 直接套进 grpc.NewServer
s := grpc.NewServer(grpc.Creds(tlsCreds))
s.Serve(ln)
gRPC 框架感知不到 libknock 的存在,libknock 也不碰 gRPC 的任何帧,两者在各自的层次做各自的事。
端口敲门(Port Knocking):它和传统方案的区别
libknock 支持「端口敲门」作为可选的前置步骤,但这里的「敲门」和你在教科书上看到的传统 port knocking 有本质区别。
传统 port knocking 的问题:早期实现依赖特定端口的 TCP/UDP 报文序列来触发防火墙规则。问题是这个序列是可以被嗅探和重放的——攻击者在局域网里抓个包,录下你的敲门序列,就能原样重放。
libknock 的 UDP 敲门帧:每个敲门数据报都是一个 AEAD 加密的二进制帧,携带时间戳(对应 TimeWindow 校验)和随机 nonce(对应 replay cache 校验)。即使攻击者完整捕获了一个合法的敲门包,时间窗口过后重放就会被拒绝,同一时间窗口内重放会被 replay cache 拒绝。
敲门方法支持多种:
| 方法 | 特征 | 适用场景 |
|---|---|---|
udp |
单次 UDP 帧,普通 UDP socket | 大多数部署的起点 |
udp-seq |
多段 UDP 序列,更强信号 | 需要更高准入确信度 |
tcp-syn |
TCP SYN 形状,无 UDP 开放端口 | 扫描可见性最低 |
udp-passive |
服务端抓包监听,不绑 UDP 端口 | 配合 DROP 规则实现端口完全不可见 |
udp-passive 是最激进的方案:服务端通过 CAP_NET_RAW 或 pcap 权限直接在网络层监听敲门包,完全不绑定任何 UDP 端口,结合防火墙 DROP 规则,扫描工具会看到 UDP 敲门端口的流量直接被丢弃,无任何响应。(注:这个模式目前标记为 experimental,需要在目标主机上自行验证。)
Gate 模式:四种准入策略
单纯的 TCP 认证(auth-only)已经能解决大多数问题。libknock 还提供了更高级的 gate 组合:
auth-only → 纯 TCP 认证,无防火墙操作,无需任何特权
knock-auth-only → 先敲门(会话绑定)+ TCP 认证,无防火墙操作,适合容器/受限环境
knock-firewall-auth → 敲门成功→临时开放防火墙窗口→TCP 认证,需要 CAP_NET_ADMIN
knock-firewall-only → 敲门成功→防火墙放行,应用层直接处理
knock-auth-only 值得专门说一下:它不需要 root 或 CAP_NET_ADMIN,这意味着可以在普通容器里使用。它不隐藏 TCP 端口(SYN 扫描仍然能看到端口开放),但未授权客户端永远无法进入应用层协议——他们连 TLS ClientHello 都发不出去,因为 libknock 的认证层直接把连接挂起了。
这对于很多场景足够了:目标不是让端口「消失」,而是让没有密钥的攻击者在应用层什么都拿不到。
使用 gate 的代码只需要一个函数调用:
ln, err := gate.Listen(ctx, gate.Config{
Mode: gate.KnockAuthOnly,
Listen: ":9000",
Auth: libknock.ServerConfig{
Secrets: libknock.NewStaticSecretResolver(secrets),
},
KnockMethod: knock.UDPMethod,
KnockPort: 9001,
KnockClients: []knock.ClientSecret{
{ClientID: "service-a", Secret: knockSecret},
},
})
Relay:保护你改不了源码的服务
并非所有内部服务都是自己写的 Go 代码。MySQL、Redis、Elasticsearch、Prometheus……这些服务你没办法在里面嵌入 libknock。
relay.Gateway 是为这个场景设计的:
gw := relay.Gateway{
Listen: ":19000", // 对外暴露这个认证端口
Upstream: "127.0.0.1:3306", // 本地的 MySQL,不对外暴露
Auth: libknock.ServerConfig{
ServerPort: 19000,
Secrets: mysqlClientSecrets,
},
Firewall: firewall.Noop{},
}
err := gw.Run(ctx)
客户端连 19000 端口,认证通过后流量被透明转发到本地 3306。MySQL 本身不需要做任何改动,不需要任何特权,就完成了一层前置认证。
配套的 cmd/knock-proxy 是命令行工具,读 YAML 配置文件,适合不想写代码的运维场景。
安全模型的边界(比宣传材料更重要的部分)
聊了这么多好处,现在说说它明确不解决的问题,这部分通常比宣传材料更值得仔细读。
libknock 不是、也不能替代:
– TLS(传输加密):认证通过后的 net.Conn 是裸 TCP,应用层字节未加密
– mTLS(完整的双向证书认证链)
– SSH 或 WireGuard(安全隧道)
– 应用层授权(谁能读哪个资源)
它解决的核心问题只有一个:在应用协议开口之前,确认这条 TCP 连接来自持有合法密钥的客户端。
这是”防攻击面暴露”,不是”加密传输”。如果你的威胁模型里包括中间人或传输加密需求,libknock 之后还需要 TLS。两者可以叠加使用,而且 libknock 明确鼓励这样做。
replay 保护值得单独说明:libknock 使用 ReplayCache 阻止同一认证帧被重放,但缓存是内存态的,进程重启会丢失。一个精心计时的重放攻击(捕获帧,等进程重启后立即重放)在理论上可以成功。生产环境如果对此有要求,需要用外部存储实现 ReplayCache 接口,或接受重启后短时间(TimeWindow)内的理论窗口。
防火墙后端目前在真实主机上的验证覆盖有限(CHANGELOG 中有诚实说明)。nftables 和 ipset-iptables 的规则生成有测试,但「干跑测试」不等于「在你的 Debian 11 + 自定义 nftables 策略上真的能用」,部署前建议在目标主机上跑一遍 scripts/validate-nftables.sh。
并发认证的实现细节
感兴趣 Go 并发模型的可以看看 netx/listener.go 的内部实现,设计上有几个值得注意的地方:
Worker pool + channel pipeline:
acceptLoop goroutine → pending channel → authWorker goroutines (N个)
↓
ready channel → Accept() 返回
认证操作是在 authWorker 里并发进行的,每个 worker 有独立的认证超时 context,互不干扰。
Backpressure:pending channel 有缓冲上限(默认 128),满载时新连接直接 drop 并触发 OnAuthDrop 事件,不会无限积压。这是一个明确的安全决策:宁可丢连接,不让 pending 队列成为内存耗尽向量。
inFlight 连接跟踪:Close() 时会对所有正在进行认证的 in-flight 连接调用 SetDeadline(Now()),强制中断,然后关闭。goroutine 生命周期是可追踪、可控制的,不会泄漏。
sync.Once 在关闭路径上用了三个(closeOnce、doneOnce、readyOnce),看起来复杂,但逻辑上每个 Once 对应一个独立的「只发生一次」的事件,没有冗余。
可观测性:EventSink 接口
libknock 的事件系统走接口,不绑定任何具体的日志或监控库:
type EventSink interface {
OnAccept(remote net.Addr)
OnAuthOK(peer PeerInfo)
OnAuthFail(remote net.Addr, reason error)
OnReplay(remote net.Addr, peerHint uint64)
OnReplayCacheFull(remote net.Addr, peerHint uint64, length, capacity int)
OnRateLimited(remote net.Addr)
}
你可以把这些事件接进你已有的 Zap / slog / OpenTelemetry / 自定义告警系统,完全自由。
Prometheus 支持在一个独立的 observability/prometheus 模块里,有自己的 go.mod,不会强制拉进 Prometheus 依赖。如果你的项目已经用了 Prometheus:
import "github.com/libknock/libknock/observability/prometheus"
sink := prometheus.MustNew(prometheus.Config{})
serverConfig.Events = sink
这样 knock_auth_total、knock_auth_duration_seconds、replay_cache_active 等指标就自动可用了。
密钥轮换:不中断服务
生产环境的密钥轮换通常是一个痛点。libknock 通过 RotatingSecrets 支持每个 clientID 同时持有多版本密钥:
secrets := libknock.NewRotatingSecretResolver(map[string][][]byte{
"service-a": {newSecret, oldSecret}, // 先放新密钥,旧密钥作为过渡
})
服务端会对两个版本都尝试认证,任意一个通过即可。客户端切换到新密钥后,从 secrets 列表里移除旧密钥,轮换完成,全程不需要重启服务端。
一些值得关注的细节
遍历代码时注意到几个用心的实现决策,顺手记录一下:
客户端 ID 哈希化:帧里传输的不是明文 clientID,而是 HMAC(secret, "client-id" || clientID) 截断为 128 位的哈希。服务端认证通过后,通过遍历候选密钥重新计算哈希来确认 clientID。这样即使帧被截获(AEAD 失败解密),也拿不到客户端标识。
ServerPort 绑定到 AAD:目标端口是 AEAD 的 AAD(附加认证数据)的一部分。这意味着一个为端口 9000 生成的认证帧无法被重用于端口 9001,即使两个端口共享相同的 secret。防止了「将认证帧转发到另一个端口」的攻击。
失败延迟抖动:FailDelayJitterMin / FailDelayJitterMax 配置项给认证失败路径加随机延迟,防止通过计时来区分「格式错误的帧」和「密钥错误的帧」(timing oracle 攻击)。
DrainOnFailBytes / DrainOnFailTimeout:认证失败后,服务端可以选择继续读取一些字节再关闭连接,防止对端通过连接关闭时机来推断认证是在读多少字节后失败的。
这些细节不在 README 最前面,但它们是这个库的安全工程水位的体现。
适合谁用
直接说:
适合:
– 自研 Go 服务,需要在 TCP 层加一道密码学前置认证
– 内部管理端点、Admin API、gRPC 内部服务
– Agent / Collector 控制面连接
– 需要用 relay 模式保护无法修改的内部服务(MySQL、Redis、内部 HTTP 等)
– 想要端口敲门但受不了传统 knocking 的重放风险
不适合:
– 需要传输加密的场景(需要在 libknock 之外加 TLS)
– 公网 Web 服务(libknock 不是 HTTPS 的替代)
– 需要精细业务权限控制(这是应用层的事)
– Windows/macOS 的被动 UDP 捕获或 TCP SYN 敲门(目前 experimental,需要自行验证平台行为)
上手试用
最小可运行的服务端+客户端组合:
go get github.com/libknock/libknock
服务端:
package main
import (
"bufio"
"log"
"net"
"time"
libknock "github.com/libknock/libknock"
)
func main() {
secret := []byte("this-is-a-32byte-secret-example!")
ln, _ := net.Listen("tcp", ":9000")
ln, err := libknock.NewListener(ln, libknock.ServerConfig{
ServerPort: 9000,
Secrets: libknock.NewStaticSecretResolver(map[string][]byte{
"client-001": secret,
}),
ReplayCache: libknock.NewMemoryReplayCache(5 * time.Minute),
})
if err != nil {
log.Fatal(err)
}
for {
conn, _ := ln.Accept()
go func(c net.Conn) {
defer c.Close()
scanner := bufio.NewScanner(c)
for scanner.Scan() {
c.Write([]byte("echo: " + scanner.Text() + "\n"))
}
}(conn)
}
}
客户端:
package main
import (
"bufio"
"context"
"fmt"
"net"
"os"
"time"
libknock "github.com/libknock/libknock"
)
func main() {
d := libknock.Dialer{
Base: &net.Dialer{Timeout: 5 * time.Second},
Config: libknock.ClientConfig{
ClientID: "client-001",
Secret: []byte("this-is-a-32byte-secret-example!"),
ServerPort: 9000,
AuthTimeout: 3 * time.Second,
},
}
conn, err := d.DialContext(context.Background(), "tcp", "127.0.0.1:9000")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
fmt.Fprintln(conn, "hello")
resp, _ := bufio.NewReader(conn).ReadString('\n')
fmt.Print(resp) // echo: hello
}
没有任何第三方依赖需要单独安装,认证全部在库里处理,应用代码不感知认证细节。
和现有技术的定位关系
这是一张我觉得应该放进 libknock 文档里的图,但它目前没有:
网络层威胁模型覆盖范围:
防火墙 / IP 白名单 → 阻止来自不信任网段的连接(网络层)
libknock → 阻止无合法密钥的连接到达应用层(TCP 准入)
TLS / mTLS → 加密传输,证书身份验证(传输层)
应用层授权 → 谁能操作哪些资源(应用层)
四者覆盖不同层次,互补而非互斥。
典型组合: IP 白名单 + libknock + TLS = 三道闸
libknock 没有试图做完整的安全栈,它只做好一件事:在应用层开口之前完成密码学准入检查。
结语
TCP 端口直接暴露应用层协议是一个我们长期用 IP 白名单、防火墙、以及「反正内网还好吧」的心理安慰来应付的问题。libknock 提供了一个在 Go 生态里真正可嵌入的方案,改动最小化(两行代码),安全基础扎实(标准库密码学原语,无自制加密),设计意图清晰(只做 TCP 准入,不碰应用层)。
它目前是 Release Candidate 阶段,核心的 TCP 认证路径已经稳定,部分高级功能(被动 UDP、TCP SYN 敲门、Windows/macOS 报文捕获)还需要在目标平台自行验证。对于新项目或者正在重构服务认证体系的团队,值得认真评估一下。
源码在:https://github.com/libknock/libknock
文档索引从 README.md → docs/getting-started.md → docs/use-cases.md 顺着读,半小时能有完整的概念图。
发表回复
要发表评论,您必须先登录。