最近在维护自己的 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收到提醒,还能在月底统计出谁才是服务器里的“肝帝”。