最近在维护自己的 Minecraft 服务器,遇到个挺真实的需求
因为服务器是放在宿舍局域网里通过 FRP 穿透出去的,平时想知道有没有朋友上线,只能登到服务器上,我又不想用别人改过的服务端
于是写了一个 Caddy 的 Layer 4 扩展插件 mc_monitor。它能在不对 MC 服务端做任何改动的前提下,在网关层直接“嗅探”出登录的玩家名称,并异步推送到 Telegram 或 Discord。
这个插件工作在 OSI 模型的第四层(传输层)。
它的核心逻辑是在 TCP 连接建立时,利用 io.TeeReader 这种“三通”技术,在不消耗原始数据流的情况下,把玩家发来的握手包(Handshake)和登录包(Login Start)复制一份出来。
通过解析这些二进制包提取出玩家 ID(Username),然后再把流量原封不动地转发给后端的 MC 服务器。最后计算玩家从连接到断开的时长,生成报告。
当然,我这里用到了之前之前大佬写的关于mc服务端相关的库
核心代码实现#
这里贴一下这个 Caddy 模块的核心逻辑代码,主要是 Handle 和 parseMCLogin 方法,它们负责了主要的流量劫持和协议解析工作:
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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
| // Handle 处理连接的核心逻辑
// 这里我们利用 io.TeeReader 在建立连接初期“偷听”流量,提取用户名
func (m *MCMonitor) Handle(cx *layer4.Connection, next layer4.Handler) error {
startTime := time.Now()
// 1. 创建一个 Buffer 用来暂存读取的数据
replayBuf := new(bytes.Buffer)
// 2. 创建 TeeReader:从 cx 读取的数据会同时写入 replayBuf
teeReader := io.TeeReader(cx, replayBuf)
// 设置读取超时,防止非 MC 流量导致卡死
cx.SetReadDeadline(time.Now().Add(3 * time.Second))
// 3. 解析登录包,提取用户名 (同时数据被 TeeReader 存入 replayBuf)
username, err := m.parseMCLogin(teeReader)
cx.SetReadDeadline(time.Time{}) // 重置超时
// ... (日志记录逻辑)
// 4. 连接真实的后端 MC 服务器
remote, err := net.Dial("tcp", m.BackendAddress)
if err != nil {
return err
}
defer remote.Close()
// 5. 关键步骤:先把刚才“偷听”并存在 replayBuf 里的握手包发给后端
// 否则后端会因为缺少握手包而拒绝连接
if _, err := io.Copy(remote, replayBuf); err != nil {
return err
}
// 6. 发送上线通知
if username != "Unknown" {
if m.WebhookUrl != "" {
go m.sendWebhook(username, fmt.Sprintf("Player %s connected", username))
}
}
// 7. 开启双向管道转发后续流量
errc := make(chan error, 1)
go func() {
_, err := io.Copy(cx, remote)
errc <- err
}()
go func() {
_, err := io.Copy(remote, cx)
errc <- err
}()
<-errc // 阻塞等待连接断开
// 8. 计算时长并发送下线通知
duration := time.Since(startTime)
if username != "Unknown" && m.WebhookUrl != "" {
go m.sendWebhook(username, fmt.Sprintf("Player %s disconnected. Duration: %s", username, duration))
}
return nil
}
// parseMCLogin 极其简化的协议解析
// 依次读取 Handshake 包和 Login Start 包
func (m *MCMonitor) parseMCLogin(r io.Reader) (string, error) {
// 使用 go-mc 库帮助解包,避免手动处理 VarInt
var p packet.Packet
// --- 第一步:读取 Handshake 包 ---
if err := p.UnPack(r, -1); err != nil {
return "", err
}
// Handshake 包结构: Protocol Version | Server Address | Server Port | Next State
var (
protocolVersion packet.VarInt
serverAddr packet.String
serverPort packet.UnsignedShort
nextState packet.VarInt
)
if err := p.Scan(&protocolVersion, &serverAddr, &serverPort, &nextState); err != nil {
return "", err
}
if nextState != 2 { // 2 代表登录状态
return "", fmt.Errorf("not a login handshake")
}
// --- 第二步:读取 Login Start 包 ---
// 紧接着发来的就是包含了用户名的包
if err := p.UnPack(r, -1); err != nil {
return "", err
}
var username packet.String
// Login Start 包结构: Username | (Optional UUID)
err := p.Scan(&username)
return string(username), err
}
|
Caddyfile 配置
插件的使用非常简单,直接在 Caddyfile 的 layer4 块里定义即可。
假设我的 MC 服务器运行在本地的 25565 端口,我想通过 Caddy 的 25577 端口进行代理监控:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| {
layer4 {
# 监听 25577 端口
:25577 {
route {
# 配置 mc_monitor
# 参数 1: Webhook URL (需要带上 chat_id)
# 参数 2: 真实的 MC 服务器地址
# 参数 3: (可选) 代理地址,用于发送 Webhook
mc_monitor https://api.telegram.org/bot<TOKEN>/sendMessage?chat_id=<ID> localhost:25565
}
}
}
}
|
编译与运行
由于这是一个自定义插件,需要使用 xcaddy 进行编译。
1
2
3
4
| # 在项目目录下执行编译
xcaddy build --with mc_monitor=.
# 启动 Caddy
./caddy run --config Caddyfile
|
效果展示
当玩家通过 Caddy 的端口(25577)登录游戏时,控制台会实时输出连接信息。即使你不配置 Webhook,它也能作为基础的连接日志使用。


自此,只要通过我这个端口访问,有小伙伴上线,我不仅能第一时间在TG收到提醒,还能在月底统计出谁才是服务器里的“肝帝”。