你的 TCP 端口每天都在裸奔——我找到了一个解决方案

阅读约需 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,互不干扰。

Backpressurepending channel 有缓冲上限(默认 128),满载时新连接直接 drop 并触发 OnAuthDrop 事件,不会无限积压。这是一个明确的安全决策:宁可丢连接,不让 pending 队列成为内存耗尽向量。

inFlight 连接跟踪Close() 时会对所有正在进行认证的 in-flight 连接调用 SetDeadline(Now()),强制中断,然后关闭。goroutine 生命周期是可追踪、可控制的,不会泄漏。

sync.Once 在关闭路径上用了三个(closeOncedoneOncereadyOnce),看起来复杂,但逻辑上每个 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_totalknock_auth_duration_secondsreplay_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.mddocs/getting-started.mddocs/use-cases.md 顺着读,半小时能有完整的概念图。


发表回复