首次推送
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
# teamspeak-go
|
||||
|
||||
纯 Go 实现的 TeamSpeak 3 客户端协议库,零 CGO 依赖,支持 gomobile 编译为 Android `.aar`。
|
||||
|
||||
- GitHub: https://github.com/honeybbq/teamspeak-go
|
||||
- 本项目使用本地补丁版本(`go/_patches/`),通过 `go.mod` 的 `replace` 指令生效
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Application Layer │
|
||||
│ (bridge.go / 用户代码) │
|
||||
│ OnConnected, OnTextMessage, SendVoice ... │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ Client (client.go) │
|
||||
│ 连接管理、事件分发、命令追踪 │
|
||||
│ events.go — 事件注册 & dispatchEvent │
|
||||
│ commands.go — 命令发送 & return_code 匹配 │
|
||||
│ notifications.go — notify* 事件解析 │
|
||||
│ api.go — 高层 API (ListChannels, SendText...)│
|
||||
│ handshake.go — 加密握手 & clientinit │
|
||||
│ transfer.go — 文件传输 │
|
||||
│ throttle.go — 命令限速 (token bucket) │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ Transport (transport/) │
|
||||
│ PacketHandler — UDP 收发、包分片 │
|
||||
│ packet.go — 包类型定义 │
|
||||
│ quicklz.go — QuickLZ 压缩解压 │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ Crypto (crypto/) │
|
||||
│ Identity 生成、密钥交换、EAX 加密 │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ Handshake (handshake/) │
|
||||
│ crypt_init2.go — 二次加密协商 │
|
||||
│ license.go — 许可证验证 │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ Discovery (discovery/) │
|
||||
│ SRV / TSDNS / 直连 地址解析 │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Files
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `client.go` | `Client` 结构体、`NewClient`、`Connect`、`Disconnect`、事件循环 (`startEventLoop`)、`notifyEvent` 入队 |
|
||||
| `events.go` | 事件注册 API (`OnConnected`, `OnTextMessage` 等)、`dispatchEvent` 分发到所有 handler |
|
||||
| `notifications.go` | `handleNotification` — 解析 `notify*` 命令,转换为结构体事件 |
|
||||
| `commands.go` | `handlePacket` — 包类型路由、`ExecCommand` / `ExecCommandWithResponse` — 带 `return_code` 的异步命令 |
|
||||
| `api.go` | 高层 API:`ListChannels`、`ListClients`、`GetClientInfo`、`SendTextMessage`、`ClientMove`、`Poke`、`SendVoice`、`WaitConnected` |
|
||||
| `handshake.go` | `handleHandshakeInitIV`、`handleHandshakeExpand2`、`handleInitServer`、`sendClientInit` |
|
||||
| `types.go` | 所有事件/数据结构体定义 |
|
||||
| `transfer.go` | 文件传输:`FileTransferInitUpload`、`FileTransferInitDownload`、`FileTransferDeleteFile` |
|
||||
| `throttle.go` | Token-bucket 命令限速器(4 tokens/s,上限 8) |
|
||||
|
||||
### Event Loop
|
||||
|
||||
所有事件在单个 goroutine 中串行处理,保证按到达顺序分发:
|
||||
|
||||
```
|
||||
notifyEvent(evt) ← 任何 goroutine 调用
|
||||
→ 入队 (evtQueueMu)
|
||||
→ signal (evtCond)
|
||||
↓
|
||||
startEventLoop goroutine ← 唯一消费者
|
||||
→ 取出全部待处理事件
|
||||
→ dispatchEvent(evt) ← 逐个分发到注册的 handler
|
||||
```
|
||||
|
||||
## Event Lifecycle
|
||||
|
||||
事件分为两类:**服务器通知事件**(`notify` 前缀,服务器主动推送)和**内部事件**(SDK 状态变化触发)。
|
||||
|
||||
### Phase 1: Connection (连接阶段)
|
||||
|
||||
客户端从发起到建立连接的握手过程,涉及加密协商和身份初始化。
|
||||
|
||||
| 事件名 | 类型 | 作用 | 描述 |
|
||||
|--------|------|------|------|
|
||||
| `PacketTypeInit1` | 握手包 | 初始化加密通道 | 客户端发送 UDP 包后服务器返回 init1 响应,完成第一阶段密钥交换。SDK 内部处理,应用层无感 |
|
||||
| `clientinitiv` | 服务器响应 | 加密参数协商 | 服务器返回 `alpha`、`beta`、`omega` 加密参数,客户端调用 `InitCrypto` 初始化加密上下文 |
|
||||
| `initivexpand2` | 服务器响应 | 二次加密扩展 | 服务器返回许可证和扩展密钥,客户端生成临时密钥对(`clientek`)完成最终加密握手 |
|
||||
| `clientinit` | 客户端命令 | 发送身份信息 | 加密握手完成后,客户端发送昵称、版本、HWID、默认频道等信息,请求加入服务器 |
|
||||
| `initserver` | 服务器响应 | 连接建立确认 | 服务器分配客户端 ID(`clid`),连接正式建立。触发 `OnConnected` 回调 |
|
||||
| **`OnConnected`** | SDK 回调 | 连接成功通知 | 应用层注册的连接成功回调。SDK 自动发送 `clientupdate` 解除静音。此时可调用 `ListChannels`、`ListClients` 获取快照 |
|
||||
|
||||
### Phase 2: Runtime — User Events (用户事件)
|
||||
|
||||
服务器上用户的上下线、移动、消息等实时事件。
|
||||
|
||||
| 事件名 | 类型 | 作用 | 描述 |
|
||||
|--------|------|------|------|
|
||||
| **`notifycliententerview`** → `OnClientEnter` | 服务器通知 | 用户进入视野 | 有新客户端连接到服务器或进入可见范围。数据结构 `ClientInfo`:`ID`(clid)、`Nickname`、`UID`、`ChannelID`、`Type`、`ServerGroups` |
|
||||
| **`notifyclientleftview`** → `OnClientLeave` | 服务器通知 | 用户离开视野 | 客户端断开连接或离开可见范围。数据结构 `ClientLeftViewEvent`:`ID`、`ReasonID`(0=正常、4=频道踢出、5=服务器踢出)、`ReasonMsg` |
|
||||
| **`notifyclientmoved`** → `OnClientMoved` | 服务器通知 | 用户切换频道 | 客户端被移动到另一个频道。数据结构 `ClientMovedEvent`:`ID`、`TargetChannelID`、`ReasonID`、`InvokerID`、`InvokerName`、`InvokerUID`。**如果是自己被移动,这是频道切换的确认点** |
|
||||
| **`notifytextmessage`** → `OnTextMessage` | 服务器通知 | 收到文字消息 | 收到私聊/频道/服务器范围的文字消息。数据结构 `TextMessage`:`TargetMode`(1=私聊、2=频道、3=服务器)、`TargetID`、`InvokerID`、`InvokerName`、`InvokerUID`、`Message`、`InvokerGroups` |
|
||||
| **`notifyclientpoke`** → `OnPoked` | 服务器通知 | 被其他用户戳 | 有用户发送 poke 消息。数据结构 `PokeEvent`:`InvokerID`、`InvokerName`、`InvokerUID`、`Message` |
|
||||
| `notifyclientneededpermissions` | 服务器通知 | 权限不足 | 操作因权限不足被拒绝。携带 `permid`、`permvalue`。SDK 仅记录日志,不触发应用层回调 |
|
||||
|
||||
### Phase 3: Runtime — Voice (语音事件)
|
||||
|
||||
实时语音数据的收发,通过 UDP 二进制包传输。
|
||||
|
||||
| 事件名 | 类型 | 作用 | 描述 |
|
||||
|--------|------|------|------|
|
||||
| **`VoiceDataEvent`** → `OnVoiceData` | 二进制包 | 收到语音数据 | 其他客户端发送的 Opus 语音帧。数据结构 `VoiceDataEvent`:`ClientID`(发送者)、`Data`(Opus 数据)、`Codec`(4=Opus Voice、5=Opus Music) |
|
||||
| `SendVoice(data, codec)` | 客户端命令 | 发送语音数据 | 发送 Opus 编码的语音帧到服务器,服务器转发给同频道其他用户。通过 `handler.SendVoicePacket` 直接写入 UDP |
|
||||
|
||||
**语音包二进制格式:**
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
0 2 packetID (big-endian)
|
||||
2 2 clientID (little-endian)
|
||||
4 1 codec (4=Opus Voice, 5=Opus Music)
|
||||
5 N Opus encoded data
|
||||
```
|
||||
|
||||
### Phase 4: Runtime — File Transfer (文件传输事件)
|
||||
|
||||
服务器文件的上传、下载和管理。文件数据通过独立的 TCP 连接传输。
|
||||
|
||||
| 事件名 | 类型 | 作用 | 描述 |
|
||||
|--------|------|------|------|
|
||||
| `ftinitupload` / **`notifystartupload`** | 命令+通知 | 初始化文件上传 | 客户端请求上传文件,服务器返回 `FileUploadInfo`:`Port`(TCP 端口)、`FileTransferKey`(传输密钥)、`SeekPosition`(断点)、`ClientFileTransferID`、`ServerFileTransferID` |
|
||||
| `ftinitdownload` / **`notifystartdownload`** | 命令+通知 | 初始化文件下载 | 客户端请求下载文件,服务器返回 `FileDownloadInfo`:`Port`、`FileTransferKey`、`Size`、`ClientFileTransferID`、`ServerFileTransferID` |
|
||||
| **`notifystatusfiletransfer`** | 服务器通知 | 文件传输状态 | 传输完成或失败的状态通知。数据结构 `FileTransferStatusInfo`:`Status`(0=成功)、`Message`(错误信息)、`ClientFileTransferID` |
|
||||
| `ftdeletefile` | 客户端命令 | 删除服务器文件 | 删除指定频道目录下的文件,支持 `\|` 分隔的批量删除 |
|
||||
|
||||
### Phase 5: Runtime — Commands & Errors (命令与错误)
|
||||
|
||||
客户端命令的异步响应机制和错误处理。
|
||||
|
||||
| 事件名 | 类型 | 作用 | 描述 |
|
||||
|--------|------|------|------|
|
||||
| `return_code` | 服务器响应 | 命令执行结果 | 每个命令附带 `return_code` 参数,服务器执行完成后返回对应的 `return_code`,SDK 通过 `commandTracker` 匹配异步响应。`id=0` 表示成功,非 0 为错误码 |
|
||||
| **`error`** | 服务器响应 | 服务器错误 | 服务器返回的错误信息。`id`(错误码)、`msg`(错误描述)。**错误码 3329 为致命错误(如被 ban),SDK 自动断开连接** |
|
||||
| `clientupdate` | 客户端命令 | 更新客户端状态 | 连接成功后 SDK 自动发送,设置 `client_input_muted=0`、`client_output_muted=0` |
|
||||
|
||||
**命令限速:** SDK 内置 token-bucket 限速器(`throttle.go`),速率 4 tokens/s,上限 8。所有 `ExecCommand` 调用自动排队等待。
|
||||
|
||||
### Phase 6: Disconnect (断开阶段)
|
||||
|
||||
连接关闭和清理。
|
||||
|
||||
| 事件名 | 类型 | 作用 | 描述 |
|
||||
|--------|------|------|------|
|
||||
| `clientdisconnect` | 客户端命令 | 主动断开 | 客户端发送 `clientdisconnect reasonmsg=Shutdown`,然后关闭 UDP 连接 |
|
||||
| **`OnDisconnected`** | SDK 回调 | 断开连接通知 | 连接断开时触发。参数 `error`:`nil` = 正常断开,非 `nil` = 异常断开原因 |
|
||||
| `OnClosed` (handler) | 传输层回调 | 底层连接关闭 | UDP 连接关闭时触发,SDK 内部据此调用 `OnDisconnected`。应用层不直接使用 |
|
||||
|
||||
### Lifecycle Overview (生命周期总览)
|
||||
|
||||
```
|
||||
NewClient(identity, addr, nickname, opts...)
|
||||
│
|
||||
├─ 启动事件循环协程 (startEventLoop)
|
||||
├─ 初始化 PacketHandler、Crypto、Throttle
|
||||
│
|
||||
Connect()
|
||||
│
|
||||
├─ 地址解析 (SRV / TSDNS / 直连)
|
||||
├─ UDP 连接
|
||||
├─ PacketTypeInit1 握手
|
||||
├─ ← clientinitiv (加密参数)
|
||||
├─ → InitCrypto
|
||||
├─ ← initivexpand2 (二次加密)
|
||||
├─ → clientek (临时密钥)
|
||||
├─ → clientinit (身份信息)
|
||||
├─ ← initserver → 连接建立
|
||||
│
|
||||
├─ → OnConnected() 回调
|
||||
├─ → clientupdate (解除静音)
|
||||
│
|
||||
│ ┌─ API 调用 ─────────────────────────────┐
|
||||
│ │ ListChannels() → channellist │
|
||||
│ │ ListClients() → clientlist │
|
||||
│ │ GetClientInfo() → clientinfo │
|
||||
│ │ SendTextMessage() → sendtextmessage │
|
||||
│ │ ClientMove() → clientmove │
|
||||
│ │ SendVoice() → UDP 二进制包 │
|
||||
│ └─────────────────────────────────────────┘
|
||||
│
|
||||
│ ┌─ 服务器事件 ────────────────────────────┐
|
||||
│ │ notifycliententerview → OnClientEnter │
|
||||
│ │ notifyclientleftview → OnClientLeave │
|
||||
│ │ notifyclientmoved → OnClientMoved │
|
||||
│ │ notifytextmessage → OnTextMessage │
|
||||
│ │ notifyclientpoke → OnPoked │
|
||||
│ │ VoiceDataEvent → OnVoiceData │
|
||||
│ │ error (id=3329) → 自动断开 │
|
||||
│ └─────────────────────────────────────────┘
|
||||
│
|
||||
Disconnect()
|
||||
│
|
||||
├─ → clientdisconnect reasonmsg=Shutdown
|
||||
├─ → 关闭 UDP 连接 (handler.Close)
|
||||
└─ → OnDisconnected(nil) 回调
|
||||
```
|
||||
|
||||
## Data Types
|
||||
|
||||
| 结构体 | 用途 | 关键字段 |
|
||||
|--------|------|----------|
|
||||
| `ClientInfo` | 客户端信息 | `ID` (uint16), `Nickname`, `UID`, `ChannelID` (uint64), `Type`, `ServerGroups` ([]string) |
|
||||
| `ChannelInfo` | 频道信息 | `ID` (uint64), `Name`, `ParentID` (uint64), `Description` |
|
||||
| `TextMessage` | 文字消息 | `TargetMode` (int), `TargetID` (uint64), `InvokerID` (uint16), `InvokerName`, `InvokerUID`, `Message`, `InvokerGroups` |
|
||||
| `ClientMovedEvent` | 频道移动 | `ID` (uint16), `TargetChannelID` (uint64), `ReasonID` (int), `InvokerID` (uint16), `InvokerName`, `InvokerUID` |
|
||||
| `ClientLeftViewEvent` | 用户离开 | `ID` (uint16), `ReasonID` (int), `ReasonMsg`, `TargetID` (uint16) |
|
||||
| `PokeEvent` | Poke 消息 | `InvokerID` (uint16), `InvokerName`, `InvokerUID`, `Message` |
|
||||
| `VoiceDataEvent` | 语音数据 | `ClientID` (uint16), `Data` ([]byte), `Codec` (byte) |
|
||||
| `FileUploadInfo` | 上传信息 | `Port`, `FileTransferKey`, `SeekPosition`, `ClientFileTransferID`, `ServerFileTransferID` |
|
||||
| `FileDownloadInfo` | 下载信息 | `Port`, `FileTransferKey`, `Size`, `ClientFileTransferID`, `ServerFileTransferID` |
|
||||
| `FileTransferStatusInfo` | 传输状态 | `Status` (int), `Message`, `ClientFileTransferID` |
|
||||
|
||||
## API Reference
|
||||
|
||||
### Client Construction
|
||||
|
||||
```go
|
||||
identity, _ := crypto.GenerateIdentity(8)
|
||||
client := teamspeak.NewClient(identity, "host:9987", "Nickname",
|
||||
teamspeak.WithServerPassword("pass"),
|
||||
teamspeak.WithDefaultChannel("/General"),
|
||||
teamspeak.WithDefaultChannelPassword("cpw"),
|
||||
teamspeak.WithLogger(logger),
|
||||
teamspeak.WithResolver(customResolver),
|
||||
)
|
||||
```
|
||||
|
||||
### Connection
|
||||
|
||||
```go
|
||||
client.Connect() // 启动连接(非阻塞)
|
||||
client.WaitConnected(context.Background()) // 阻塞等待握手完成
|
||||
client.Disconnect() // 优雅断开
|
||||
client.ClientID() // 获取服务器分配的客户端 ID
|
||||
```
|
||||
|
||||
### Data Queries
|
||||
|
||||
```go
|
||||
channels, err := client.ListChannels() // 获取所有频道
|
||||
clients, err := client.ListClients() // 获取所有在线客户端
|
||||
info, err := client.GetClientInfo(clid) // 获取单个客户端详情
|
||||
```
|
||||
|
||||
### Messaging
|
||||
|
||||
```go
|
||||
client.SendTextMessage(targetMode, targetID, message) // 发送文字消息
|
||||
client.ClientMove(clid, channelID, password) // 移动频道
|
||||
client.Poke(clid, message) // Poke 用户
|
||||
```
|
||||
|
||||
### Voice
|
||||
|
||||
```go
|
||||
client.SendVoice(opusData, 4) // 发送 Opus Voice 帧 (codec=4)
|
||||
client.SendVoice(opusData, 5) // 发送 Opus Music 帧 (codec=5)
|
||||
```
|
||||
|
||||
### File Transfer
|
||||
|
||||
```go
|
||||
info, err := client.FileTransferInitUpload(channelID, "/path", "", size, false)
|
||||
teamspeak.UploadFileData(host, info, reader)
|
||||
|
||||
info, err := client.FileTransferInitDownload(channelID, "/path", "")
|
||||
teamspeak.DownloadFileData(host, info, writer)
|
||||
|
||||
client.FileTransferDeleteFile(channelID, []string{"/file1", "/file2"})
|
||||
```
|
||||
|
||||
### Event Registration
|
||||
|
||||
```go
|
||||
client.OnConnected(func() { ... })
|
||||
client.OnDisconnected(func(err error) { ... })
|
||||
client.OnClientEnter(func(info teamspeak.ClientInfo) { ... })
|
||||
client.OnClientLeave(func(evt teamspeak.ClientLeftViewEvent) { ... })
|
||||
client.OnClientMoved(func(evt teamspeak.ClientMovedEvent) { ... })
|
||||
client.OnTextMessage(func(msg teamspeak.TextMessage) { ... })
|
||||
client.OnPoked(func(evt teamspeak.PokeEvent) { ... })
|
||||
client.OnKicked(func(reason string) { ... })
|
||||
client.OnVoiceData(func(evt teamspeak.VoiceDataEvent) { ... })
|
||||
```
|
||||
|
||||
### Middleware
|
||||
|
||||
```go
|
||||
// 命令中间件:拦截/修改出站命令
|
||||
client.UseCommandMiddleware(func(next func(string) error) func(string) error {
|
||||
return func(cmd string) error {
|
||||
log.Println("CMD:", cmd)
|
||||
return next(cmd)
|
||||
}
|
||||
})
|
||||
|
||||
// 事件中间件:拦截/修改入站事件
|
||||
client.UseEventMiddleware(func(next func(any)) func(any) {
|
||||
return func(evt any) {
|
||||
log.Println("EVT:", evt)
|
||||
next(evt)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Local Patches
|
||||
|
||||
本项目通过 `go.mod` 的 `replace` 指令使用本地补丁版本:
|
||||
|
||||
```
|
||||
replace github.com/honeybbq/teamspeak-go => ./_patches/github.com/honeybbq/teamspeak-go
|
||||
```
|
||||
|
||||
补丁修复了上游库的以下问题:
|
||||
- 32 位目标平台的整数溢出(`math.MaxUint32 overflows int`)
|
||||
- 其他 gomobile 兼容性修复
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-2026 honeybbq
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,223 @@
|
||||
<div align="center">
|
||||
|
||||
# teamspeak-go
|
||||
|
||||
**A clean-room TeamSpeak client protocol library written in pure Go.**
|
||||
|
||||
Compatible with TeamSpeak 3, 5 & 6. No proprietary SDK. No copy-pasted code.
|
||||
|
||||
[](https://github.com/honeybbq/teamspeak-go/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/honeybbq/teamspeak-go)
|
||||
[](https://goreportcard.com/report/github.com/honeybbq/teamspeak-go)
|
||||
|
||||
[](https://pkg.go.dev/github.com/honeybbq/teamspeak-go)
|
||||
[](go.mod)
|
||||
[](LICENSE)
|
||||
|
||||
</div>
|
||||
|
||||
## Features
|
||||
|
||||
- **Full protocol handshake** — ECDH key exchange, RSA puzzle, EAX-encrypted transport
|
||||
- **Command & notification system** — Send commands, receive server events
|
||||
- **Event-driven API** — Register handlers for text messages, client enter/leave, channel moves, kicks, etc.
|
||||
- **Voice data** — Send Opus voice packets (codec 4 & 5)
|
||||
- **File transfers** — Upload, download, and delete files on the server
|
||||
- **Address resolution** — SRV records, TSDNS, and direct address support
|
||||
- **Middleware** — Pluggable command and event middleware chains
|
||||
- **Built-in rate limiter** — Token-bucket throttling to prevent server-side flood kicks
|
||||
- **Identity management** — Generate, import/export, and upgrade security level of identities
|
||||
- **Zero CGO** — Pure Go, cross-compile anywhere
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get github.com/honeybbq/teamspeak-go
|
||||
```
|
||||
|
||||
Requires **Go 1.26** or later.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
teamspeak "github.com/honeybbq/teamspeak-go"
|
||||
"github.com/honeybbq/teamspeak-go/crypto"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Generate a new identity (or load an existing one)
|
||||
identity, err := crypto.GenerateIdentity(8)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Create the client
|
||||
client := teamspeak.NewClient(
|
||||
identity,
|
||||
"localhost",
|
||||
"GoBot",
|
||||
teamspeak.WithServerPassword(os.Getenv("TEAMSPEAK_SERVER_PASSWORD")),
|
||||
teamspeak.WithDefaultChannel("Lobby"),
|
||||
teamspeak.WithDefaultChannelPassword(os.Getenv("TEAMSPEAK_DEFAULT_CHANNEL_PASSWORD")),
|
||||
)
|
||||
|
||||
// Register event handlers
|
||||
client.OnConnected(func() {
|
||||
fmt.Println("Connected to server!")
|
||||
})
|
||||
|
||||
client.OnTextMessage(func(msg teamspeak.TextMessage) {
|
||||
fmt.Printf("[%s]: %s\n", msg.InvokerName, msg.Message)
|
||||
})
|
||||
|
||||
client.OnDisconnected(func(err error) {
|
||||
fmt.Println("Disconnected:", err)
|
||||
})
|
||||
|
||||
// Connect
|
||||
if err := client.Connect(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Wait until connected
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := client.WaitConnected(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Wait for interrupt signal
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt)
|
||||
<-sig
|
||||
|
||||
client.Disconnect()
|
||||
}
|
||||
```
|
||||
|
||||
## API Overview
|
||||
|
||||
### Client Lifecycle
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `NewClient(identity, addr, nickname, ...opts)` | Create a new client |
|
||||
| `Connect()` | Initiate connection to the server |
|
||||
| `WaitConnected(ctx)` | Block until the handshake completes |
|
||||
| `Disconnect()` | Gracefully disconnect |
|
||||
|
||||
### Events
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `OnConnected(func())` | Fires when fully connected |
|
||||
| `OnDisconnected(func(error))` | Fires on disconnect |
|
||||
| `OnTextMessage(func(TextMessage))` | Fires on text messages |
|
||||
| `OnClientEnter(func(ClientInfo))` | Fires when a client joins |
|
||||
| `OnClientLeave(func(ClientLeftViewEvent))` | Fires when a client leaves |
|
||||
| `OnClientMoved(func(ClientMovedEvent))` | Fires when a client moves channels |
|
||||
| `OnKicked(func(string))` | Fires when the bot is kicked |
|
||||
|
||||
### Commands
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `SendTextMessage(targetMode, targetID, msg)` | Send a text message |
|
||||
| `ClientMove(clid, channelID, password)` | Move a client to a channel |
|
||||
| `Poke(clid, message)` | Poke a client |
|
||||
| `SendVoice(data, codec)` | Send Opus voice data |
|
||||
| `ListChannels()` | List all channels |
|
||||
| `ListClients()` | List all connected clients |
|
||||
| `GetClientInfo(clid)` | Get detailed client information |
|
||||
| `ExecCommand(cmd, timeout)` | Execute a raw command |
|
||||
| `ExecCommandWithResponse(cmd, timeout)` | Execute a command and return response data |
|
||||
|
||||
### File Transfers
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `FileTransferInitUpload(...)` | Initialize a file upload |
|
||||
| `FileTransferInitDownload(...)` | Initialize a file download |
|
||||
| `FileTransferDeleteFile(...)` | Delete files on the server |
|
||||
| `UploadFileData(host, info, reader)` | Transfer file data to the server |
|
||||
| `DownloadFileData(host, info, writer)` | Receive file data from the server |
|
||||
|
||||
### Identity
|
||||
|
||||
```go
|
||||
// Generate a new identity with security level 8
|
||||
identity, err := crypto.GenerateIdentity(8)
|
||||
|
||||
// Export to string for persistent storage
|
||||
exported := identity.String()
|
||||
|
||||
// Import from a previously exported string
|
||||
identity, err = crypto.IdentityFromString(exported)
|
||||
|
||||
// Upgrade security level (CPU-intensive)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
err = identity.UpgradeToLevel(10, ctx)
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```go
|
||||
client := teamspeak.NewClient(identity, "ts.example.com", "Bot",
|
||||
teamspeak.WithLogger(slog.Default()),
|
||||
teamspeak.WithResolver(customResolver),
|
||||
teamspeak.WithCommandMiddleware(loggingMiddleware),
|
||||
teamspeak.WithEventMiddleware(filterMiddleware),
|
||||
teamspeak.WithServerPassword(os.Getenv("TEAMSPEAK_SERVER_PASSWORD")),
|
||||
teamspeak.WithDefaultChannel("Lobby"),
|
||||
teamspeak.WithDefaultChannelPassword(os.Getenv("TEAMSPEAK_DEFAULT_CHANNEL_PASSWORD")),
|
||||
)
|
||||
```
|
||||
|
||||
Connection auth options:
|
||||
|
||||
- `WithServerPassword(password)` accepts the plain-text server password and sends the TeamSpeak protocol hash during `clientinit`
|
||||
- `WithDefaultChannel(channel)` requests a default channel during `clientinit`
|
||||
- `WithDefaultChannelPassword(password)` accepts the plain-text channel password and sends the TeamSpeak protocol hash for the configured default channel
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
teamspeak-go/
|
||||
├── client.go # Client lifecycle, connection management
|
||||
├── api.go # High-level API (messages, channels, clients)
|
||||
├── commands.go # Command sending and response tracking
|
||||
├── events.go # Event handler registration and middleware
|
||||
├── notifications.go # Server notification parsing and dispatch
|
||||
├── handshake.go # Protocol handshake orchestration
|
||||
├── transfer.go # File transfer operations
|
||||
├── crypto/ # ECDH, EAX encryption, identity management
|
||||
├── handshake/ # Crypto handshake and license verification
|
||||
├── transport/ # UDP packet framing, ACK, compression
|
||||
├── commands/ # Command builder and parser
|
||||
└── discovery/ # SRV / TSDNS / direct address resolution
|
||||
```
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Protocol knowledge was primarily informed by the [TSLib](https://github.com/Splamy/TS3AudioBot) implementation in [TS3AudioBot](https://github.com/Splamy/TS3AudioBot) by Splamy. Huge thanks to the TS3AudioBot project and its contributors.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
TeamSpeak is a registered trademark of [TeamSpeak Systems GmbH](https://teamspeak.com/). This project is not affiliated with, endorsed by, or associated with TeamSpeak Systems GmbH in any way.
|
||||
|
||||
This library is a **clean-room implementation** developed from publicly available documentation, protocol analysis of network traffic, and independent research. No proprietary TeamSpeak SDK code, headers, or libraries were used in its creation.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
@@ -0,0 +1,683 @@
|
||||
package teamspeak
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/commands"
|
||||
)
|
||||
|
||||
var errNoDataReturnedForClient = errors.New("no data returned for client")
|
||||
|
||||
// SendTextMessage sends a text message to a client, channel or server.
|
||||
func (c *Client) SendTextMessage(targetMode int, targetID uint64, message string) error {
|
||||
cmd := commands.BuildCommandOrdered("sendtextmessage", [][2]string{
|
||||
{"targetmode", strconv.Itoa(targetMode)},
|
||||
{"target", strconv.FormatUint(targetID, 10)},
|
||||
{"msg", message},
|
||||
})
|
||||
|
||||
return c.SendCommandNoWait(cmd)
|
||||
}
|
||||
|
||||
// ClientMove moves a client to a different channel.
|
||||
func (c *Client) ClientMove(clid uint16, channelID uint64, password string) error {
|
||||
params := [][2]string{
|
||||
{"clid", strconv.Itoa(int(clid))},
|
||||
{"cid", strconv.FormatUint(channelID, 10)},
|
||||
}
|
||||
if password != "" {
|
||||
params = append(params, [2]string{"cpw", prepareClientPassword(password)})
|
||||
}
|
||||
cmd := commands.BuildCommandOrdered("clientmove", params)
|
||||
|
||||
return c.ExecCommand(cmd, 10*time.Second)
|
||||
}
|
||||
|
||||
// Poke sends a poke message to a client.
|
||||
func (c *Client) Poke(clid uint16, message string) error {
|
||||
cmd := commands.BuildCommandOrdered("clientpoke", [][2]string{
|
||||
{"clid", strconv.Itoa(int(clid))},
|
||||
{"msg", message},
|
||||
})
|
||||
|
||||
return c.ExecCommand(cmd, 10*time.Second)
|
||||
}
|
||||
|
||||
// SendVoice sends a raw Opus frame. Codec values: 4 = Opus voice, 5 = Opus music.
|
||||
func (c *Client) SendVoice(data []byte, codec byte) error {
|
||||
return c.handler.SendVoicePacket(data, codec)
|
||||
}
|
||||
|
||||
// ClientID returns the client's own ID assigned by the server.
|
||||
func (c *Client) ClientID() uint16 {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
return c.clid
|
||||
}
|
||||
|
||||
// GetClientInfo fetches detailed information about a client.
|
||||
func (c *Client) GetClientInfo(clid uint16) (map[string]string, error) {
|
||||
cmd := fmt.Sprintf("clientinfo clid=%d", clid)
|
||||
data, err := c.ExecCommandWithResponse(cmd, 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("%w: %d", errNoDataReturnedForClient, clid)
|
||||
}
|
||||
|
||||
return data[0], nil
|
||||
}
|
||||
|
||||
// ListChannels returns a list of all channels on the server.
|
||||
func (c *Client) ListChannels() ([]ChannelInfo, error) {
|
||||
data, err := c.ExecCommandWithResponse("channellist", 5*time.Second)
|
||||
if err != nil {
|
||||
log.Printf("[teamspeak-go] ListChannels ExecCommandWithResponse error: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("[teamspeak-go] ListChannels got %d raw data rows", len(data))
|
||||
for i, item := range data {
|
||||
log.Printf("[teamspeak-go] ListChannels row[%d]: %v", i, item)
|
||||
}
|
||||
|
||||
channels := make([]ChannelInfo, 0, len(data))
|
||||
for _, item := range data {
|
||||
cid, _ := parseUint64Value(item["cid"])
|
||||
pid, _ := parseUint64Value(item["pid"])
|
||||
name := item["channel_name"]
|
||||
|
||||
channels = append(channels, ChannelInfo{
|
||||
ID: cid,
|
||||
ParentID: pid,
|
||||
Name: commands.Unescape(name),
|
||||
})
|
||||
}
|
||||
|
||||
return channels, nil
|
||||
}
|
||||
|
||||
// ListChannelsDetailed returns channels with extended properties.
|
||||
// 通过 channellist -topic -flags -voice -limits -icon 获取更丰富的频道属性。
|
||||
func (c *Client) ListChannelsDetailed() ([]ChannelInfoDetailed, error) {
|
||||
data, err := c.ExecCommandWithResponse(
|
||||
"channellist -topic -flags -voice -limits -icon", 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
channels := make([]ChannelInfoDetailed, 0, len(data))
|
||||
for _, item := range data {
|
||||
cid, _ := parseUint64Value(item["cid"])
|
||||
pid, _ := parseUint64Value(item["pid"])
|
||||
order, _ := parseUint64Value(item["channel_order"])
|
||||
name := item["channel_name"]
|
||||
topic := item["channel_topic"]
|
||||
|
||||
codec, _ := parseIntValue(item["channel_codec"])
|
||||
codecQuality, _ := parseIntValue(item["channel_codec_quality"])
|
||||
neededTalkPower, _ := parseIntValue(item["channel_needed_talk_power"])
|
||||
|
||||
maxClients, _ := parseIntValue(item["channel_maxclients"])
|
||||
maxFamilyClients, _ := parseIntValue(item["channel_maxfamilyclients"])
|
||||
|
||||
iconID, _ := parseInt64Value(item["channel_icon_id"])
|
||||
neededModifyPower, _ := parseIntValue(item["channel_needed_modify_power"])
|
||||
|
||||
channels = append(channels, ChannelInfoDetailed{
|
||||
ID: cid,
|
||||
ParentID: pid,
|
||||
Order: order,
|
||||
Name: commands.Unescape(name),
|
||||
Topic: commands.Unescape(topic),
|
||||
Codec: codec,
|
||||
CodecQuality: codecQuality,
|
||||
NeededTalkPower: neededTalkPower,
|
||||
MaxClients: maxClients,
|
||||
MaxFamilyClients: maxFamilyClients,
|
||||
IsMaxClientsUnlimited: parseBoolValue(item["channel_flag_maxclients_unlimited"]),
|
||||
IsMaxFamilyClientsUnlimited: parseBoolValue(item["channel_flag_maxfamilyclients_unlimited"]),
|
||||
IsOrdered: order > 0,
|
||||
IsPermanent: parseBoolValue(item["channel_flag_permanent"]),
|
||||
IsSemiPermanent: parseBoolValue(item["channel_flag_semi_permanent"]),
|
||||
IsDefault: parseBoolValue(item["channel_flag_default"]),
|
||||
IsPassword: parseBoolValue(item["channel_flag_password"]),
|
||||
HasPassword: parseBoolValue(item["channel_flag_password"]),
|
||||
NeededModifyPower: neededModifyPower,
|
||||
IconID: iconID,
|
||||
})
|
||||
}
|
||||
|
||||
return channels, nil
|
||||
}
|
||||
|
||||
// ListClients returns a list of all clients currently connected to the server.
|
||||
func (c *Client) ListClients() ([]ClientInfo, error) {
|
||||
data, err := c.ExecCommandWithResponse("clientlist -uid -away -voice -groups", 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
clients := make([]ClientInfo, 0, len(data))
|
||||
for _, item := range data {
|
||||
clid, _ := parseUint16Value(item["clid"])
|
||||
nick := item["client_nickname"]
|
||||
cid, _ := parseUint64Value(item["cid"])
|
||||
uid := item["client_unique_identifier"]
|
||||
clientType, _ := strconv.Atoi(item["client_type"])
|
||||
groupsStr := item["client_servergroups"]
|
||||
|
||||
groups := make([]string, 0)
|
||||
if groupsStr != "" {
|
||||
groups = strings.Split(groupsStr, ",")
|
||||
}
|
||||
|
||||
clients = append(clients, ClientInfo{
|
||||
ID: clid,
|
||||
Nickname: commands.Unescape(nick),
|
||||
ChannelID: cid,
|
||||
UID: uid,
|
||||
Type: clientType,
|
||||
ServerGroups: groups,
|
||||
})
|
||||
}
|
||||
|
||||
return clients, nil
|
||||
}
|
||||
|
||||
// WaitConnected waits for the connection handshake to be completed.
|
||||
// Returns nil on success, or the handshake/disconnect error on failure.
|
||||
func (c *Client) WaitConnected(ctx context.Context) error {
|
||||
c.logger.Info("WaitConnected: waiting for handshake to complete")
|
||||
select {
|
||||
case <-c.connectedChan:
|
||||
c.logger.Info("WaitConnected: channel unblocked", slog.Any("err", c.connectedErr))
|
||||
return c.connectedErr
|
||||
case <-ctx.Done():
|
||||
c.logger.Warn("WaitConnected: context done", slog.Any("err", ctx.Err()))
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 服务器查询 ────────────────────────────────────────────
|
||||
|
||||
// GetServerInfo returns server details.
|
||||
// 协议命令: serverinfo
|
||||
func (c *Client) GetServerInfo() (*ServerInfo, error) {
|
||||
data, err := c.ExecCommandWithResponse("serverinfo", 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("serverinfo: %w", errNoDataReturnedForClient)
|
||||
}
|
||||
item := data[0]
|
||||
maxClients, _ := parseIntValue(item["virtualserver_maxclients"])
|
||||
clientsOnline, _ := parseIntValue(item["virtualserver_clientsonline"])
|
||||
channelsOnline, _ := parseIntValue(item["virtualserver_channelsonline"])
|
||||
uptime, _ := parseInt64Value(item["virtualserver_uptime"])
|
||||
created, _ := parseInt64Value(item["virtualserver_created"])
|
||||
iconID, _ := parseInt64Value(item["virtualserver_icon_id"])
|
||||
defServerGroup, _ := parseIntValue(item["virtualserver_default_server_group"])
|
||||
defChannelGroup, _ := parseIntValue(item["virtualserver_default_channel_group"])
|
||||
|
||||
return &ServerInfo{
|
||||
Name: commands.Unescape(item["virtualserver_name"]),
|
||||
WelcomeMessage: commands.Unescape(item["virtualserver_welcomemessage"]),
|
||||
MaxClients: maxClients,
|
||||
ClientsOnline: clientsOnline,
|
||||
ChannelsOnline: channelsOnline,
|
||||
Uptime: uptime,
|
||||
Version: item["virtualserver_version"],
|
||||
Platform: item["virtualserver_platform"],
|
||||
Created: created,
|
||||
IconID: iconID,
|
||||
DefaultServerGroup: defServerGroup,
|
||||
DefaultChannelGroup: defChannelGroup,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ─── 频道查询 / 管理 ──────────────────────────────────────
|
||||
|
||||
// GetChannelInfo returns detailed info for a single channel.
|
||||
// 协议命令: channelinfo cid=X
|
||||
// 与 ListChannelsDetailed 的区别:本方法返回单频道的完整信息(含 description),
|
||||
// ListChannelsDetailed 返回所有频道的部分属性。
|
||||
func (c *Client) GetChannelInfo(channelID uint64) (*ChannelDetailInfo, error) {
|
||||
cmd := fmt.Sprintf("channelinfo cid=%d", channelID)
|
||||
data, err := c.ExecCommandWithResponse(cmd, 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("channelinfo: %w", errNoDataReturnedForClient)
|
||||
}
|
||||
item := data[0]
|
||||
cid, _ := parseUint64Value(item["cid"])
|
||||
pid, _ := parseUint64Value(item["channel_order"])
|
||||
order, _ := parseUint64Value(item["channel_order"])
|
||||
codec, _ := parseIntValue(item["channel_codec"])
|
||||
codecQuality, _ := parseIntValue(item["channel_codec_quality"])
|
||||
maxClients, _ := parseIntValue(item["channel_maxclients"])
|
||||
maxFamilyClients, _ := parseIntValue(item["channel_maxfamilyclients"])
|
||||
neededTalkPower, _ := parseIntValue(item["channel_needed_talk_power"])
|
||||
iconID, _ := parseInt64Value(item["channel_icon_id"])
|
||||
|
||||
return &ChannelDetailInfo{
|
||||
ID: cid,
|
||||
ParentID: pid,
|
||||
Order: order,
|
||||
Name: commands.Unescape(item["channel_name"]),
|
||||
Topic: commands.Unescape(item["channel_topic"]),
|
||||
Description: commands.Unescape(item["channel_description"]),
|
||||
Codec: codec,
|
||||
CodecQuality: codecQuality,
|
||||
MaxClients: maxClients,
|
||||
MaxFamilyClients: maxFamilyClients,
|
||||
NeededTalkPower: neededTalkPower,
|
||||
IconID: iconID,
|
||||
IsPermanent: parseBoolValue(item["channel_flag_permanent"]),
|
||||
IsSemiPermanent: parseBoolValue(item["channel_flag_semi_permanent"]),
|
||||
IsDefault: parseBoolValue(item["channel_flag_default"]),
|
||||
IsPassword: parseBoolValue(item["channel_flag_password"]),
|
||||
BannerGfxURL: item["channel_banner_gfx_url"],
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FindChannels searches channels by name pattern.
|
||||
// 协议命令: channelfind pattern=X
|
||||
func (c *Client) FindChannels(pattern string) ([]ChannelInfo, error) {
|
||||
cmd := commands.BuildCommand("channelfind", map[string]string{"pattern": pattern})
|
||||
data, err := c.ExecCommandWithResponse(cmd, 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
channels := make([]ChannelInfo, 0, len(data))
|
||||
for _, item := range data {
|
||||
cid, _ := parseUint64Value(item["cid"])
|
||||
channels = append(channels, ChannelInfo{
|
||||
ID: cid,
|
||||
Name: commands.Unescape(item["channel_name"]),
|
||||
})
|
||||
}
|
||||
return channels, nil
|
||||
}
|
||||
|
||||
// CreateChannel creates a new channel.
|
||||
// 协议命令: channelcreate channel_name=X [params...]
|
||||
// 返回新建频道的 ID。
|
||||
// 常用可选参数通过 options map 传递,例如:
|
||||
// - "channel_topic": "主题"
|
||||
// - "channel_flag_permanent": "1"
|
||||
// - "channel_password": "密码"
|
||||
// - "cpid": "父频道ID"
|
||||
func (c *Client) CreateChannel(name string, options map[string]string) (uint64, error) {
|
||||
params := [][2]string{{"channel_name", name}}
|
||||
for k, v := range options {
|
||||
params = append(params, [2]string{k, v})
|
||||
}
|
||||
cmd := commands.BuildCommandOrdered("channelcreate", params)
|
||||
data, err := c.ExecCommandWithResponse(cmd, 5*time.Second)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return 0, fmt.Errorf("channelcreate: no cid returned")
|
||||
}
|
||||
cid, _ := parseUint64Value(data[0]["cid"])
|
||||
return cid, nil
|
||||
}
|
||||
|
||||
// EditChannel modifies channel properties.
|
||||
// 协议命令: channeledit cid=X [params...]
|
||||
func (c *Client) EditChannel(channelID uint64, properties map[string]string) error {
|
||||
params := [][2]string{{"cid", fmt.Sprintf("%d", channelID)}}
|
||||
for k, v := range properties {
|
||||
params = append(params, [2]string{k, v})
|
||||
}
|
||||
cmd := commands.BuildCommandOrdered("channeledit", params)
|
||||
return c.ExecCommand(cmd, 5*time.Second)
|
||||
}
|
||||
|
||||
// DeleteChannel deletes a channel.
|
||||
// 协议命令: channeldelete cid=X force=X
|
||||
// force=true 强制删除(含子频道)。
|
||||
func (c *Client) DeleteChannel(channelID uint64, force bool) error {
|
||||
forceFlag := "0"
|
||||
if force {
|
||||
forceFlag = "1"
|
||||
}
|
||||
cmd := fmt.Sprintf("channeldelete cid=%d force=%s", channelID, forceFlag)
|
||||
return c.ExecCommand(cmd, 5*time.Second)
|
||||
}
|
||||
|
||||
// MoveChannel moves a channel to a new parent or position.
|
||||
// 协议命令: channelmove cid=X cpid=X order=X
|
||||
func (c *Client) MoveChannel(channelID, parentID, order uint64) error {
|
||||
cmd := fmt.Sprintf("channelmove cid=%d cpid=%d order=%d", channelID, parentID, order)
|
||||
return c.ExecCommand(cmd, 5*time.Second)
|
||||
}
|
||||
|
||||
// ─── 客户端查询 / 管理 ────────────────────────────────────
|
||||
|
||||
// GetClientDetailInfo returns extended client details.
|
||||
// 协议命令: clientinfo clid=X
|
||||
// 与 ListClients 返回的 ClientInfo 相比,本方法返回更丰富的属性
|
||||
// (away 状态、平台、版本、IP、连接历史等)。
|
||||
func (c *Client) GetClientDetailInfo(clid uint16) (*ClientDetailInfo, error) {
|
||||
cmd := fmt.Sprintf("clientinfo clid=%d", clid)
|
||||
data, err := c.ExecCommandWithResponse(cmd, 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("%w: %d", errNoDataReturnedForClient, clid)
|
||||
}
|
||||
item := data[0]
|
||||
cid, _ := parseUint16Value(item["clid"])
|
||||
channelID, _ := parseUint64Value(item["cid"])
|
||||
clientType, _ := parseIntValue(item["client_type"])
|
||||
created, _ := parseInt64Value(item["client_created"])
|
||||
lastConnected, _ := parseInt64Value(item["client_lastconnected"])
|
||||
totalConnections, _ := parseIntValue(item["client_totalconnections"])
|
||||
iconID, _ := parseInt64Value(item["client_icon_id"])
|
||||
|
||||
groupsStr := item["client_servergroups"]
|
||||
groups := make([]string, 0)
|
||||
if groupsStr != "" {
|
||||
groups = strings.Split(groupsStr, ",")
|
||||
}
|
||||
|
||||
return &ClientDetailInfo{
|
||||
ID: cid,
|
||||
Nickname: commands.Unescape(item["client_nickname"]),
|
||||
UID: item["client_unique_identifier"],
|
||||
ChannelID: channelID,
|
||||
Type: clientType,
|
||||
ServerGroups: groups,
|
||||
Away: parseBoolValue(item["client_away"]),
|
||||
AwayMessage: commands.Unescape(item["client_away_message"]),
|
||||
InputMuted: parseBoolValue(item["client_input_muted"]),
|
||||
OutputMuted: parseBoolValue(item["client_output_muted"]),
|
||||
Platform: item["client_platform"],
|
||||
Version: item["client_version"],
|
||||
IP: item["connection_client_ip"],
|
||||
Created: created,
|
||||
LastConnected: lastConnected,
|
||||
TotalConnections: totalConnections,
|
||||
Description: commands.Unescape(item["client_description"]),
|
||||
IconID: iconID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FindClientByDBID searches the client database by database ID.
|
||||
// 协议命令: clientdbfind pattern=X -uid
|
||||
// 返回客户端的 DBID 和 UID。
|
||||
func (c *Client) FindClientByDBID(dbid uint64) (string, error) {
|
||||
cmd := fmt.Sprintf("clientdbfind -uid cldbid=%d", dbid)
|
||||
data, err := c.ExecCommandWithResponse(cmd, 5*time.Second)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return "", fmt.Errorf("clientdbfind: no result for cldbid %d", dbid)
|
||||
}
|
||||
return data[0]["client_unique_identifier"], nil
|
||||
}
|
||||
|
||||
// FindClientByName searches the client database by nickname.
|
||||
// 协议命令: clientdbfind pattern=X -uid
|
||||
func (c *Client) FindClientByName(nickname string) (uid string, dbid uint64, err error) {
|
||||
cmd := commands.BuildCommand("clientdbfind", map[string]string{
|
||||
"pattern": nickname,
|
||||
}) + " -uid"
|
||||
data, err := c.ExecCommandWithResponse(cmd, 5*time.Second)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return "", 0, fmt.Errorf("clientdbfind: no result for %q", nickname)
|
||||
}
|
||||
uid = data[0]["client_unique_identifier"]
|
||||
dbid, _ = parseUint64Value(data[0]["cldbid"])
|
||||
return uid, dbid, nil
|
||||
}
|
||||
|
||||
// ListDBClients returns registered clients from the server database.
|
||||
// 协议命令: clientdblist start=X duration=X
|
||||
// start: 起始位置, duration: 返回数量(0=全部)
|
||||
func (c *Client) ListDBClients(start, duration int) ([]DBClient, error) {
|
||||
cmd := fmt.Sprintf("clientdblist start=%d duration=%d", start, duration)
|
||||
data, err := c.ExecCommandWithResponse(cmd, 10*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clients := make([]DBClient, 0, len(data))
|
||||
for _, item := range data {
|
||||
dbid, _ := parseUint64Value(item["cldbid"])
|
||||
created, _ := parseInt64Value(item["client_created"])
|
||||
lastConnected, _ := parseInt64Value(item["client_lastconnected"])
|
||||
totalConnections, _ := parseIntValue(item["client_totalconnections"])
|
||||
clients = append(clients, DBClient{
|
||||
DBID: dbid,
|
||||
UID: item["client_unique_identifier"],
|
||||
Nickname: commands.Unescape(item["client_nickname"]),
|
||||
Created: created,
|
||||
LastConnected: lastConnected,
|
||||
TotalConnections: totalConnections,
|
||||
Description: commands.Unescape(item["client_description"]),
|
||||
})
|
||||
}
|
||||
return clients, nil
|
||||
}
|
||||
|
||||
// UpdateSelf updates the current client's properties.
|
||||
// 协议命令: clientupdate [params...]
|
||||
// 常用参数:
|
||||
// - client_nickname: 新昵称
|
||||
// - client_away: "1"/"0"
|
||||
// - client_away_message: 离开消息
|
||||
// - client_input_muted: "1"/"0"
|
||||
// - client_output_muted: "1"/"0"
|
||||
// - client_phonetic_nickname: 语音昵称
|
||||
// - client_default_token: 默认 Token
|
||||
func (c *Client) UpdateSelf(properties map[string]string) error {
|
||||
if len(properties) == 0 {
|
||||
return nil
|
||||
}
|
||||
cmd := commands.BuildCommand("clientupdate", properties)
|
||||
return c.ExecCommand(cmd, 5*time.Second)
|
||||
}
|
||||
|
||||
// KickClient kicks a client from channel or server.
|
||||
// 协议命令: clientkick clid=X reasonid=X reasonmsg=X
|
||||
// reasonid: 4=从频道踢出, 5=从服务器踢出
|
||||
func (c *Client) KickClient(clid uint16, reasonID int, reasonMsg string) error {
|
||||
cmd := commands.BuildCommandOrdered("clientkick", [][2]string{
|
||||
{"clid", fmt.Sprintf("%d", clid)},
|
||||
{"reasonid", fmt.Sprintf("%d", reasonID)},
|
||||
{"reasonmsg", reasonMsg},
|
||||
})
|
||||
return c.ExecCommand(cmd, 5*time.Second)
|
||||
}
|
||||
|
||||
// ─── Ban 管理 ────────────────────────────────────────────
|
||||
|
||||
// ListBans returns all active ban entries.
|
||||
// 协议命令: banlist
|
||||
func (c *Client) ListBans() ([]BanEntry, error) {
|
||||
data, err := c.ExecCommandWithResponse("banlist", 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bans := make([]BanEntry, 0, len(data))
|
||||
for _, item := range data {
|
||||
banID, _ := parseInt64Value(item["banid"])
|
||||
created, _ := parseInt64Value(item["created"])
|
||||
bans = append(bans, BanEntry{
|
||||
BanID: banID,
|
||||
IP: item["ip"],
|
||||
Name: commands.Unescape(item["name"]),
|
||||
UID: item["uid"],
|
||||
Created: created,
|
||||
InvokerName: commands.Unescape(item["invokername"]),
|
||||
InvokerUID: item["invokeruid"],
|
||||
Reason: commands.Unescape(item["reason"]),
|
||||
Enforcement: parseBoolValue(item["enforcement"]),
|
||||
})
|
||||
}
|
||||
return bans, nil
|
||||
}
|
||||
|
||||
// AddBan adds a ban entry.
|
||||
// 协议命令: banadd ip=X name=X uid=X time=X banreason=X
|
||||
// ip/name/uid 至少指定一个;time 单位为秒,0=永久。
|
||||
func (c *Client) AddBan(ip, name, uid string, timeSeconds int, reason string) error {
|
||||
params := make([][2]string, 0, 5)
|
||||
if ip != "" {
|
||||
params = append(params, [2]string{"ip", ip})
|
||||
}
|
||||
if name != "" {
|
||||
params = append(params, [2]string{"name", name})
|
||||
}
|
||||
if uid != "" {
|
||||
params = append(params, [2]string{"uid", uid})
|
||||
}
|
||||
if timeSeconds > 0 {
|
||||
params = append(params, [2]string{"time", fmt.Sprintf("%d", timeSeconds)})
|
||||
}
|
||||
if reason != "" {
|
||||
params = append(params, [2]string{"banreason", reason})
|
||||
}
|
||||
cmd := commands.BuildCommandOrdered("banadd", params)
|
||||
return c.ExecCommand(cmd, 5*time.Second)
|
||||
}
|
||||
|
||||
// DeleteBan removes a ban entry.
|
||||
// 协议命令: bandel banid=X
|
||||
func (c *Client) DeleteBan(banID int64) error {
|
||||
cmd := fmt.Sprintf("bandel banid=%d", banID)
|
||||
return c.ExecCommand(cmd, 5*time.Second)
|
||||
}
|
||||
|
||||
// DeleteAllBans removes all ban entries.
|
||||
// 协议命令: bandelall
|
||||
func (c *Client) DeleteAllBans() error {
|
||||
return c.ExecCommand("bandelall", 5*time.Second)
|
||||
}
|
||||
|
||||
// ─── 文件列表 ────────────────────────────────────────────
|
||||
|
||||
// ListFiles returns files and directories in a channel path.
|
||||
// 协议命令: ftgetfilelist cid=X path=X
|
||||
// path 为频道内虚拟路径,根目录为 "/"。
|
||||
func (c *Client) ListFiles(channelID uint64, path string) ([]FileEntry, error) {
|
||||
cmd := commands.BuildCommandOrdered("ftgetfilelist", [][2]string{
|
||||
{"cid", fmt.Sprintf("%d", channelID)},
|
||||
{"path", path},
|
||||
})
|
||||
data, err := c.ExecCommandWithResponse(cmd, 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files := make([]FileEntry, 0, len(data))
|
||||
for _, item := range data {
|
||||
size, _ := parseUint64Value(item["size"])
|
||||
dt, _ := parseInt64Value(item["datetime"])
|
||||
files = append(files, FileEntry{
|
||||
Name: commands.Unescape(item["name"]),
|
||||
Size: size,
|
||||
DateTime: dt,
|
||||
IsFile: parseBoolValue(item["is_file"]),
|
||||
})
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// ─── Token 管理 ──────────────────────────────────────────
|
||||
|
||||
// ListTokens returns all privilege keys.
|
||||
// 协议命令: tokenlist
|
||||
func (c *Client) ListTokens() ([]TokenEntry, error) {
|
||||
data, err := c.ExecCommandWithResponse("tokenlist", 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokens := make([]TokenEntry, 0, len(data))
|
||||
for _, item := range data {
|
||||
tokenType, _ := parseIntValue(item["token_type"])
|
||||
tokenID1, _ := parseInt64Value(item["token_id1"])
|
||||
tokenID2, _ := parseInt64Value(item["token_id2"])
|
||||
created, _ := parseInt64Value(item["token_created"])
|
||||
tokens = append(tokens, TokenEntry{
|
||||
Token: item["token"],
|
||||
TokenType: tokenType,
|
||||
TokenID1: tokenID1,
|
||||
TokenID2: tokenID2,
|
||||
Created: created,
|
||||
Description: commands.Unescape(item["token_description"]),
|
||||
})
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// UseToken activates a privilege key.
|
||||
// 协议命令: tokenuse token=X
|
||||
func (c *Client) UseToken(token string) error {
|
||||
cmd := commands.BuildCommand("tokenuse", map[string]string{"token": token})
|
||||
return c.ExecCommand(cmd, 5*time.Second)
|
||||
}
|
||||
|
||||
// ─── 投诉管理 ────────────────────────────────────────────
|
||||
|
||||
// ListComplaints returns complaints against a target client.
|
||||
// 协议命令: complainlist tcldbid=X
|
||||
// 若 targetDBID=0 则返回全部投诉。
|
||||
func (c *Client) ListComplaints(targetDBID uint64) ([]ComplaintEntry, error) {
|
||||
cmd := "complainlist"
|
||||
if targetDBID > 0 {
|
||||
cmd = fmt.Sprintf("complainlist tcldbid=%d", targetDBID)
|
||||
}
|
||||
data, err := c.ExecCommandWithResponse(cmd, 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
complaints := make([]ComplaintEntry, 0, len(data))
|
||||
for _, item := range data {
|
||||
fromDBID, _ := parseUint64Value(item["fcldbid"])
|
||||
toDBID, _ := parseUint64Value(item["tcldbid"])
|
||||
ts, _ := parseInt64Value(item["timestamp"])
|
||||
complaints = append(complaints, ComplaintEntry{
|
||||
FromDBID: fromDBID,
|
||||
ToDBID: toDBID,
|
||||
Message: commands.Unescape(item["message"]),
|
||||
Timestamp: ts,
|
||||
})
|
||||
}
|
||||
return complaints, nil
|
||||
}
|
||||
|
||||
// AddComplaint files a complaint against a target client.
|
||||
// 协议命令: complainadd tcldbid=X message=X
|
||||
func (c *Client) AddComplaint(targetDBID uint64, message string) error {
|
||||
cmd := commands.BuildCommandOrdered("complainadd", [][2]string{
|
||||
{"tcldbid", fmt.Sprintf("%d", targetDBID)},
|
||||
{"message", message},
|
||||
})
|
||||
return c.ExecCommand(cmd, 5*time.Second)
|
||||
}
|
||||
|
||||
// DeleteComplaint removes a complaint.
|
||||
// 协议命令: complaindel tcldbid=X fcldbid=X
|
||||
func (c *Client) DeleteComplaint(targetDBID, fromDBID uint64) error {
|
||||
cmd := fmt.Sprintf("complaindel tcldbid=%d fcldbid=%d", targetDBID, fromDBID)
|
||||
return c.ExecCommand(cmd, 5*time.Second)
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package teamspeak
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/crypto"
|
||||
"github.com/honeybbq/teamspeak-go/discovery"
|
||||
"github.com/honeybbq/teamspeak-go/transport"
|
||||
)
|
||||
|
||||
var errAlreadyConnectingOrConnected = errors.New("already connecting or connected")
|
||||
|
||||
// ClientStatus represents the current connection state of the client.
|
||||
type ClientStatus int
|
||||
|
||||
const (
|
||||
StatusDisconnected ClientStatus = iota
|
||||
StatusConnecting
|
||||
StatusConnected
|
||||
)
|
||||
|
||||
// AddrResolver resolves a TeamSpeak server address to host:port endpoints.
|
||||
// Implementations may replace the default chain (SRV, TSDNS, direct).
|
||||
type AddrResolver interface {
|
||||
Resolve(ctx context.Context, addr string) ([]discovery.ResolvedAddr, error)
|
||||
}
|
||||
|
||||
// CommandMiddleware wraps the final command sender; it may alter or drop commands.
|
||||
type CommandMiddleware func(next func(string) error) func(string) error
|
||||
|
||||
// EventMiddleware wraps event dispatch; it may observe or replace notifications.
|
||||
type EventMiddleware func(next func(any)) func(any)
|
||||
|
||||
type clientInitOptions struct {
|
||||
serverPassword string
|
||||
defaultChannel string
|
||||
defaultChannelPassword string
|
||||
}
|
||||
|
||||
// Client is the TeamSpeak 3 client.
|
||||
type Client struct {
|
||||
resolver AddrResolver
|
||||
finalCmdHandler func(string) error
|
||||
crypt *crypto.Crypt
|
||||
connectedChan chan struct{}
|
||||
connectedErr error // handshake error stored before closing connectedChan
|
||||
evtQueue []any
|
||||
evtQueueMu sync.Mutex
|
||||
evtCond *sync.Cond
|
||||
evtDone chan struct{}
|
||||
ftTrack *fileTransferTracker
|
||||
logger *slog.Logger
|
||||
handler *transport.PacketHandler
|
||||
cmdTrack *commandTracker
|
||||
throttle *commandThrottle
|
||||
clients map[uint16]ClientInfo
|
||||
finalEvtHandler func(any)
|
||||
addr string
|
||||
nickname string
|
||||
clientInitOptions clientInitOptions
|
||||
textMsgHandlers []func(TextMessage)
|
||||
cmdMiddlewares []CommandMiddleware
|
||||
eventMiddlewares []EventMiddleware
|
||||
clientEnterHandlers []func(ClientInfo)
|
||||
clientLeaveHandlers []func(ClientLeftViewEvent)
|
||||
clientMoveHandlers []func(ClientMovedEvent)
|
||||
connectedHandlers []func()
|
||||
disconnectedHandlers []func(error)
|
||||
pokedHandlers []func(PokeEvent)
|
||||
kickedHandlers []func(string)
|
||||
voiceDataHandlers []func(VoiceDataEvent)
|
||||
status ClientStatus
|
||||
mu sync.Mutex
|
||||
clid uint16
|
||||
}
|
||||
|
||||
// NewClient creates a new TeamSpeak 3 client.
|
||||
func NewClient(identity *crypto.Identity, addr string, nickname string, options ...ClientOption) *Client {
|
||||
crypt := crypto.NewCrypt(identity)
|
||||
|
||||
c := &Client{
|
||||
crypt: crypt,
|
||||
status: StatusDisconnected,
|
||||
logger: slog.Default(),
|
||||
addr: addr,
|
||||
nickname: nickname,
|
||||
clients: make(map[uint16]ClientInfo),
|
||||
throttle: newCommandThrottle(),
|
||||
cmdTrack: newCommandTracker(),
|
||||
ftTrack: newFileTransferTracker(),
|
||||
connectedChan: make(chan struct{}),
|
||||
evtDone: make(chan struct{}),
|
||||
}
|
||||
|
||||
c.evtCond = sync.NewCond(&c.evtQueueMu)
|
||||
c.startEventLoop()
|
||||
|
||||
for _, opt := range options {
|
||||
opt(c)
|
||||
}
|
||||
|
||||
c.handler = transport.NewPacketHandler(c.crypt, c.logger)
|
||||
if c.resolver == nil {
|
||||
c.resolver = discovery.NewResolver(c.logger)
|
||||
}
|
||||
c.handler.OnPacket = c.handlePacket
|
||||
c.handler.OnClosed = c.handleConnectionClosed
|
||||
|
||||
c.rebuildMiddlewareChains()
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
type ClientOption func(*Client)
|
||||
|
||||
func WithLogger(logger *slog.Logger) ClientOption {
|
||||
return func(c *Client) {
|
||||
if logger != nil {
|
||||
c.logger = logger
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithResolver sets a custom address resolver used by Connect.
|
||||
func WithResolver(r AddrResolver) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.resolver = r
|
||||
}
|
||||
}
|
||||
|
||||
func WithCommandMiddleware(mw ...CommandMiddleware) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.cmdMiddlewares = append(c.cmdMiddlewares, mw...)
|
||||
}
|
||||
}
|
||||
|
||||
func WithEventMiddleware(mw ...EventMiddleware) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.eventMiddlewares = append(c.eventMiddlewares, mw...)
|
||||
}
|
||||
}
|
||||
|
||||
// WithServerPassword configures the server password sent during clientinit.
|
||||
func WithServerPassword(password string) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.clientInitOptions.serverPassword = password
|
||||
}
|
||||
}
|
||||
|
||||
// WithDefaultChannel configures the default channel requested during clientinit.
|
||||
func WithDefaultChannel(channel string) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.clientInitOptions.defaultChannel = channel
|
||||
}
|
||||
}
|
||||
|
||||
// WithDefaultChannelPassword configures the password for the default channel.
|
||||
func WithDefaultChannelPassword(password string) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.clientInitOptions.defaultChannelPassword = password
|
||||
}
|
||||
}
|
||||
|
||||
// Connect starts the UDP session and handshake to the server.
|
||||
func (c *Client) Connect() error {
|
||||
c.mu.Lock()
|
||||
if c.status != StatusDisconnected {
|
||||
c.mu.Unlock()
|
||||
|
||||
return errAlreadyConnectingOrConnected
|
||||
}
|
||||
|
||||
finalAddr := c.resetForConnectLocked()
|
||||
|
||||
c.status = StatusConnecting
|
||||
c.mu.Unlock()
|
||||
|
||||
targetAddr, source := c.resolveConnectTarget(finalAddr)
|
||||
c.logger.Info("connecting to server", slog.String("address", targetAddr), slog.String("source", source))
|
||||
|
||||
return c.handler.Connect(targetAddr)
|
||||
}
|
||||
|
||||
// Disconnect gracefully closes the connection.
|
||||
func (c *Client) Disconnect() error {
|
||||
c.mu.Lock()
|
||||
oldStatus := c.status
|
||||
if oldStatus == StatusDisconnected {
|
||||
c.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
connectErr := c.connectedErr
|
||||
c.status = StatusDisconnected
|
||||
handlers := c.disconnectedHandlers
|
||||
c.mu.Unlock()
|
||||
|
||||
c.logger.Info("disconnecting from server")
|
||||
|
||||
if oldStatus == StatusConnected {
|
||||
_ = c.ExecCommand("clientdisconnect reasonmsg=Shutdown", 1*time.Second)
|
||||
}
|
||||
|
||||
err := c.handler.Close()
|
||||
|
||||
// When disconnecting during the handshake phase, pass the stored handshake
|
||||
// error (e.g. wrong password, banned) so callbacks receive a useful message.
|
||||
handlerErr := error(nil)
|
||||
if oldStatus == StatusConnecting && connectErr != nil {
|
||||
handlerErr = connectErr
|
||||
}
|
||||
// Invoke disconnected handlers here; handleConnectionClosed skips them once
|
||||
// status is already Disconnected to avoid duplicate callbacks.
|
||||
for _, h := range handlers {
|
||||
go h(handlerErr)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) resetForConnectLocked() string {
|
||||
if c.handler != nil {
|
||||
_ = c.handler.Close()
|
||||
}
|
||||
identity := c.crypt.Identity
|
||||
c.crypt = crypto.NewCrypt(identity)
|
||||
c.handler = transport.NewPacketHandler(c.crypt, c.logger)
|
||||
c.handler.OnPacket = c.handlePacket
|
||||
c.handler.OnClosed = c.handleConnectionClosed
|
||||
c.connectedChan = make(chan struct{})
|
||||
// Note: connectedErr is NOT reset here so that handleConnectionClosed
|
||||
// can still access the handshake error when the server closes the
|
||||
// connection after a failed clientinit (e.g. wrong password).
|
||||
c.cmdTrack.reset()
|
||||
c.ftTrack.reset()
|
||||
c.clients = make(map[uint16]ClientInfo)
|
||||
c.clid = 0
|
||||
|
||||
finalAddr := c.addr
|
||||
if !strings.Contains(finalAddr, ":") {
|
||||
c.logger.Debug("no port specified, using default port 9987")
|
||||
finalAddr += ":9987"
|
||||
}
|
||||
|
||||
return finalAddr
|
||||
}
|
||||
|
||||
func (c *Client) resolveConnectTarget(fallbackAddr string) (string, string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resolved, err := c.resolver.Resolve(ctx, c.addr)
|
||||
if err != nil {
|
||||
c.logger.Warn("address resolution failed, falling back to direct", slog.Any("error", err))
|
||||
|
||||
return fallbackAddr, "Fallback"
|
||||
}
|
||||
|
||||
return resolved[0].Addr, resolved[0].Source
|
||||
}
|
||||
|
||||
func (c *Client) handleConnectionClosed(err error) {
|
||||
c.mu.Lock()
|
||||
if c.status == StatusDisconnected {
|
||||
c.mu.Unlock()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
wasConnecting := c.status == StatusConnecting
|
||||
connectErr := c.connectedErr
|
||||
c.status = StatusDisconnected
|
||||
handlers := c.disconnectedHandlers
|
||||
c.mu.Unlock()
|
||||
|
||||
// If we were still in the handshake phase, unblock WaitConnected so the
|
||||
// caller can observe the failure instead of blocking forever.
|
||||
if wasConnecting {
|
||||
c.signalConnected(err)
|
||||
}
|
||||
|
||||
// When the connection drops after a handshake error (e.g. wrong password),
|
||||
// the transport-level err is typically nil. Use the stored handshake error
|
||||
// so the disconnected handlers receive a meaningful message.
|
||||
handlerErr := err
|
||||
if handlerErr == nil && connectErr != nil {
|
||||
handlerErr = connectErr
|
||||
}
|
||||
|
||||
for _, h := range handlers {
|
||||
go h(handlerErr)
|
||||
}
|
||||
}
|
||||
|
||||
// signalConnected closes connectedChan (if not already closed) and stores err.
|
||||
// Safe to call from any goroutine; acquires c.mu internally.
|
||||
// A non-nil error never overwrites an existing non-nil error, so the original
|
||||
// handshake error (e.g. wrong password) is preserved even if a later transport
|
||||
// close also calls signalConnected with a nil or different error.
|
||||
func (c *Client) signalConnected(err error) {
|
||||
c.mu.Lock()
|
||||
c.logger.Info("signalConnected called",
|
||||
slog.Any("error", err),
|
||||
slog.Any("existingErr", c.connectedErr),
|
||||
slog.Int("status", int(c.status)))
|
||||
if err != nil || c.connectedErr == nil {
|
||||
c.connectedErr = err
|
||||
}
|
||||
select {
|
||||
case <-c.connectedChan:
|
||||
c.logger.Info("signalConnected: channel already closed")
|
||||
default:
|
||||
close(c.connectedChan)
|
||||
c.logger.Info("signalConnected: channel closed successfully")
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// startEventLoop 启动事件消费协程,保证所有事件在同一个协程中按顺序处理。
|
||||
// 消费协程的生命周期与 Client 相同(NewClient 时启动),
|
||||
// 通过 c.evtDone channel 控制退出。
|
||||
func (c *Client) startEventLoop() {
|
||||
go func() {
|
||||
for {
|
||||
// 等待队列非空或 done 信号
|
||||
c.evtQueueMu.Lock()
|
||||
for len(c.evtQueue) == 0 {
|
||||
select {
|
||||
case <-c.evtDone:
|
||||
c.evtQueueMu.Unlock()
|
||||
return
|
||||
default:
|
||||
}
|
||||
c.evtCond.Wait()
|
||||
}
|
||||
// 取出所有待处理事件
|
||||
events := make([]any, len(c.evtQueue))
|
||||
copy(events, c.evtQueue)
|
||||
c.evtQueue = nil
|
||||
c.evtQueueMu.Unlock()
|
||||
|
||||
// 在无锁状态下逐个分发事件
|
||||
for _, evt := range events {
|
||||
c.dispatchEvent(evt)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// notifyEvent 将事件放入队列,由事件消费协程顺序处理。
|
||||
// 线程安全,可从任何 goroutine 调用。
|
||||
func (c *Client) notifyEvent(evt any) {
|
||||
c.evtQueueMu.Lock()
|
||||
c.evtQueue = append(c.evtQueue, evt)
|
||||
c.evtQueueMu.Unlock()
|
||||
c.evtCond.Signal()
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package teamspeak
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/commands"
|
||||
"github.com/honeybbq/teamspeak-go/transport"
|
||||
)
|
||||
|
||||
func TestHandleCommandLines_Empty(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
// Must not panic.
|
||||
c.handleCommandLines("")
|
||||
}
|
||||
|
||||
func TestHandleCommandLines_NewlineSeparated(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
// Two lines: an error line with a return_code + one notify.
|
||||
// We only care that neither path panics and notify fires.
|
||||
entered := make(chan struct{}, 2)
|
||||
c.OnClientEnter(func(_ ClientInfo) { entered <- struct{}{} })
|
||||
c.rebuildMiddlewareChains()
|
||||
|
||||
line1 := "notifycliententerview clid=1 client_nickname=A" +
|
||||
" cid=1 client_type=0 client_servergroups= client_unique_identifier=x"
|
||||
line2 := "notifycliententerview clid=2 client_nickname=B" +
|
||||
" cid=1 client_type=0 client_servergroups= client_unique_identifier=y"
|
||||
c.handleCommandLines(line1 + "\n" + line2)
|
||||
|
||||
for i := range 2 {
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(time.Second):
|
||||
t.Errorf("notify not fired for line %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCommandLines_NullByteSeparated(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
entered := make(chan struct{}, 1)
|
||||
c.OnClientEnter(func(_ ClientInfo) { entered <- struct{}{} })
|
||||
c.rebuildMiddlewareChains()
|
||||
|
||||
line := "notifycliententerview clid=3 client_nickname=C" +
|
||||
" cid=1 client_type=0 client_servergroups= client_unique_identifier=z"
|
||||
c.handleCommandLines(line + "\x00")
|
||||
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("notify not fired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCommand_Notify_Routed(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
entered := make(chan struct{}, 1)
|
||||
c.OnClientEnter(func(_ ClientInfo) { entered <- struct{}{} })
|
||||
c.rebuildMiddlewareChains()
|
||||
|
||||
line := "notifycliententerview clid=4 client_nickname=D" +
|
||||
" cid=1 client_type=0 client_servergroups= client_unique_identifier=d"
|
||||
c.handleCommand(line)
|
||||
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("notification not routed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCommand_Error_NoReturnCode_NoResolve(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
rc, ch := c.cmdTrack.register()
|
||||
|
||||
// error without a matching return_code should not resolve our tracker.
|
||||
c.handleCommand("error id=1 msg=fail")
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
t.Error("should not resolve for error without matching return_code")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
c.cmdTrack.unregister(rc)
|
||||
}
|
||||
|
||||
func TestHandleCommand_Error_WithReturnCode_Resolves(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
rc, ch := c.cmdTrack.register()
|
||||
|
||||
c.handleCommand("error id=0 msg=ok return_code=" + mustUint32Str(rc))
|
||||
|
||||
select {
|
||||
case result := <-ch:
|
||||
if result.Err != nil {
|
||||
t.Errorf("expected nil error, got %v", result.Err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("command not resolved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCommand_Error_NonZeroID_ReturnsError(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
rc, ch := c.cmdTrack.register()
|
||||
|
||||
c.handleCommand("error id=256 msg=notfound return_code=" + mustUint32Str(rc))
|
||||
|
||||
select {
|
||||
case result := <-ch:
|
||||
if result.Err == nil {
|
||||
t.Error("expected non-nil error for id=256")
|
||||
}
|
||||
if !strings.Contains(result.Err.Error(), "notfound") {
|
||||
t.Errorf("unexpected error: %v", result.Err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("command not resolved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCommand_DataCollectedBeforeError(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
rc, ch := c.cmdTrack.register()
|
||||
|
||||
// In the real TeamSpeak flow, data rows arrive before the "error" response.
|
||||
c.handleCommand("somedata key=val")
|
||||
c.handleCommand("error id=0 msg=ok return_code=" + mustUint32Str(rc))
|
||||
|
||||
select {
|
||||
case result := <-ch:
|
||||
if len(result.Data) != 1 || result.Data[0]["key"] != "val" {
|
||||
t.Errorf("unexpected data: %v", result.Data)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("not resolved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCommand_UnknownCommand_CollectsAsData(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
rc, ch := c.cmdTrack.register()
|
||||
|
||||
c.handleCommand("unknowncmd foo=bar baz=qux")
|
||||
c.handleCommand("error id=0 msg=ok return_code=" + mustUint32Str(rc))
|
||||
|
||||
select {
|
||||
case result := <-ch:
|
||||
if len(result.Data) != 1 {
|
||||
t.Fatalf("expected 1 data row, got %d", len(result.Data))
|
||||
}
|
||||
if result.Data[0]["foo"] != "bar" {
|
||||
t.Errorf("unexpected foo: %q", result.Data[0]["foo"])
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("not resolved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePacket_VoiceMetadata(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
received := make(chan VoiceDataEvent, 2)
|
||||
c.OnVoiceData(func(evt VoiceDataEvent) { received <- evt })
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
packetType transport.PacketType
|
||||
isWhisper bool
|
||||
}{
|
||||
{name: "channel voice", packetType: transport.PacketTypeVoice},
|
||||
{name: "whisper", packetType: transport.PacketTypeVoiceWhisper, isWhisper: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
c.handlePacket(&transport.Packet{
|
||||
TypeFlagged: byte(test.packetType),
|
||||
ID: 0x1234,
|
||||
Data: []byte{0xab, 0xcd, 0x01, 0x02, 4, 0xaa, 0xbb},
|
||||
})
|
||||
|
||||
select {
|
||||
case evt := <-received:
|
||||
if evt.ClientID != 0x0102 || evt.Codec != 4 {
|
||||
t.Errorf("unexpected voice source: clientID=%d codec=%d", evt.ClientID, evt.Codec)
|
||||
}
|
||||
if evt.Sequence != 0xabcd {
|
||||
t.Errorf("sequence = %#x, want payload packet ID %#x", evt.Sequence, uint16(0xabcd))
|
||||
}
|
||||
if evt.IsWhisper != test.isWhisper {
|
||||
t.Errorf("IsWhisper = %t, want %t", evt.IsWhisper, test.isWhisper)
|
||||
}
|
||||
if string(evt.Data) != string([]byte{0xaa, 0xbb}) {
|
||||
t.Errorf("opus data = %x, want aabb", evt.Data)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("voice event not received")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePacket_VoiceSequencesArePerSpeaker(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
received := make(chan VoiceDataEvent, 4)
|
||||
c.OnVoiceData(func(evt VoiceDataEvent) { received <- evt })
|
||||
|
||||
packets := []*transport.Packet{
|
||||
{TypeFlagged: byte(transport.PacketTypeVoice), ID: 700, Data: []byte{0, 100, 0, 1, 4, 0xa0}},
|
||||
{TypeFlagged: byte(transport.PacketTypeVoice), ID: 701, Data: []byte{1, 244, 0, 2, 4, 0xb0}},
|
||||
{TypeFlagged: byte(transport.PacketTypeVoice), ID: 702, Data: []byte{0, 101, 0, 1, 4, 0xa1}},
|
||||
{TypeFlagged: byte(transport.PacketTypeVoice), ID: 703, Data: []byte{1, 245, 0, 2, 4, 0xb1}},
|
||||
}
|
||||
for _, packet := range packets {
|
||||
c.handlePacket(packet)
|
||||
}
|
||||
|
||||
sequences := map[uint16][]uint16{}
|
||||
for range packets {
|
||||
select {
|
||||
case evt := <-received:
|
||||
sequences[evt.ClientID] = append(sequences[evt.ClientID], evt.Sequence)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("voice event not received")
|
||||
}
|
||||
}
|
||||
|
||||
if got := sequences[1]; len(got) != 2 || got[0] != 100 || got[1] != 101 {
|
||||
t.Errorf("client 1 sequences = %v, want [100 101]", got)
|
||||
}
|
||||
if got := sequences[2]; len(got) != 2 || got[0] != 500 || got[1] != 501 {
|
||||
t.Errorf("client 2 sequences = %v, want [500 501]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecCommandWithResponse_Success(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
go func() {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
// The command sent by ExecCommandWithResponse contains "return_code=N".
|
||||
// Simulate server data row then ok error line with matching return_code.
|
||||
c.handleCommandLines("rowdata somekey=someval\nerror id=0 msg=ok return_code=1")
|
||||
}()
|
||||
|
||||
data, err := c.ExecCommandWithResponse("dummycmd", 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(data) != 1 || data[0]["somekey"] != "someval" {
|
||||
t.Errorf("unexpected data: %v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecCommandWithResponse_Timeout(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
start := time.Now()
|
||||
_, err := c.ExecCommandWithResponse("dummycmd", 50*time.Millisecond)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected timeout error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "timeout") {
|
||||
t.Errorf("expected 'timeout' in error, got %v", err)
|
||||
}
|
||||
if elapsed < 40*time.Millisecond || elapsed > 500*time.Millisecond {
|
||||
t.Errorf("unexpected elapsed time: %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecCommandWithResponse_ServerError(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
go func() {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
c.handleCommandLines("error id=512 msg=invalid_size return_code=1")
|
||||
}()
|
||||
|
||||
_, err := c.ExecCommandWithResponse("dummycmd", 2*time.Second)
|
||||
if err == nil {
|
||||
t.Error("expected error from server")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid_size") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustUint32Str(rc uint32) string {
|
||||
return commands.ParseCommand("x return_code=" + itoa(rc)).Params["return_code"]
|
||||
}
|
||||
|
||||
func itoa(n uint32) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
buf := make([]byte, 0, 10)
|
||||
for n > 0 {
|
||||
buf = append([]byte{byte('0' + n%10)}, buf...)
|
||||
n /= 10
|
||||
}
|
||||
|
||||
return string(buf)
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package teamspeak
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/commands"
|
||||
"github.com/honeybbq/teamspeak-go/handshake"
|
||||
"github.com/honeybbq/teamspeak-go/transport"
|
||||
)
|
||||
|
||||
var (
|
||||
errTeamSpeakCommand = errors.New("TeamSpeak server error")
|
||||
errCommandTimed = errors.New("command timeout")
|
||||
voicePacketCount uint64
|
||||
)
|
||||
|
||||
type commandResult struct {
|
||||
Err error
|
||||
Data []map[string]string
|
||||
}
|
||||
|
||||
// commandTracker matches return_code values to pending commands and response rows.
|
||||
type commandTracker struct {
|
||||
pending map[uint32]chan commandResult
|
||||
collecting map[uint32][]map[string]string
|
||||
mu sync.Mutex
|
||||
nextRC uint32
|
||||
}
|
||||
|
||||
func newCommandTracker() *commandTracker {
|
||||
return &commandTracker{
|
||||
pending: make(map[uint32]chan commandResult),
|
||||
collecting: make(map[uint32][]map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *commandTracker) register() (uint32, <-chan commandResult) {
|
||||
rc := atomic.AddUint32(&t.nextRC, 1)
|
||||
ch := make(chan commandResult, 1)
|
||||
t.mu.Lock()
|
||||
t.pending[rc] = ch
|
||||
t.mu.Unlock()
|
||||
|
||||
return rc, ch
|
||||
}
|
||||
|
||||
func (t *commandTracker) unregister(rc uint32) {
|
||||
t.mu.Lock()
|
||||
delete(t.pending, rc)
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// collect appends a parameter row to the pending command with the largest return_code.
|
||||
func (t *commandTracker) collect(params map[string]string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
var maxRC uint32
|
||||
for rc := range t.pending {
|
||||
if rc > maxRC {
|
||||
maxRC = rc
|
||||
}
|
||||
}
|
||||
if maxRC > 0 {
|
||||
t.collecting[maxRC] = append(t.collecting[maxRC], params)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *commandTracker) resolve(rc uint32, err error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if ch, ok := t.pending[rc]; ok {
|
||||
data := t.collecting[rc]
|
||||
delete(t.collecting, rc)
|
||||
ch <- commandResult{Data: data, Err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *commandTracker) reset() {
|
||||
t.mu.Lock()
|
||||
t.pending = make(map[uint32]chan commandResult)
|
||||
t.collecting = make(map[uint32][]map[string]string)
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Client) handlePacket(p *transport.Packet) {
|
||||
c.logger.Debug("received packet", slog.Uint64("type", uint64(p.Type())), slog.Int("length", len(p.Data)))
|
||||
switch p.Type() {
|
||||
case transport.PacketTypeInit1:
|
||||
c.logger.Debug("processing init1 packet")
|
||||
response := handshake.ProcessInit1(c.crypt, p.Data)
|
||||
if response != nil {
|
||||
c.logger.Debug("sending init1 response")
|
||||
err := c.handler.SendPacket(byte(transport.PacketTypeInit1), response, 0)
|
||||
if err != nil {
|
||||
c.logger.Warn("failed to send init1 response", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
case transport.PacketTypeCommand, transport.PacketTypeCommandLow:
|
||||
if len(p.Data) == 0 {
|
||||
return
|
||||
}
|
||||
dataStr := string(p.Data)
|
||||
c.logger.Debug("received command data", slog.String("data", dataStr))
|
||||
c.handleCommandLines(dataStr)
|
||||
case transport.PacketTypeVoice, transport.PacketTypeVoiceWhisper:
|
||||
// Voice payload format: [packetID(2 BE)] [clientID(2 BE)] [codec(1)] [opusData...].
|
||||
if len(p.Data) >= 5 {
|
||||
packetID := uint16(p.Data[0])<<8 | uint16(p.Data[1])
|
||||
clientID := uint16(p.Data[2])<<8 | uint16(p.Data[3])
|
||||
codec := p.Data[4]
|
||||
count := atomic.AddUint64(&voicePacketCount, 1)
|
||||
if count <= 5 || count%250 == 0 {
|
||||
log.Printf("[TSVoice] packet count=%d client=%d codec=%d sequence=%d payload=%d whisper=%t", count, clientID, codec, packetID, len(p.Data)-5, p.Type() == transport.PacketTypeVoiceWhisper)
|
||||
}
|
||||
opusData := make([]byte, len(p.Data)-5)
|
||||
copy(opusData, p.Data[5:])
|
||||
c.notifyEvent(VoiceDataEvent{
|
||||
ClientID: clientID,
|
||||
Data: opusData,
|
||||
Codec: codec,
|
||||
Sequence: packetID,
|
||||
IsWhisper: p.Type() == transport.PacketTypeVoiceWhisper,
|
||||
})
|
||||
}
|
||||
case transport.PacketTypePing, transport.PacketTypePong,
|
||||
transport.PacketTypeAck, transport.PacketTypeAckLow:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) handleCommandLines(s string) {
|
||||
if s == "" {
|
||||
return
|
||||
}
|
||||
start := 0
|
||||
for i := 0; i <= len(s); i++ {
|
||||
if i == len(s) || s[i] == '\n' || s[i] == 0x00 {
|
||||
part := strings.TrimSuffix(s[start:i], "\r")
|
||||
if part != "" {
|
||||
rows := splitCommandRows(part)
|
||||
for _, row := range rows {
|
||||
c.handleCommand(row)
|
||||
}
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) handleCommand(s string) {
|
||||
cmd := commands.ParseCommand(s)
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Nameless rows are the data rows of list responses (clientlist,
|
||||
// channellist), which arrive as bare key=value pairs. Collect them for the
|
||||
// pending command instead of dropping them.
|
||||
if cmd.Name == "" {
|
||||
if len(cmd.Params) > 0 {
|
||||
c.cmdTrack.collect(cmd.Params)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c.logger.Debug("processing command", slog.String("name", cmd.Name), slog.Any("params", cmd.Params))
|
||||
|
||||
if strings.HasPrefix(cmd.Name, "notify") {
|
||||
c.handleNotification(cmd)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
switch cmd.Name {
|
||||
case "clientinitiv":
|
||||
c.handleHandshakeInitIV(cmd)
|
||||
case "initivexpand2":
|
||||
c.handleHandshakeExpand2(cmd)
|
||||
case "initserver":
|
||||
c.handleInitServer(cmd)
|
||||
case "error":
|
||||
c.handleError(cmd)
|
||||
default:
|
||||
c.cmdTrack.collect(cmd.Params)
|
||||
c.logger.Debug("unhandled or data command", slog.String("name", cmd.Name), slog.Any("params", cmd.Params))
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) handleError(cmd *commands.Command) {
|
||||
id := cmd.Params["id"]
|
||||
msg := cmd.Params["msg"]
|
||||
rcStr := cmd.Params["return_code"]
|
||||
|
||||
var err error
|
||||
if id != "0" {
|
||||
err = fmt.Errorf("%w: %s (id=%s)", errTeamSpeakCommand, msg, id)
|
||||
c.logger.Error("server returned error", slog.String("id", id), slog.String("message", msg))
|
||||
|
||||
// Fatal server errors (e.g. banned) during the handshake are handled
|
||||
// below via signalConnected + transport close. The separate
|
||||
// go c.Disconnect() is only needed when already fully connected.
|
||||
if id == "3329" {
|
||||
c.mu.Lock()
|
||||
isConnecting := c.status == StatusConnecting
|
||||
c.mu.Unlock()
|
||||
if !isConnecting {
|
||||
c.logger.Warn("fatal connection error detected, closing connection", slog.String("id", id))
|
||||
go func() {
|
||||
disconnectErr := c.Disconnect()
|
||||
if disconnectErr != nil {
|
||||
c.logger.Warn("disconnect after fatal error failed", slog.Any("error", disconnectErr))
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handshake-phase errors (e.g. password error 1028, banned 3329) arrive
|
||||
// without a return_code. When we are still in the connecting phase,
|
||||
// store the error, unblock WaitConnected, and close the transport so
|
||||
// the connection is cleaned up promptly.
|
||||
if rcStr == "" && id != "0" {
|
||||
c.mu.Lock()
|
||||
isConnecting := c.status == StatusConnecting
|
||||
c.mu.Unlock()
|
||||
if isConnecting {
|
||||
c.logger.Info("handshake error detected, unblocking WaitConnected",
|
||||
slog.String("id", id), slog.String("message", msg))
|
||||
c.signalConnected(err)
|
||||
// Close transport to trigger handleConnectionClosed and clean up.
|
||||
// handleConnectionClosed will fire disconnected handlers with the
|
||||
// stored handshake error.
|
||||
go func() {
|
||||
if closeErr := c.handler.Close(); closeErr != nil {
|
||||
c.logger.Warn("transport close after handshake error failed", slog.Any("error", closeErr))
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
if rcStr != "" {
|
||||
rc, parseErr := strconv.ParseUint(rcStr, 10, 32)
|
||||
if parseErr == nil {
|
||||
c.cmdTrack.resolve(uint32(rc), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendCommandNoWait sends a command without waiting for return_code.
|
||||
func (c *Client) SendCommandNoWait(cmd string) error {
|
||||
err := c.throttle.wait(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.logger.Debug("sending command without waiting", slog.String("raw", cmd))
|
||||
|
||||
return c.finalCmdHandler(cmd)
|
||||
}
|
||||
|
||||
// ExecCommand sends a command and waits for its return_code response.
|
||||
func (c *Client) ExecCommand(cmd string, timeout time.Duration) error {
|
||||
_, err := c.ExecCommandWithResponse(cmd, timeout)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ExecCommandWithResponse sends a command and waits for its return_code response and data.
|
||||
func (c *Client) ExecCommandWithResponse(cmd string, timeout time.Duration) ([]map[string]string, error) {
|
||||
rc, ch := c.cmdTrack.register()
|
||||
defer c.cmdTrack.unregister(rc)
|
||||
|
||||
withReturnCode := cmd
|
||||
if !strings.Contains(cmd, "return_code=") {
|
||||
withReturnCode = fmt.Sprintf("%s return_code=%d", cmd, rc)
|
||||
}
|
||||
|
||||
c.logger.Debug("sending command", slog.String("raw", withReturnCode))
|
||||
|
||||
err := c.throttle.wait(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = c.finalCmdHandler(withReturnCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
select {
|
||||
case res := <-ch:
|
||||
return res.Data, res.Err
|
||||
case <-time.After(timeout):
|
||||
return nil, fmt.Errorf("%w: %s", errCommandTimed, cmd)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Command represents a parsed or built TS3 command line.
|
||||
type Command struct {
|
||||
Params map[string]string
|
||||
Name string
|
||||
}
|
||||
|
||||
// Build returns the string representation of the command.
|
||||
func (c *Command) Build() string {
|
||||
return BuildCommand(c.Name, c.Params)
|
||||
}
|
||||
|
||||
var escaper = strings.NewReplacer(
|
||||
"\\", "\\\\",
|
||||
"/", "\\/",
|
||||
" ", "\\s",
|
||||
"|", "\\p",
|
||||
"\a", "\\a",
|
||||
"\b", "\\b",
|
||||
"\f", "\\f",
|
||||
"\n", "\\n",
|
||||
"\r", "\\r",
|
||||
"\t", "\\t",
|
||||
"\v", "\\v",
|
||||
)
|
||||
|
||||
func Escape(s string) string {
|
||||
return escaper.Replace(s)
|
||||
}
|
||||
|
||||
func BuildCommand(cmd string, params map[string]string) string {
|
||||
var res strings.Builder
|
||||
res.WriteString(Escape(cmd))
|
||||
for k, v := range params {
|
||||
res.WriteByte(' ')
|
||||
res.WriteString(k)
|
||||
res.WriteByte('=')
|
||||
res.WriteString(Escape(v))
|
||||
}
|
||||
|
||||
return res.String()
|
||||
}
|
||||
|
||||
func BuildCommandOrdered(cmd string, params [][2]string) string {
|
||||
var res strings.Builder
|
||||
res.WriteString(Escape(cmd))
|
||||
for _, kv := range params {
|
||||
res.WriteByte(' ')
|
||||
res.WriteString(kv[0])
|
||||
res.WriteByte('=')
|
||||
res.WriteString(Escape(kv[1]))
|
||||
}
|
||||
|
||||
return res.String()
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ParseCommand(s string) *Command {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
startIndex := 0
|
||||
for i := range len(s) {
|
||||
if s[i] >= 32 && s[i] <= 126 {
|
||||
startIndex = i
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
if startIndex > 0 {
|
||||
s = s[startIndex:]
|
||||
}
|
||||
|
||||
parts := strings.Split(s, " ")
|
||||
if len(parts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := &Command{
|
||||
Params: make(map[string]string),
|
||||
}
|
||||
|
||||
// TS3 list responses (clientlist, channellist) arrive as nameless rows of
|
||||
// key=value pairs. Only treat the first token as a command name when it is
|
||||
// NOT a key=value pair; otherwise every token is a parameter.
|
||||
start := 0
|
||||
if !strings.Contains(parts[0], "=") {
|
||||
cmd.Name = parts[0]
|
||||
start = 1
|
||||
}
|
||||
|
||||
for _, p := range parts[start:] {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
kv := strings.SplitN(p, "=", 2)
|
||||
if len(kv) == 2 {
|
||||
cmd.Params[Unescape(kv[0])] = Unescape(kv[1])
|
||||
} else {
|
||||
cmd.Params[Unescape(p)] = ""
|
||||
}
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
var unescaper = strings.NewReplacer(
|
||||
"\\\\", "\\",
|
||||
"\\/", "/",
|
||||
"\\s", " ",
|
||||
"\\p", "|",
|
||||
"\\a", "\a",
|
||||
"\\b", "\b",
|
||||
"\\f", "\f",
|
||||
"\\n", "\n",
|
||||
"\\r", "\r",
|
||||
"\\t", "\t",
|
||||
"\\v", "\v",
|
||||
)
|
||||
|
||||
func Unescape(s string) string {
|
||||
return unescaper.Replace(s)
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package commands_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/commands"
|
||||
)
|
||||
|
||||
const (
|
||||
cmdClientlist = "clientlist"
|
||||
strHelloWorld = "hello world"
|
||||
)
|
||||
|
||||
func TestEscapeSpecialChars(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"", ""},
|
||||
{"hello", "hello"},
|
||||
{strHelloWorld, "hello\\sworld"},
|
||||
{"a\\b", "a\\\\b"},
|
||||
{"a/b", "a\\/b"},
|
||||
{"a|b", "a\\pb"},
|
||||
{"a\ab", "a\\ab"},
|
||||
{"a\bb", "a\\bb"},
|
||||
{"a\fb", "a\\fb"},
|
||||
{"a\nb", "a\\nb"},
|
||||
{"a\rb", "a\\rb"},
|
||||
{"a\tb", "a\\tb"},
|
||||
{"a\vb", "a\\vb"},
|
||||
{"back\\slash /slash |pipe", "back\\\\slash\\s\\/slash\\s\\ppipe"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := commands.Escape(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("Escape(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnescapeSpecialChars(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"", ""},
|
||||
{"hello", "hello"},
|
||||
{"hello\\sworld", strHelloWorld},
|
||||
{"a\\\\b", "a\\b"},
|
||||
{"a\\/b", "a/b"},
|
||||
{"a\\pb", "a|b"},
|
||||
{"a\\ab", "a\ab"},
|
||||
{"a\\bb", "a\bb"},
|
||||
{"a\\fb", "a\fb"},
|
||||
{"a\\nb", "a\nb"},
|
||||
{"a\\rb", "a\rb"},
|
||||
{"a\\tb", "a\tb"},
|
||||
{"a\\vb", "a\vb"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := commands.Unescape(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("Unescape(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapeUnescapeRoundtrip(t *testing.T) {
|
||||
inputs := []string{
|
||||
strHelloWorld,
|
||||
"back\\slash",
|
||||
"pipe|val",
|
||||
"slash/val",
|
||||
"all\\ \t\n\r",
|
||||
"",
|
||||
"plain text",
|
||||
"mix\\ed /chars|here",
|
||||
}
|
||||
for _, input := range inputs {
|
||||
got := commands.Unescape(commands.Escape(input))
|
||||
if got != input {
|
||||
t.Errorf("Unescape(Escape(%q)) = %q", input, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommandEmpty(t *testing.T) {
|
||||
if commands.ParseCommand("") != nil {
|
||||
t.Error("expected nil for empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommandNameOnly(t *testing.T) {
|
||||
cmd := commands.ParseCommand(cmdClientlist)
|
||||
if cmd == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if cmd.Name != cmdClientlist {
|
||||
t.Errorf("Name = %q, want %q", cmd.Name, cmdClientlist)
|
||||
}
|
||||
if len(cmd.Params) != 0 {
|
||||
t.Errorf("expected 0 params, got %d", len(cmd.Params))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommandWithParams(t *testing.T) {
|
||||
cmd := commands.ParseCommand("notifytextmessage targetmode=3 msg=hello\\sworld invokerid=42")
|
||||
if cmd == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if cmd.Name != "notifytextmessage" {
|
||||
t.Errorf("Name = %q", cmd.Name)
|
||||
}
|
||||
if cmd.Params["targetmode"] != "3" {
|
||||
t.Errorf("targetmode = %q", cmd.Params["targetmode"])
|
||||
}
|
||||
if cmd.Params["msg"] != strHelloWorld {
|
||||
t.Errorf("msg = %q", cmd.Params["msg"])
|
||||
}
|
||||
if cmd.Params["invokerid"] != "42" {
|
||||
t.Errorf("invokerid = %q", cmd.Params["invokerid"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommandFlagParam(t *testing.T) {
|
||||
cmd := commands.ParseCommand("serverinfo -virtualserver_flag")
|
||||
if cmd == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if _, ok := cmd.Params["-virtualserver_flag"]; !ok {
|
||||
t.Error("expected flag param to exist")
|
||||
}
|
||||
if cmd.Params["-virtualserver_flag"] != "" {
|
||||
t.Errorf("flag param value should be empty, got %q", cmd.Params["-virtualserver_flag"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommandNamelessDataRow(t *testing.T) {
|
||||
// TS3 list responses (clientlist/channellist) arrive as nameless rows
|
||||
// beginning with a key=value pair. The first field must NOT be dropped as
|
||||
// a command name, otherwise the leading field (e.g. clid) is lost.
|
||||
cmd := commands.ParseCommand("clid=5761 cid=1 client_nickname=30k")
|
||||
if cmd == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if cmd.Name != "" {
|
||||
t.Errorf("Name = %q, want empty for nameless data row", cmd.Name)
|
||||
}
|
||||
if cmd.Params["clid"] != "5761" {
|
||||
t.Errorf("clid = %q, want 5761 (leading field must not be dropped)", cmd.Params["clid"])
|
||||
}
|
||||
if cmd.Params["cid"] != "1" {
|
||||
t.Errorf("cid = %q, want 1", cmd.Params["cid"])
|
||||
}
|
||||
if cmd.Params["client_nickname"] != "30k" {
|
||||
t.Errorf("client_nickname = %q, want 30k", cmd.Params["client_nickname"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommandLeadingNonPrintable(t *testing.T) {
|
||||
input := "\x00\x01\x02" + cmdClientlist + " clid=1"
|
||||
cmd := commands.ParseCommand(input)
|
||||
if cmd == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if cmd.Name != cmdClientlist {
|
||||
t.Errorf("Name = %q, want %s", cmd.Name, cmdClientlist)
|
||||
}
|
||||
if cmd.Params["clid"] != "1" {
|
||||
t.Errorf("clid = %q", cmd.Params["clid"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommandEscapedValues(t *testing.T) {
|
||||
cmd := commands.ParseCommand("test key=hello\\sworld pipe=a\\pb slash=a\\/b")
|
||||
if cmd == nil {
|
||||
t.Fatal("expected non-nil")
|
||||
}
|
||||
if cmd.Params["key"] != strHelloWorld {
|
||||
t.Errorf("key = %q", cmd.Params["key"])
|
||||
}
|
||||
if cmd.Params["pipe"] != "a|b" {
|
||||
t.Errorf("pipe = %q", cmd.Params["pipe"])
|
||||
}
|
||||
if cmd.Params["slash"] != "a/b" {
|
||||
t.Errorf("slash = %q", cmd.Params["slash"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommandNoParams(t *testing.T) {
|
||||
result := commands.BuildCommand(cmdClientlist, nil)
|
||||
if result != cmdClientlist {
|
||||
t.Errorf("BuildCommand = %q, want %q", result, cmdClientlist)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommandEscapesValues(t *testing.T) {
|
||||
result := commands.BuildCommand("sendtextmessage", map[string]string{
|
||||
"msg": strHelloWorld,
|
||||
})
|
||||
if !strings.Contains(result, "msg=hello\\sworld") {
|
||||
t.Errorf("BuildCommand missing escaped value: %q", result)
|
||||
}
|
||||
if !strings.HasPrefix(result, "sendtextmessage") {
|
||||
t.Errorf("BuildCommand missing command name: %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommandOrderedPreservesOrder(t *testing.T) {
|
||||
result := commands.BuildCommandOrdered("clientinitiv", [][2]string{
|
||||
{"alpha", "aaa"},
|
||||
{"omega", "bbb"},
|
||||
{"ot", "1"},
|
||||
{"ip", ""},
|
||||
})
|
||||
expected := "clientinitiv alpha=aaa omega=bbb ot=1 ip="
|
||||
if result != expected {
|
||||
t.Errorf("BuildCommandOrdered = %q, want %q", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCommandOrderedEscapesValues(t *testing.T) {
|
||||
result := commands.BuildCommandOrdered("cmd", [][2]string{
|
||||
{"key", "a b|c"},
|
||||
})
|
||||
if !strings.Contains(result, "key=a\\sb\\pc") {
|
||||
t.Errorf("BuildCommandOrdered missing escaped: %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandBuildMethod(t *testing.T) {
|
||||
cmd := &commands.Command{
|
||||
Name: "sendtextmessage",
|
||||
Params: map[string]string{"msg": "hi"},
|
||||
}
|
||||
result := cmd.Build()
|
||||
if !strings.HasPrefix(result, "sendtextmessage") {
|
||||
t.Errorf("Build() = %q, missing command name", result)
|
||||
}
|
||||
if !strings.Contains(result, "msg=hi") {
|
||||
t.Errorf("Build() = %q, missing param", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBuildRoundtrip(t *testing.T) {
|
||||
original := commands.BuildCommandOrdered("test", [][2]string{
|
||||
{"key1", "val1"},
|
||||
{"key2", strHelloWorld},
|
||||
{"key3", "pipe|val"},
|
||||
{"key4", "back\\slash"},
|
||||
})
|
||||
cmd := commands.ParseCommand(original)
|
||||
if cmd == nil {
|
||||
t.Fatal("ParseCommand returned nil")
|
||||
}
|
||||
if cmd.Name != "test" {
|
||||
t.Errorf("Name = %q", cmd.Name)
|
||||
}
|
||||
if cmd.Params["key1"] != "val1" {
|
||||
t.Errorf("key1 = %q", cmd.Params["key1"])
|
||||
}
|
||||
if cmd.Params["key2"] != strHelloWorld {
|
||||
t.Errorf("key2 = %q", cmd.Params["key2"])
|
||||
}
|
||||
if cmd.Params["key3"] != "pipe|val" {
|
||||
t.Errorf("key3 = %q", cmd.Params["key3"])
|
||||
}
|
||||
if cmd.Params["key4"] != "back\\slash" {
|
||||
t.Errorf("key4 = %q", cmd.Params["key4"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/sha512"
|
||||
"encoding/asn1"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
errInvalidIdentityFormat = errors.New("invalid identity format")
|
||||
errRSAChallengeOutRange = errors.New("RSA challenge level out of range")
|
||||
errInvalidPublicPoint = errors.New("invalid public key point encoding")
|
||||
errSharedSecretCompute = errors.New("failed to compute ECDH shared secret")
|
||||
)
|
||||
|
||||
const (
|
||||
decimalBase = 10
|
||||
identityPartCount = 2
|
||||
p256ScalarSize = 32
|
||||
p256PointPrefix = 0x04
|
||||
p256UncompressedKeySize = 65
|
||||
rsaChallengeBlockSize = 64
|
||||
maxRSAChallengeLevel = 1000000
|
||||
packetTypeMask = 0x0F
|
||||
generationIDShift = 32
|
||||
fromServerShift = 40
|
||||
fakeSignatureSize = 8
|
||||
ivAlphaSize = 10
|
||||
sha1NumBufSize = 20
|
||||
bitsPerByte = 8
|
||||
)
|
||||
|
||||
type Identity struct {
|
||||
PrivateKey *ecdsa.PrivateKey
|
||||
Offset uint64
|
||||
}
|
||||
|
||||
func (id *Identity) PublicKeyBase64() string {
|
||||
pubBytes, err := id.PrivateKey.PublicKey.Bytes()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if len(pubBytes) != p256UncompressedKeySize || pubBytes[0] != p256PointPrefix {
|
||||
return ""
|
||||
}
|
||||
|
||||
x := new(big.Int).SetBytes(pubBytes[1 : 1+p256ScalarSize])
|
||||
y := new(big.Int).SetBytes(pubBytes[1+p256ScalarSize : p256UncompressedKeySize])
|
||||
|
||||
data := struct {
|
||||
BitInfo asn1.BitString
|
||||
Size int
|
||||
X *big.Int
|
||||
Y *big.Int
|
||||
}{
|
||||
BitInfo: asn1.BitString{Bytes: []byte{0x00}, BitLength: 1},
|
||||
Size: p256ScalarSize,
|
||||
X: x,
|
||||
Y: y,
|
||||
}
|
||||
bytes, _ := asn1.Marshal(data)
|
||||
|
||||
return base64.StdEncoding.EncodeToString(bytes)
|
||||
}
|
||||
|
||||
func (id *Identity) String() string {
|
||||
d, err := id.PrivateKey.Bytes()
|
||||
if err != nil {
|
||||
// Keep String side-effect free; invalid key should not crash callers.
|
||||
return fmt.Sprintf(":%d", id.Offset)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s:%d", base64.StdEncoding.EncodeToString(d), id.Offset)
|
||||
}
|
||||
|
||||
func IdentityFromString(s string) (*Identity, error) {
|
||||
parts := strings.Split(s, ":")
|
||||
if len(parts) != identityPartCount {
|
||||
return nil, errInvalidIdentityFormat
|
||||
}
|
||||
dBytes, err := base64.StdEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
offset, err := strconv.ParseUint(parts[1], decimalBase, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
priv, err := ecdsa.ParseRawPrivateKey(elliptic.P256(), dBytes)
|
||||
if err != nil {
|
||||
// Backward compatibility: historical identity strings might store
|
||||
// non-padded scalars; normalize to SEC 1 fixed-size raw key.
|
||||
if len(dBytes) >= p256ScalarSize {
|
||||
return nil, err
|
||||
}
|
||||
padded := make([]byte, p256ScalarSize)
|
||||
copy(padded[p256ScalarSize-len(dBytes):], dBytes)
|
||||
priv, err = ecdsa.ParseRawPrivateKey(elliptic.P256(), padded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &Identity{PrivateKey: priv, Offset: offset}, nil
|
||||
}
|
||||
|
||||
func GetUidFromPublicKey(publicKey string) string {
|
||||
sum := sha1.Sum([]byte(publicKey))
|
||||
|
||||
return base64.StdEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
type Crypt struct {
|
||||
Identity *Identity
|
||||
CachedKeys map[uint64]KeyNonce
|
||||
IvStruct []byte
|
||||
FakeSignature []byte
|
||||
AlphaTmp []byte
|
||||
keyMu sync.Mutex
|
||||
CryptoInitComplete bool
|
||||
}
|
||||
|
||||
type KeyNonce struct {
|
||||
Key []byte
|
||||
Nonce []byte
|
||||
Gen uint32
|
||||
}
|
||||
|
||||
// makeCacheKey packs (fromServer, packetType, generationID) into a map key without allocating.
|
||||
func makeCacheKey(fromServer bool, packetType byte, generationID uint32) uint64 {
|
||||
var key uint64
|
||||
if fromServer {
|
||||
key = 1 << fromServerShift
|
||||
}
|
||||
key |= uint64(packetType&packetTypeMask) << generationIDShift
|
||||
key |= uint64(generationID)
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
func NewCrypt(id *Identity) *Crypt {
|
||||
return &Crypt{
|
||||
Identity: id,
|
||||
FakeSignature: make([]byte, fakeSignatureSize),
|
||||
CachedKeys: make(map[uint64]KeyNonce),
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *Crypt) SolveRsaChallenge(data []byte, offset int, level int) ([]byte, error) {
|
||||
if level < 0 || level > maxRSAChallengeLevel {
|
||||
return nil, errRSAChallengeOutRange
|
||||
}
|
||||
x := new(big.Int).SetBytes(data[offset : offset+rsaChallengeBlockSize])
|
||||
n := new(big.Int).SetBytes(data[offset+rsaChallengeBlockSize : offset+2*rsaChallengeBlockSize])
|
||||
|
||||
// y = x^(2^level) mod n via repeated squaring.
|
||||
y := new(big.Int).Set(x)
|
||||
for range level {
|
||||
y.Mul(y, y)
|
||||
y.Mod(y, n)
|
||||
}
|
||||
|
||||
res := y.Bytes()
|
||||
if len(res) < rsaChallengeBlockSize {
|
||||
aligned := make([]byte, rsaChallengeBlockSize)
|
||||
copy(aligned[rsaChallengeBlockSize-len(res):], res)
|
||||
res = aligned
|
||||
} else if len(res) > rsaChallengeBlockSize {
|
||||
res = res[len(res)-rsaChallengeBlockSize:]
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (tc *Crypt) InitCrypto(alpha, beta, omega string) error {
|
||||
alphaBytes, err := base64.StdEncoding.DecodeString(alpha)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid alpha: %w", err)
|
||||
}
|
||||
betaBytes, err := base64.StdEncoding.DecodeString(beta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid beta: %w", err)
|
||||
}
|
||||
omegaBytes, err := base64.StdEncoding.DecodeString(omega)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid omega: %w", err)
|
||||
}
|
||||
serverPubKey, err := ImportPublicKey(omegaBytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sharedSecret := tc.getSharedSecret(serverPubKey)
|
||||
if len(sharedSecret) == 0 {
|
||||
return errSharedSecretCompute
|
||||
}
|
||||
|
||||
return tc.SetSharedSecret(alphaBytes, betaBytes, sharedSecret)
|
||||
}
|
||||
|
||||
func (tc *Crypt) SetSharedSecret(alpha, beta, sharedKey []byte) error {
|
||||
tc.IvStruct = make([]byte, ivAlphaSize+len(beta))
|
||||
for i := range alpha {
|
||||
tc.IvStruct[i] = sharedKey[i] ^ alpha[i]
|
||||
}
|
||||
for i := range beta {
|
||||
tc.IvStruct[ivAlphaSize+i] = sharedKey[ivAlphaSize+i] ^ beta[i]
|
||||
}
|
||||
|
||||
h := sha1.New()
|
||||
h.Write(tc.IvStruct)
|
||||
copy(tc.FakeSignature, h.Sum(nil)[:fakeSignatureSize])
|
||||
|
||||
tc.CryptoInitComplete = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tc *Crypt) DebugCryptoState() (int, string) {
|
||||
if len(tc.IvStruct) == 0 {
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
return len(tc.IvStruct), hex.EncodeToString(tc.FakeSignature)
|
||||
}
|
||||
|
||||
func (tc *Crypt) getSharedSecret(pub *ecdsa.PublicKey) []byte {
|
||||
privECDH, err := tc.Identity.PrivateKey.ECDH()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
pubECDH, err := pub.ECDH()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
keyArr, err := privECDH.ECDH(pubECDH)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if len(keyArr) > p256ScalarSize {
|
||||
keyArr = keyArr[len(keyArr)-p256ScalarSize:]
|
||||
} else if len(keyArr) < p256ScalarSize {
|
||||
aligned := make([]byte, p256ScalarSize)
|
||||
copy(aligned[p256ScalarSize-len(keyArr):], keyArr)
|
||||
keyArr = aligned
|
||||
}
|
||||
h := sha1.New()
|
||||
h.Write(keyArr)
|
||||
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func Hash512(data []byte) []byte {
|
||||
sum := sha512.Sum512(data)
|
||||
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
func ImportPublicKey(data []byte) (*ecdsa.PublicKey, error) {
|
||||
// Canonical format (TS5/TS6): {BitString, Size, X, Y}
|
||||
var canonical struct {
|
||||
BitInfo asn1.BitString
|
||||
Size int
|
||||
X *big.Int
|
||||
Y *big.Int
|
||||
}
|
||||
_, canonicalErr := asn1.Unmarshal(data, &canonical)
|
||||
if canonicalErr == nil {
|
||||
encoded, err := encodeUncompressedP256Point(canonical.X, canonical.Y)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ecdsa.ParseUncompressedPublicKey(elliptic.P256(), encoded)
|
||||
}
|
||||
|
||||
// Legacy format (TeamSpeak): {X, Y, BitString, Size}
|
||||
var legacy struct {
|
||||
X *big.Int
|
||||
Y *big.Int
|
||||
BitInfo asn1.BitString
|
||||
Size int
|
||||
}
|
||||
_, err := asn1.Unmarshal(data, &legacy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
encoded, err := encodeUncompressedP256Point(legacy.X, legacy.Y)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ecdsa.ParseUncompressedPublicKey(elliptic.P256(), encoded)
|
||||
}
|
||||
|
||||
func encodeUncompressedP256Point(x, y *big.Int) ([]byte, error) {
|
||||
if x == nil || y == nil {
|
||||
return nil, errInvalidPublicPoint
|
||||
}
|
||||
xBytes := x.Bytes()
|
||||
yBytes := y.Bytes()
|
||||
const fieldSize = 32
|
||||
if len(xBytes) > fieldSize || len(yBytes) > fieldSize {
|
||||
return nil, errInvalidPublicPoint
|
||||
}
|
||||
|
||||
point := make([]byte, 1+fieldSize+fieldSize)
|
||||
point[0] = p256PointPrefix
|
||||
copy(point[1+fieldSize-len(xBytes):1+fieldSize], xBytes)
|
||||
copy(point[1+2*fieldSize-len(yBytes):], yBytes)
|
||||
|
||||
return point, nil
|
||||
}
|
||||
|
||||
func (id *Identity) SecurityLevel() int {
|
||||
h := sha1.New()
|
||||
h.Write([]byte(id.PublicKeyBase64()))
|
||||
var numBuf [sha1NumBufSize]byte
|
||||
h.Write(strconv.AppendUint(numBuf[:0], id.Offset, decimalBase))
|
||||
|
||||
return countLeadingZeros(h.Sum(nil))
|
||||
}
|
||||
|
||||
// UpgradeToLevel increments Offset until SecurityLevel reaches targetLevel.
|
||||
func (id *Identity) UpgradeToLevel(targetLevel int, ctx context.Context) error {
|
||||
prefix := []byte(id.PublicKeyBase64())
|
||||
h := sha1.New()
|
||||
var numBuf [sha1NumBufSize]byte
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
h.Reset()
|
||||
h.Write(prefix)
|
||||
h.Write(strconv.AppendUint(numBuf[:0], id.Offset, decimalBase))
|
||||
if countLeadingZeros(h.Sum(nil)) >= targetLevel {
|
||||
return nil
|
||||
}
|
||||
id.Offset++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func GenerateIdentity(targetLevel int) (*Identity, error) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id := &Identity{PrivateKey: priv}
|
||||
|
||||
prefix := []byte(id.PublicKeyBase64())
|
||||
h := sha1.New()
|
||||
var numBuf [sha1NumBufSize]byte
|
||||
for {
|
||||
h.Reset()
|
||||
h.Write(prefix)
|
||||
h.Write(strconv.AppendUint(numBuf[:0], id.Offset, decimalBase))
|
||||
if countLeadingZeros(h.Sum(nil)) >= targetLevel {
|
||||
return id, nil
|
||||
}
|
||||
id.Offset++
|
||||
}
|
||||
}
|
||||
|
||||
func countLeadingZeros(data []byte) int {
|
||||
zeros := 0
|
||||
for _, b := range data {
|
||||
if b == 0 {
|
||||
zeros += bitsPerByte
|
||||
} else {
|
||||
// Security level counts trailing zero bits in SHA1(prefix||offset), LSB-first.
|
||||
for i := range bitsPerByte {
|
||||
if (b & (1 << uint(i))) == 0 {
|
||||
zeros++
|
||||
} else {
|
||||
return zeros
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return zeros
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
keySizeBytes = 16
|
||||
init1PacketType = 8
|
||||
hashInputMetaSize = 6
|
||||
clientSaltByte = 0x31
|
||||
serverSaltByte = 0x30
|
||||
)
|
||||
|
||||
// keyPool reuses 16-byte AES key buffers for packet crypto.
|
||||
var keyPool = sync.Pool{
|
||||
New: func() any {
|
||||
buf := make([]byte, keySizeBytes)
|
||||
|
||||
return &buf
|
||||
},
|
||||
}
|
||||
|
||||
// AcquireKeyBuffer returns a 16-byte buffer from keyPool or allocates one.
|
||||
func AcquireKeyBuffer() []byte {
|
||||
bufPtr, ok := keyPool.Get().(*[]byte)
|
||||
if !ok || bufPtr == nil {
|
||||
return make([]byte, keySizeBytes)
|
||||
}
|
||||
|
||||
return *bufPtr
|
||||
}
|
||||
|
||||
// ReleaseKeyBuffer returns buf to keyPool only if len(buf) is the AES key size.
|
||||
func ReleaseKeyBuffer(buf []byte) {
|
||||
if len(buf) == keySizeBytes {
|
||||
keyPool.Put(&buf)
|
||||
}
|
||||
}
|
||||
|
||||
// Precomputed dummy key/nonce matching the TS3 client pre-crypto placeholder.
|
||||
var (
|
||||
dummyKey = []byte("c:\\windows\\syste")
|
||||
dummyNonce = []byte("m\\firewall32.cpl")
|
||||
)
|
||||
|
||||
func (tc *Crypt) GetKeyNonce(
|
||||
fromServer bool,
|
||||
packetID uint16,
|
||||
generationID uint32,
|
||||
packetType byte,
|
||||
dummy bool,
|
||||
) ([]byte, []byte) {
|
||||
if dummy {
|
||||
key := AcquireKeyBuffer()
|
||||
copy(key, dummyKey)
|
||||
|
||||
return key, dummyNonce
|
||||
}
|
||||
|
||||
cacheKey := makeCacheKey(fromServer, packetType, generationID)
|
||||
|
||||
tc.keyMu.Lock()
|
||||
kn, ok := tc.CachedKeys[cacheKey]
|
||||
if !ok {
|
||||
tmpToHash := make([]byte, hashInputMetaSize+len(tc.IvStruct))
|
||||
if fromServer {
|
||||
tmpToHash[0] = serverSaltByte
|
||||
} else {
|
||||
tmpToHash[0] = clientSaltByte
|
||||
}
|
||||
tmpToHash[1] = packetType & packetTypeMask
|
||||
binary.BigEndian.PutUint32(tmpToHash[2:6], generationID)
|
||||
copy(tmpToHash[6:], tc.IvStruct)
|
||||
|
||||
hash := sha256.Sum256(tmpToHash)
|
||||
kn = KeyNonce{
|
||||
Key: append([]byte(nil), hash[0:keySizeBytes]...),
|
||||
Nonce: append([]byte(nil), hash[keySizeBytes:2*keySizeBytes]...),
|
||||
Gen: generationID,
|
||||
}
|
||||
tc.CachedKeys[cacheKey] = kn
|
||||
}
|
||||
tc.keyMu.Unlock()
|
||||
|
||||
key := AcquireKeyBuffer()
|
||||
copy(key, kn.Key)
|
||||
var packetIDBytes [2]byte
|
||||
binary.BigEndian.PutUint16(packetIDBytes[:], packetID)
|
||||
key[0] ^= packetIDBytes[0]
|
||||
key[1] ^= packetIDBytes[1]
|
||||
|
||||
return key, kn.Nonce
|
||||
}
|
||||
|
||||
var init1MAC = []byte("TS3INIT1")
|
||||
|
||||
var ErrFakeSignatureMismatch = errors.New("fake signature mismatch")
|
||||
|
||||
// Encrypt returns (ciphertext, MAC, err). Init1 and unencrypted packet types bypass EAX.
|
||||
func (tc *Crypt) Encrypt(
|
||||
packetType byte,
|
||||
packetID uint16,
|
||||
generationID uint32,
|
||||
header, plaintext []byte,
|
||||
dummy bool,
|
||||
unencrypted bool,
|
||||
) ([]byte, []byte, error) {
|
||||
if packetType == init1PacketType {
|
||||
return plaintext, init1MAC, nil
|
||||
}
|
||||
|
||||
if unencrypted {
|
||||
return plaintext, tc.FakeSignature, nil
|
||||
}
|
||||
|
||||
key, nonce := tc.GetKeyNonce(false, packetID, generationID, packetType, dummy)
|
||||
defer ReleaseKeyBuffer(key)
|
||||
|
||||
eax, err := NewEAX(key)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ciphertext, mac, err := eax.Encrypt(nonce, header, plaintext)
|
||||
|
||||
return ciphertext, mac, err
|
||||
}
|
||||
|
||||
// Decrypt verifies and decrypts ciphertext; Init1 and unencrypted types pass through.
|
||||
func (tc *Crypt) Decrypt(
|
||||
packetType byte,
|
||||
packetID uint16,
|
||||
generationID uint32,
|
||||
header, ciphertext, tag []byte,
|
||||
dummy bool,
|
||||
unencrypted bool,
|
||||
) ([]byte, error) {
|
||||
if packetType == init1PacketType {
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
if unencrypted {
|
||||
if subtle.ConstantTimeCompare(tag[:fakeSignatureSize], tc.FakeSignature) != 1 {
|
||||
return nil, ErrFakeSignatureMismatch
|
||||
}
|
||||
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
key, nonce := tc.GetKeyNonce(true, packetID, generationID, packetType, dummy)
|
||||
defer ReleaseKeyBuffer(key)
|
||||
|
||||
eax, err := NewEAX(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return eax.Decrypt(nonce, header, ciphertext, tag)
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
package crypto_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/crypto"
|
||||
"github.com/honeybbq/teamspeak-go/handshake"
|
||||
"github.com/oasisprotocol/curve25519-voi/curve"
|
||||
"github.com/oasisprotocol/curve25519-voi/curve/scalar"
|
||||
)
|
||||
|
||||
func TestGetSharedSecret2(t *testing.T) {
|
||||
publicKeyHex := "9d93589a4a86cf80d8dc1c1b384555289454021ad2f5dacf29d9938eade940b1"
|
||||
privateKeyHex := "58cd07b6765c3427afcfa64c73a609705a7f1656f40c582c7362080791bcfb68"
|
||||
expectedSharedSecretHex := "8aa100de2e0cde11827c36b5b3ef2758b1a7d52a202c375049cd8a3944d764" +
|
||||
"14b9854db31f781b5b51f37c025e9efee70edcd7189ccb7831a04eb7bc09e5b20b"
|
||||
|
||||
publicKey, _ := hex.DecodeString(publicKeyHex)
|
||||
privateKey, _ := hex.DecodeString(privateKeyHex)
|
||||
|
||||
sharedSecret, err := crypto.GetSharedSecret2(publicKey, privateKey)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSharedSecret2 failed: %v", err)
|
||||
}
|
||||
|
||||
actualSharedSecretHex := hex.EncodeToString(sharedSecret)
|
||||
if actualSharedSecretHex != expectedSharedSecretHex {
|
||||
t.Errorf("sharedSecret mismatch:\n expected: %s\n actual: %s", expectedSharedSecretHex, actualSharedSecretHex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetKeyNonce(t *testing.T) {
|
||||
ivStructHex := "10ea569521d4d88e06a63db265416d780daf03a1ee4d3731ba22e8981e61d292" +
|
||||
"febebc434ce2a2ac36e8e1bd2b6cd9c953f84d7a269cc42f33917de8c47b8bdf"
|
||||
expectedKeyHex := "0659e387b9258c3f4fe32b31bc794dee"
|
||||
expectedNonceHex := "ec4630a6e61e216f61e15788bb42eaec"
|
||||
|
||||
ivStruct, _ := hex.DecodeString(ivStructHex)
|
||||
|
||||
tc := &crypto.Crypt{
|
||||
IvStruct: ivStruct,
|
||||
CryptoInitComplete: true,
|
||||
CachedKeys: make(map[uint64]crypto.KeyNonce),
|
||||
}
|
||||
|
||||
key, nonce := tc.GetKeyNonce(false, 2, 0, 2, false)
|
||||
|
||||
actualKeyHex := hex.EncodeToString(key)
|
||||
actualNonceHex := hex.EncodeToString(nonce)
|
||||
|
||||
if actualKeyHex != expectedKeyHex {
|
||||
t.Errorf("key mismatch:\n expected: %s\n actual: %s", expectedKeyHex, actualKeyHex)
|
||||
}
|
||||
if actualNonceHex != expectedNonceHex {
|
||||
t.Errorf("nonce mismatch:\n expected: %s\n actual: %s", expectedNonceHex, actualNonceHex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTemporaryKey(t *testing.T) {
|
||||
pubKey, privKey, err := crypto.GenerateTemporaryKey()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateTemporaryKey failed: %v", err)
|
||||
}
|
||||
|
||||
if len(pubKey) != 32 {
|
||||
t.Errorf("publicKey should be 32 bytes, got %d", len(pubKey))
|
||||
}
|
||||
if len(privKey) != 32 {
|
||||
t.Errorf("privateKey should be 32 bytes, got %d", len(privKey))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSharedSecret2WithCSharpData(t *testing.T) {
|
||||
publicKeyHex := "a878824253ba90c33297d0e44fa52439d9a35e316200e712d9d9e0efcd11dc0a"
|
||||
privateKeyHex := "b8985f89031ee1adf325fb5595fe5810f232fa33c5629eb4632969e17e69717f"
|
||||
expectedSharedSecretHex := "91478e774dc13a156cc2019c6c6ebe63d220381a2a914a6bedd49058685fdc" +
|
||||
"55a02c79569a62d4d71926899c8e45fb56122ef86a445cfb461689c945c826e707"
|
||||
|
||||
publicKey, _ := hex.DecodeString(publicKeyHex)
|
||||
privateKey, _ := hex.DecodeString(privateKeyHex)
|
||||
expectedSharedSecret, _ := hex.DecodeString(expectedSharedSecretHex)
|
||||
|
||||
sharedSecret, err := crypto.GetSharedSecret2(publicKey, privateKey)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSharedSecret2 failed: %v", err)
|
||||
}
|
||||
|
||||
if hex.EncodeToString(sharedSecret) != hex.EncodeToString(expectedSharedSecret) {
|
||||
t.Errorf("sharedSecret mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemporaryKeyWithFixedPrivate(t *testing.T) {
|
||||
privateKeyHex := "a02708b21598ae10932dc8eac25cf70bdd033c1f36f14a2caf24036dd8010d5b"
|
||||
expectedPublicKeyHex := "f67d0b5b0db004ab4f5df21d9e92f184e32aa45d90f483889912f95e7071ad79"
|
||||
|
||||
privateKey, _ := hex.DecodeString(privateKeyHex)
|
||||
|
||||
sc, err := scalar.NewFromBits(privateKey)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFromBits failed: %v", err)
|
||||
}
|
||||
publicKey, err := curve.NewEdwardsPoint().MulBasepoint(curve.ED25519_BASEPOINT_TABLE, sc).MarshalBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("MulBasepoint failed: %v", err)
|
||||
}
|
||||
|
||||
if hex.EncodeToString(publicKey) != expectedPublicKeyHex {
|
||||
t.Errorf("publicKey mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullLicenseParseAndDerive(t *testing.T) {
|
||||
licenseBase64 := "AQBgjAAqtcBUrw5futTtkl3+EM3OW4Lal6OTPlwuv4xV/gIRFlEAG0Nl" +
|
||||
"AAcAAAAgQW5vbnltb3VzAACWSZf+Mjl5RT5mu4rvf8nhAZp9TjXO10XfGHQ9HQPtHiAYiqjtGItRrQ=="
|
||||
licenseBytes, _ := base64.StdEncoding.DecodeString(licenseBase64)
|
||||
|
||||
chain, err := handshake.ParseLicenses(licenseBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLicenses failed: %v", err)
|
||||
}
|
||||
|
||||
if len(chain.Blocks) != 2 {
|
||||
t.Errorf("expected 2 blocks, got %d", len(chain.Blocks))
|
||||
}
|
||||
|
||||
key, err := chain.DeriveKey()
|
||||
if err != nil {
|
||||
t.Fatalf("DeriveKey failed: %v", err)
|
||||
}
|
||||
|
||||
if len(key) != 32 {
|
||||
t.Errorf("expected 32-byte key, got %d bytes", len(key))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityStringRoundtrip(t *testing.T) {
|
||||
id, err := crypto.GenerateIdentity(0)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateIdentity failed: %v", err)
|
||||
}
|
||||
s := id.String()
|
||||
id2, err := crypto.IdentityFromString(s)
|
||||
if err != nil {
|
||||
t.Fatalf("IdentityFromString failed: %v", err)
|
||||
}
|
||||
// Compare via serialised form to avoid accessing deprecated D field directly.
|
||||
if id.String() != id2.String() {
|
||||
t.Errorf("serialized identity mismatch: %q vs %q", id.String(), id2.String())
|
||||
}
|
||||
if id.Offset != id2.Offset {
|
||||
t.Errorf("Offset mismatch: %d vs %d", id.Offset, id2.Offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityFromStringErrors(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
"notvalidnocodon",
|
||||
"invalid==base64:0",
|
||||
"dGVzdA==:notanumber",
|
||||
}
|
||||
for _, s := range cases {
|
||||
_, err := crypto.IdentityFromString(s)
|
||||
if err == nil {
|
||||
t.Errorf("IdentityFromString(%q) expected error, got nil", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentitySecurityLevel(t *testing.T) {
|
||||
id, err := crypto.GenerateIdentity(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lvl := id.SecurityLevel()
|
||||
if lvl < 0 {
|
||||
t.Errorf("SecurityLevel should be >= 0, got %d", lvl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityUpgradeToLevel(t *testing.T) {
|
||||
id, err := crypto.GenerateIdentity(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = id.UpgradeToLevel(1, context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("UpgradeToLevel(1) failed: %v", err)
|
||||
}
|
||||
if id.SecurityLevel() < 1 {
|
||||
t.Errorf("SecurityLevel after upgrade = %d, want >= 1", id.SecurityLevel())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityUpgradeCtxCancelled(t *testing.T) {
|
||||
id, err := crypto.GenerateIdentity(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // cancel immediately
|
||||
err = id.UpgradeToLevel(100, ctx)
|
||||
if err == nil {
|
||||
t.Error("expected context cancellation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicKeyBase64RoundtrippableWithImport(t *testing.T) {
|
||||
id, err := crypto.GenerateIdentity(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pubB64 := id.PublicKeyBase64()
|
||||
pubBytes, err := base64.StdEncoding.DecodeString(pubB64)
|
||||
if err != nil {
|
||||
t.Fatalf("base64 decode failed: %v", err)
|
||||
}
|
||||
// Verify round-trip: import the bytes and check the UID is consistent.
|
||||
_, err = crypto.ImportPublicKey(pubBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportPublicKey failed: %v", err)
|
||||
}
|
||||
// GetUidFromPublicKey verifies the public key serialisation is stable.
|
||||
uid1 := crypto.GetUidFromPublicKey(pubB64)
|
||||
uid2 := crypto.GetUidFromPublicKey(pubB64)
|
||||
if uid1 != uid2 {
|
||||
t.Error("UID is not stable after import")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUidFromPublicKey(t *testing.T) {
|
||||
id, err := crypto.GenerateIdentity(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uid1 := crypto.GetUidFromPublicKey(id.PublicKeyBase64())
|
||||
uid2 := crypto.GetUidFromPublicKey(id.PublicKeyBase64())
|
||||
if uid1 != uid2 {
|
||||
t.Error("GetUidFromPublicKey is not deterministic")
|
||||
}
|
||||
// SHA-1 produces 20 bytes → base64 = 28 chars
|
||||
if len(uid1) != 28 {
|
||||
t.Errorf("UID length = %d, want 28", len(uid1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHash512Length(t *testing.T) {
|
||||
out := crypto.Hash512([]byte("hello"))
|
||||
if len(out) != 64 {
|
||||
t.Errorf("Hash512 output length = %d, want 64", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHash512Deterministic(t *testing.T) {
|
||||
data := []byte("determinism test")
|
||||
if !bytes.Equal(crypto.Hash512(data), crypto.Hash512(data)) {
|
||||
t.Error("Hash512 is not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHash512EmptyInput(t *testing.T) {
|
||||
// SHA-512 of empty string is a well-known constant.
|
||||
const emptyHex = "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce" +
|
||||
"47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e"
|
||||
out := crypto.Hash512(nil)
|
||||
if hex.EncodeToString(out) != emptyHex {
|
||||
t.Errorf("Hash512(nil) = %x, want %s", out, emptyHex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampScalar(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
key[0] = 0xFF
|
||||
key[31] = 0xFF
|
||||
crypto.ClampScalar(key)
|
||||
if key[0]&0x07 != 0 {
|
||||
t.Errorf("key[0] low 3 bits should be 0 after clamping, got 0x%02X", key[0])
|
||||
}
|
||||
if key[31]&0x80 != 0 {
|
||||
t.Errorf("key[31] high bit should be 0 after clamping, got 0x%02X", key[31])
|
||||
}
|
||||
if key[31]&0x40 == 0 {
|
||||
t.Errorf("key[31] bit 6 should be 1 after clamping, got 0x%02X", key[31])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampScalarTooShort(t *testing.T) {
|
||||
key := []byte{0xFF, 0xFF}
|
||||
crypto.ClampScalar(key) // should not panic
|
||||
}
|
||||
|
||||
// Sign / VerifySign
|
||||
|
||||
func TestSignAndVerify(t *testing.T) {
|
||||
id, err := crypto.GenerateIdentity(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := []byte("the message to sign")
|
||||
sig, err := crypto.Sign(id.PrivateKey, data)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign failed: %v", err)
|
||||
}
|
||||
if !crypto.VerifySign(&id.PrivateKey.PublicKey, data, sig) {
|
||||
t.Error("VerifySign returned false for valid signature")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifySignFailsOnTamperedData(t *testing.T) {
|
||||
id, err := crypto.GenerateIdentity(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := []byte("original")
|
||||
sig, _ := crypto.Sign(id.PrivateKey, data)
|
||||
if crypto.VerifySign(&id.PrivateKey.PublicKey, append(data, 'X'), sig) {
|
||||
t.Error("VerifySign should fail for tampered data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifySignFailsOnTamperedSig(t *testing.T) {
|
||||
id, err := crypto.GenerateIdentity(0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := []byte("original")
|
||||
sig, _ := crypto.Sign(id.PrivateKey, data)
|
||||
sig[0] ^= 0xFF
|
||||
if crypto.VerifySign(&id.PrivateKey.PublicKey, data, sig) {
|
||||
t.Error("VerifySign should fail for tampered signature")
|
||||
}
|
||||
}
|
||||
|
||||
func makeSolveData(x, n byte) []byte {
|
||||
data := make([]byte, 128)
|
||||
data[63] = x // x as 64-byte big-endian
|
||||
data[127] = n // n as 64-byte big-endian
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func TestSolveRsaChallengeLevel0(t *testing.T) {
|
||||
// level=0: no squarings, result = x (padded to 64 bytes)
|
||||
tc := crypto.NewCrypt(nil)
|
||||
data := makeSolveData(5, 100) // x=5, n=100
|
||||
result, err := tc.SolveRsaChallenge(data, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("SolveRsaChallenge failed: %v", err)
|
||||
}
|
||||
if len(result) != 64 {
|
||||
t.Errorf("result length = %d, want 64", len(result))
|
||||
}
|
||||
if result[63] != 5 {
|
||||
t.Errorf("result[63] = %d, want 5", result[63])
|
||||
}
|
||||
for i := range 63 {
|
||||
if result[i] != 0 {
|
||||
t.Errorf("result[%d] = %d, want 0", i, result[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveRsaChallengeLevel2(t *testing.T) {
|
||||
// x=2, n=17, level=2: y = ((2^2)^2) mod 17 = 4^2 mod 17 = 16
|
||||
tc := crypto.NewCrypt(nil)
|
||||
data := makeSolveData(2, 17)
|
||||
result, err := tc.SolveRsaChallenge(data, 0, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("SolveRsaChallenge failed: %v", err)
|
||||
}
|
||||
if result[63] != 16 {
|
||||
t.Errorf("result[63] = %d, want 16", result[63])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveRsaChallengeNegativeLevel(t *testing.T) {
|
||||
tc := crypto.NewCrypt(nil)
|
||||
_, err := tc.SolveRsaChallenge(make([]byte, 128), 0, -1)
|
||||
if err == nil {
|
||||
t.Error("expected error for level < 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveRsaChallengeLevelTooHigh(t *testing.T) {
|
||||
tc := crypto.NewCrypt(nil)
|
||||
_, err := tc.SolveRsaChallenge(make([]byte, 128), 0, 1000001)
|
||||
if err == nil {
|
||||
t.Error("expected error for level > 1000000")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCrypt(t *testing.T) {
|
||||
id, _ := crypto.GenerateIdentity(0)
|
||||
tc := crypto.NewCrypt(id)
|
||||
if tc == nil {
|
||||
t.Fatal("expected non-nil Crypt")
|
||||
}
|
||||
if len(tc.FakeSignature) != 8 {
|
||||
t.Errorf("FakeSignature length = %d, want 8", len(tc.FakeSignature))
|
||||
}
|
||||
if tc.CachedKeys == nil {
|
||||
t.Error("CachedKeys should be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSharedSecret(t *testing.T) {
|
||||
tc := crypto.NewCrypt(nil)
|
||||
alpha := make([]byte, 10)
|
||||
beta := make([]byte, 10)
|
||||
sharedKey := make([]byte, 20)
|
||||
err := tc.SetSharedSecret(alpha, beta, sharedKey)
|
||||
if err != nil {
|
||||
t.Fatalf("SetSharedSecret failed: %v", err)
|
||||
}
|
||||
if !tc.CryptoInitComplete {
|
||||
t.Error("CryptoInitComplete should be true")
|
||||
}
|
||||
if len(tc.IvStruct) != 20 {
|
||||
t.Errorf("IvStruct length = %d, want 20", len(tc.IvStruct))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugCryptoStateEmpty(t *testing.T) {
|
||||
tc := crypto.NewCrypt(nil)
|
||||
length, hexStr := tc.DebugCryptoState()
|
||||
if length != 0 || hexStr != "" {
|
||||
t.Errorf("empty Crypt: DebugCryptoState() = (%d, %q), want (0, \"\")", length, hexStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebugCryptoStateAfterSetSharedSecret(t *testing.T) {
|
||||
tc := crypto.NewCrypt(nil)
|
||||
_ = tc.SetSharedSecret(make([]byte, 10), make([]byte, 10), make([]byte, 20))
|
||||
length, hexStr := tc.DebugCryptoState()
|
||||
if length == 0 {
|
||||
t.Error("IvStruct should be non-empty after SetSharedSecret")
|
||||
}
|
||||
if len(hexStr) == 0 {
|
||||
t.Error("FakeSignature hex should be non-empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptInit1Passthrough(t *testing.T) {
|
||||
// PacketTypeInit1 (type=8): plaintext is returned unchanged, MAC = "TS3INIT1"
|
||||
tc := crypto.NewCrypt(nil)
|
||||
plaintext := []byte{0x01, 0x02, 0x03}
|
||||
header := []byte{0x00, 0x65, 0x00, 0x00, 0x08}
|
||||
ct, mac, err := tc.Encrypt(8, 0, 0, header, plaintext, false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt Init1 failed: %v", err)
|
||||
}
|
||||
if !bytes.Equal(ct, plaintext) {
|
||||
t.Error("Init1: ciphertext should equal plaintext")
|
||||
}
|
||||
if string(mac) != "TS3INIT1" {
|
||||
t.Errorf("Init1 MAC = %q, want TS3INIT1", mac)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptInit1Passthrough(t *testing.T) {
|
||||
tc := crypto.NewCrypt(nil)
|
||||
data := []byte{0xAA, 0xBB}
|
||||
header := []byte{0x00}
|
||||
pt, err := tc.Decrypt(8, 0, 0, header, data, nil, false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt Init1 failed: %v", err)
|
||||
}
|
||||
if !bytes.Equal(pt, data) {
|
||||
t.Error("Init1: decrypted should equal original data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecryptUnencrypted(t *testing.T) {
|
||||
tc := &crypto.Crypt{
|
||||
FakeSignature: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08},
|
||||
CachedKeys: make(map[uint64]crypto.KeyNonce),
|
||||
}
|
||||
plaintext := []byte("voice packet data")
|
||||
header := []byte{0x00, 0x01, 0x02}
|
||||
ct, mac, err := tc.Encrypt(0, 5, 0, header, plaintext, false, true)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt unencrypted failed: %v", err)
|
||||
}
|
||||
if !bytes.Equal(ct, plaintext) {
|
||||
t.Error("unencrypted: ciphertext should equal plaintext")
|
||||
}
|
||||
if !bytes.Equal(mac, tc.FakeSignature) {
|
||||
t.Error("unencrypted: MAC should equal FakeSignature")
|
||||
}
|
||||
pt, err := tc.Decrypt(0, 5, 0, header, ct, mac, false, true)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt unencrypted failed: %v", err)
|
||||
}
|
||||
if !bytes.Equal(pt, plaintext) {
|
||||
t.Error("unencrypted decrypt mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptUnencryptedBadSignature(t *testing.T) {
|
||||
tc := &crypto.Crypt{
|
||||
FakeSignature: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08},
|
||||
CachedKeys: make(map[uint64]crypto.KeyNonce),
|
||||
}
|
||||
badTag := []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}
|
||||
_, err := tc.Decrypt(0, 1, 0, nil, []byte("data"), badTag, false, true)
|
||||
if err == nil {
|
||||
t.Error("expected ErrFakeSignatureMismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecryptEAXRoundtripDummy(t *testing.T) {
|
||||
// dummy=true uses fixed key/nonce; both Encrypt and Decrypt use the same
|
||||
// dummy key so the roundtrip is self-consistent.
|
||||
tc := &crypto.Crypt{
|
||||
FakeSignature: make([]byte, 8),
|
||||
CachedKeys: make(map[uint64]crypto.KeyNonce),
|
||||
}
|
||||
plaintext := []byte("hello encrypted world")
|
||||
header := []byte{0x00, 0x01, 0x02, 0x03, 0x04}
|
||||
ct, mac, err := tc.Encrypt(2, 42, 0, header, plaintext, true, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt EAX failed: %v", err)
|
||||
}
|
||||
if bytes.Equal(ct, plaintext) {
|
||||
t.Error("ciphertext should differ from plaintext")
|
||||
}
|
||||
pt, err := tc.Decrypt(2, 42, 0, header, ct, mac, true, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt EAX failed: %v", err)
|
||||
}
|
||||
if !bytes.Equal(pt, plaintext) {
|
||||
t.Errorf("decrypted = %q, want %q", pt, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireReleaseKeyBuffer(t *testing.T) {
|
||||
buf := crypto.AcquireKeyBuffer()
|
||||
if len(buf) != 16 {
|
||||
t.Errorf("buffer len = %d, want 16", len(buf))
|
||||
}
|
||||
crypto.ReleaseKeyBuffer(buf)
|
||||
// Acquire again — should get a buffer of the correct length
|
||||
buf2 := crypto.AcquireKeyBuffer()
|
||||
if len(buf2) != 16 {
|
||||
t.Errorf("recycled buffer len = %d, want 16", len(buf2))
|
||||
}
|
||||
crypto.ReleaseKeyBuffer(buf2)
|
||||
}
|
||||
|
||||
func TestReleaseKeyBufferWrongLength(t *testing.T) {
|
||||
// Wrong-length buffers should not be returned to the pool (no panic)
|
||||
crypto.ReleaseKeyBuffer(make([]byte, 15))
|
||||
crypto.ReleaseKeyBuffer(make([]byte, 0))
|
||||
}
|
||||
|
||||
// TestInitCrypto verifies that InitCrypto performs ECDH with a freshly generated
|
||||
// server key pair and marks crypto as initialized.
|
||||
func TestInitCrypto(t *testing.T) {
|
||||
clientID, err := crypto.IdentityFromString(
|
||||
"W2OSGpWxkzBPJjt8iyJFsMnqnwHCnxOlmE9gWFOFnKs=:0",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("IdentityFromString: %v", err)
|
||||
}
|
||||
tc := crypto.NewCrypt(clientID)
|
||||
|
||||
// Generate a fresh server identity to use as the server public key.
|
||||
serverID, err := crypto.IdentityFromString(
|
||||
"W2OSGpWxkzBPJjt8iyJFsMnqnwHCnxOlmE9gWFOFnKs=:0",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("IdentityFromString server: %v", err)
|
||||
}
|
||||
omega := serverID.PublicKeyBase64()
|
||||
|
||||
// In the clientinitiv handshake path, alpha and beta are both 10 bytes.
|
||||
// SetSharedSecret uses SHA-1 (20 bytes) as the shared key, so beta must be <= 10 bytes.
|
||||
alpha := base64.StdEncoding.EncodeToString(make([]byte, 10))
|
||||
beta := base64.StdEncoding.EncodeToString(make([]byte, 10))
|
||||
|
||||
err = tc.InitCrypto(alpha, beta, omega)
|
||||
if err != nil {
|
||||
t.Fatalf("InitCrypto failed: %v", err)
|
||||
}
|
||||
if !tc.CryptoInitComplete {
|
||||
t.Error("expected CryptoInitComplete to be true")
|
||||
}
|
||||
// IvStruct = 10 (alpha len) + len(betaBytes)
|
||||
betaBytes, _ := base64.StdEncoding.DecodeString(beta)
|
||||
expectedLen := 10 + len(betaBytes)
|
||||
if len(tc.IvStruct) != expectedLen {
|
||||
t.Errorf("IvStruct length = %d, want %d", len(tc.IvStruct), expectedLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitCrypto_InvalidAlpha(t *testing.T) {
|
||||
clientID, _ := crypto.IdentityFromString("W2OSGpWxkzBPJjt8iyJFsMnqnwHCnxOlmE9gWFOFnKs=:0")
|
||||
tc := crypto.NewCrypt(clientID)
|
||||
err := tc.InitCrypto("not-base64!!!", "", "")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid alpha base64")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitCrypto_InvalidBeta(t *testing.T) {
|
||||
clientID, _ := crypto.IdentityFromString("W2OSGpWxkzBPJjt8iyJFsMnqnwHCnxOlmE9gWFOFnKs=:0")
|
||||
tc := crypto.NewCrypt(clientID)
|
||||
err := tc.InitCrypto(base64.StdEncoding.EncodeToString([]byte("alpha")), "not-base64!!!", "")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid beta base64")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitCrypto_InvalidOmega(t *testing.T) {
|
||||
clientID, _ := crypto.IdentityFromString("W2OSGpWxkzBPJjt8iyJFsMnqnwHCnxOlmE9gWFOFnKs=:0")
|
||||
tc := crypto.NewCrypt(clientID)
|
||||
alpha := base64.StdEncoding.EncodeToString(make([]byte, 10))
|
||||
beta := base64.StdEncoding.EncodeToString(make([]byte, 10))
|
||||
err := tc.InitCrypto(alpha, beta, "not-base64!!!")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid omega base64")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitCrypto_InvalidOmegaKey(t *testing.T) {
|
||||
clientID, _ := crypto.IdentityFromString("W2OSGpWxkzBPJjt8iyJFsMnqnwHCnxOlmE9gWFOFnKs=:0")
|
||||
tc := crypto.NewCrypt(clientID)
|
||||
alpha := base64.StdEncoding.EncodeToString(make([]byte, 10))
|
||||
beta := base64.StdEncoding.EncodeToString(make([]byte, 10))
|
||||
badOmega := base64.StdEncoding.EncodeToString([]byte("this is not a valid public key"))
|
||||
err := tc.InitCrypto(alpha, beta, badOmega)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid omega key bytes")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
cryptosubtle "crypto/subtle"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/tink-crypto/tink-go/v2/mac/subtle"
|
||||
)
|
||||
|
||||
const (
|
||||
eaxTagByte0 = 0
|
||||
eaxTagByte1 = 1
|
||||
eaxTagByte2 = 2
|
||||
eaxTagSize = 8
|
||||
eaxBlockSize = 16
|
||||
eaxPoolBufferSize = 528
|
||||
)
|
||||
|
||||
// cmacInputPool sizes buffers for nonce + header + ciphertext (TS3 wire limits).
|
||||
var cmacInputPool = sync.Pool{
|
||||
New: func() any {
|
||||
buf := make([]byte, eaxPoolBufferSize)
|
||||
|
||||
return &buf
|
||||
},
|
||||
}
|
||||
|
||||
// EAX implementation for TeamSpeak 3 (64-bit tag).
|
||||
type EAX struct {
|
||||
block cipher.Block
|
||||
cmacHasher *subtle.AESCMAC
|
||||
}
|
||||
|
||||
func NewEAX(key []byte) (*EAX, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmacHasher, err := subtle.NewAESCMAC(key, eaxBlockSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &EAX{block: block, cmacHasher: cmacHasher}, nil
|
||||
}
|
||||
|
||||
var ErrEAXTagMismatch = errors.New("EAX tag mismatch")
|
||||
|
||||
func (e *EAX) Encrypt(nonce, header, plaintext []byte) ([]byte, []byte, error) {
|
||||
nStar, _ := e.cmac(eaxTagByte0, nonce)
|
||||
hStar, _ := e.cmac(eaxTagByte1, header)
|
||||
|
||||
// CTR encryption
|
||||
stream := cipher.NewCTR(e.block, nStar)
|
||||
ciphertext := make([]byte, len(plaintext))
|
||||
stream.XORKeyStream(ciphertext, plaintext)
|
||||
|
||||
cStar, _ := e.cmac(eaxTagByte2, ciphertext)
|
||||
|
||||
tag := make([]byte, eaxTagSize)
|
||||
for i := range eaxTagSize {
|
||||
tag[i] = nStar[i] ^ hStar[i] ^ cStar[i]
|
||||
}
|
||||
|
||||
return ciphertext, tag, nil
|
||||
}
|
||||
|
||||
func (e *EAX) Decrypt(nonce, header, ciphertext, tag []byte) ([]byte, error) {
|
||||
nStar, _ := e.cmac(eaxTagByte0, nonce)
|
||||
hStar, _ := e.cmac(eaxTagByte1, header)
|
||||
cStar, _ := e.cmac(eaxTagByte2, ciphertext)
|
||||
|
||||
var expected [eaxTagSize]byte
|
||||
for i := range eaxTagSize {
|
||||
expected[i] = nStar[i] ^ hStar[i] ^ cStar[i]
|
||||
}
|
||||
if cryptosubtle.ConstantTimeCompare(expected[:], tag[:eaxTagSize]) != 1 {
|
||||
return nil, ErrEAXTagMismatch
|
||||
}
|
||||
|
||||
// CTR decryption (same as encryption)
|
||||
stream := cipher.NewCTR(e.block, nStar)
|
||||
plaintext := make([]byte, len(ciphertext))
|
||||
stream.XORKeyStream(plaintext, ciphertext)
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
func (e *EAX) cmac(tag byte, data []byte) ([]byte, error) {
|
||||
inputLen := eaxBlockSize + len(data)
|
||||
|
||||
inputBufPtr, ok := cmacInputPool.Get().(*[]byte)
|
||||
if !ok || inputBufPtr == nil {
|
||||
buf := make([]byte, inputLen)
|
||||
inputBufPtr = &buf
|
||||
}
|
||||
inputBuf := *inputBufPtr
|
||||
if cap(inputBuf) < inputLen {
|
||||
inputBuf = make([]byte, inputLen)
|
||||
} else {
|
||||
inputBuf = inputBuf[:inputLen]
|
||||
}
|
||||
|
||||
for i := range eaxBlockSize - 1 {
|
||||
inputBuf[i] = 0
|
||||
}
|
||||
inputBuf[eaxBlockSize-1] = tag
|
||||
copy(inputBuf[eaxBlockSize:], data)
|
||||
|
||||
result, err := e.cmacHasher.ComputeMAC(inputBuf)
|
||||
|
||||
cmacInputPool.Put(&inputBuf)
|
||||
|
||||
return result, err
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package crypto_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/crypto"
|
||||
"github.com/tink-crypto/tink-go/v2/mac/subtle"
|
||||
)
|
||||
|
||||
func TestEAXEncrypt(t *testing.T) {
|
||||
key := []byte("c:\\windows\\syste") // 16 bytes
|
||||
nonce := []byte("m\\firewall32.cpl") // 16 bytes
|
||||
header := []byte{0x00, 0x65, 0x00, 0x00, 0x08} // Init1 header
|
||||
plaintext := []byte{
|
||||
0x01, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x35, 0xfc, 0x54, 0x2f,
|
||||
}
|
||||
|
||||
eax, err := crypto.NewEAX(key)
|
||||
if err != nil {
|
||||
t.Fatalf("NewEAX failed: %v", err)
|
||||
}
|
||||
|
||||
ciphertext, tag, err := eax.Encrypt(nonce, header, plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Ciphertext: %x", ciphertext)
|
||||
t.Logf("Tag: %x", tag)
|
||||
|
||||
// Decrypt back
|
||||
decrypted, err := eax.Decrypt(nonce, header, ciphertext, tag)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt failed: %v", err)
|
||||
}
|
||||
|
||||
if hex.EncodeToString(decrypted) != hex.EncodeToString(plaintext) {
|
||||
t.Errorf("Decrypted data mismatch!\nExpected: %x\nActual: %x", plaintext, decrypted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCMACAlignment(t *testing.T) {
|
||||
key := []byte("c:\\windows\\syste")
|
||||
data := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4}
|
||||
|
||||
mac, err := subtle.NewAESCMAC(key, 16)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAESCMAC failed: %v", err)
|
||||
}
|
||||
sum, err := mac.ComputeMAC(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ComputeMAC failed: %v", err)
|
||||
}
|
||||
// AES-CMAC output must be exactly 16 bytes and not all-zero for non-trivial input.
|
||||
if len(sum) != 16 {
|
||||
t.Errorf("CMAC length = %d, want 16", len(sum))
|
||||
}
|
||||
if bytes.Equal(sum, make([]byte, 16)) {
|
||||
t.Error("CMAC should not be all zeros for non-trivial input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEAXDecryptTagMismatch(t *testing.T) {
|
||||
key := []byte("c:\\windows\\syste")
|
||||
nonce := []byte("m\\firewall32.cpl")
|
||||
header := []byte{0x00, 0x01, 0x02}
|
||||
plaintext := []byte{0x01, 0x02, 0x03, 0x04}
|
||||
|
||||
eax, err := crypto.NewEAX(key)
|
||||
if err != nil {
|
||||
t.Fatalf("NewEAX failed: %v", err)
|
||||
}
|
||||
ciphertext, tag, err := eax.Encrypt(nonce, header, plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
// Corrupt the tag
|
||||
corruptTag := make([]byte, len(tag))
|
||||
copy(corruptTag, tag)
|
||||
corruptTag[0] ^= 0xFF
|
||||
|
||||
_, err = eax.Decrypt(nonce, header, ciphertext, corruptTag)
|
||||
if err == nil {
|
||||
t.Error("expected tag mismatch error with corrupted tag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEAXDecryptCiphertextTampered(t *testing.T) {
|
||||
key := []byte("c:\\windows\\syste")
|
||||
nonce := []byte("m\\firewall32.cpl")
|
||||
header := []byte{0x00, 0x01}
|
||||
plaintext := []byte{0xDE, 0xAD, 0xBE, 0xEF}
|
||||
|
||||
eax, err := crypto.NewEAX(key)
|
||||
if err != nil {
|
||||
t.Fatalf("NewEAX failed: %v", err)
|
||||
}
|
||||
ciphertext, tag, err := eax.Encrypt(nonce, header, plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
// Corrupt the ciphertext
|
||||
tampered := make([]byte, len(ciphertext))
|
||||
copy(tampered, ciphertext)
|
||||
tampered[0] ^= 0x01
|
||||
|
||||
_, err = eax.Decrypt(nonce, header, tampered, tag)
|
||||
if err == nil {
|
||||
t.Error("expected tag mismatch error with tampered ciphertext")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEAXEmptyPlaintext(t *testing.T) {
|
||||
key := []byte("c:\\windows\\syste")
|
||||
nonce := []byte("m\\firewall32.cpl")
|
||||
header := []byte{0x00}
|
||||
plaintext := []byte{}
|
||||
|
||||
eax, err := crypto.NewEAX(key)
|
||||
if err != nil {
|
||||
t.Fatalf("NewEAX failed: %v", err)
|
||||
}
|
||||
ct, tag, err := eax.Encrypt(nonce, header, plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
pt, err := eax.Decrypt(nonce, header, ct, tag)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt failed: %v", err)
|
||||
}
|
||||
if len(pt) != 0 {
|
||||
t.Errorf("expected empty plaintext, got %d bytes", len(pt))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEAXTagIs8Bytes(t *testing.T) {
|
||||
key := []byte("c:\\windows\\syste")
|
||||
nonce := []byte("m\\firewall32.cpl")
|
||||
eax, _ := crypto.NewEAX(key)
|
||||
_, tag, err := eax.Encrypt(nonce, nil, []byte("test"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tag) != 8 {
|
||||
t.Errorf("tag length = %d, want 8 (TeamSpeak 64-bit tag)", len(tag))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEAXKnownVector(t *testing.T) {
|
||||
// Encrypt with known key/nonce/plaintext, then verify decrypt produces original.
|
||||
// The exact ciphertext is implementation-specific; we validate the roundtrip
|
||||
// and check that ciphertext differs from plaintext.
|
||||
key := []byte("c:\\windows\\syste")
|
||||
nonce := []byte("m\\firewall32.cpl")
|
||||
header := []byte{0x00, 0x65, 0x00, 0x00, 0x08}
|
||||
plaintext := []byte{0x01, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
|
||||
eax, _ := crypto.NewEAX(key)
|
||||
ct, tag, err := eax.Encrypt(nonce, header, plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
if bytes.Equal(ct, plaintext) {
|
||||
t.Error("ciphertext should differ from plaintext")
|
||||
}
|
||||
pt, err := eax.Decrypt(nonce, header, ct, tag)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt failed: %v", err)
|
||||
}
|
||||
if hex.EncodeToString(pt) != hex.EncodeToString(plaintext) {
|
||||
t.Errorf("decrypted mismatch: got %x, want %x", pt, plaintext)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"errors"
|
||||
|
||||
"github.com/oasisprotocol/curve25519-voi/curve"
|
||||
"github.com/oasisprotocol/curve25519-voi/curve/scalar"
|
||||
)
|
||||
|
||||
var errInvalidKeyLength = errors.New("invalid key length")
|
||||
|
||||
const (
|
||||
curve25519KeySize = 32
|
||||
clampMaskLow = 248
|
||||
clampMaskHigh = 127
|
||||
clampHighBit = 64
|
||||
sharedSignBit = 0x80
|
||||
privateKeyTopMask = 0x7F
|
||||
)
|
||||
|
||||
func GenerateTemporaryKey() ([]byte, []byte, error) {
|
||||
privateKey := make([]byte, curve25519KeySize)
|
||||
_, err := rand.Read(privateKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ClampScalar(privateKey)
|
||||
sc, err := scalar.NewFromBits(privateKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
publicKey, err := curve.NewEdwardsPoint().MulBasepoint(curve.ED25519_BASEPOINT_TABLE, sc).MarshalBinary()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return publicKey, privateKey, nil
|
||||
}
|
||||
|
||||
func Sign(priv *ecdsa.PrivateKey, data []byte) ([]byte, error) {
|
||||
hash := sha256.Sum256(data)
|
||||
|
||||
return ecdsa.SignASN1(rand.Reader, priv, hash[:])
|
||||
}
|
||||
|
||||
func VerifySign(pub *ecdsa.PublicKey, data, sig []byte) bool {
|
||||
hash := sha256.Sum256(data)
|
||||
|
||||
return ecdsa.VerifyASN1(pub, hash[:], sig)
|
||||
}
|
||||
|
||||
func ClampScalar(key []byte) {
|
||||
if len(key) < curve25519KeySize {
|
||||
return
|
||||
}
|
||||
key[0] &= clampMaskLow
|
||||
key[curve25519KeySize-1] &= clampMaskHigh
|
||||
key[curve25519KeySize-1] |= clampHighBit
|
||||
}
|
||||
|
||||
func GetSharedSecret2(publicKey, privateKey []byte) ([]byte, error) {
|
||||
if len(publicKey) != curve25519KeySize || len(privateKey) != curve25519KeySize {
|
||||
return nil, errInvalidKeyLength
|
||||
}
|
||||
|
||||
privateKeyCpy := make([]byte, curve25519KeySize)
|
||||
copy(privateKeyCpy, privateKey)
|
||||
privateKeyCpy[curve25519KeySize-1] &= privateKeyTopMask
|
||||
sc, err := scalar.NewFromBits(privateKeyCpy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pub := curve.NewEdwardsPoint()
|
||||
err = pub.UnmarshalBinary(publicKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pub.Neg(pub)
|
||||
|
||||
sharedPoint := curve.NewEdwardsPoint().Mul(pub, sc)
|
||||
shared, err := sharedPoint.MarshalBinary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shared[curve25519KeySize-1] ^= sharedSignBit
|
||||
|
||||
hash := sha512.Sum512(shared)
|
||||
|
||||
return hash[:], nil
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
errEmptyAddress = errors.New("empty address")
|
||||
errNicknameNotFound = errors.New("nickname not found")
|
||||
errEmptyResponseBody = errors.New("empty response")
|
||||
errTSDNSNotFound = errors.New("not found")
|
||||
)
|
||||
|
||||
const (
|
||||
TsDnsDefaultPort = "41144"
|
||||
NicknameLookup = "https://named.myteamspeak.com/lookup"
|
||||
CacheTTL = 10 * time.Minute
|
||||
)
|
||||
|
||||
// ResolvedAddr is one resolved host:port and how it was obtained.
|
||||
type ResolvedAddr struct {
|
||||
Expiry time.Time
|
||||
Addr string
|
||||
Source string
|
||||
}
|
||||
|
||||
// Resolver resolves TeamSpeak-style addresses (nickname, SRV, TSDNS) with TTL cache.
|
||||
type Resolver struct {
|
||||
log *slog.Logger
|
||||
cache map[string][]ResolvedAddr
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewResolver returns a Resolver using log for debug tracing (nil → slog.Default).
|
||||
func NewResolver(log *slog.Logger) *Resolver {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
|
||||
return &Resolver{
|
||||
log: log,
|
||||
cache: make(map[string][]ResolvedAddr),
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve tries, in order: MyTeamSpeak nickname, _ts3._udp SRV, TSDNS via SRV and :41144, then plain DNS.
|
||||
func (r *Resolver) Resolve(ctx context.Context, inputAddr string) ([]ResolvedAddr, error) {
|
||||
if inputAddr == "" {
|
||||
return nil, errEmptyAddress
|
||||
}
|
||||
|
||||
if cached, ok := r.getValidCache(inputAddr); ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
host, port := splitHostPortOrDefault(inputAddr)
|
||||
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return []ResolvedAddr{{Addr: net.JoinHostPort(host, port), Source: "Direct"}}, nil
|
||||
}
|
||||
|
||||
if !strings.Contains(host, ".") && host != "localhost" {
|
||||
if nickAddr, ok := r.resolveNicknameAddr(ctx, host); ok {
|
||||
return r.Resolve(ctx, nickAddr)
|
||||
}
|
||||
}
|
||||
|
||||
if results, ok := r.resolveSRV(ctx, host); ok {
|
||||
return r.setCache(inputAddr, results), nil
|
||||
}
|
||||
|
||||
domainList := getDomainList(host)
|
||||
|
||||
if tsdnsAddr, ok := r.resolveTSDNSSRV(ctx, domainList, host); ok {
|
||||
results := []ResolvedAddr{{Addr: tsdnsAddr, Source: "TSDNS-SRV"}}
|
||||
|
||||
return r.setCache(inputAddr, results), nil
|
||||
}
|
||||
|
||||
if tsdnsAddr, ok := r.resolveTSDNSDirect(ctx, domainList, host); ok {
|
||||
results := []ResolvedAddr{{Addr: tsdnsAddr, Source: "TSDNS-Direct"}}
|
||||
|
||||
return r.setCache(inputAddr, results), nil
|
||||
}
|
||||
|
||||
r.log.Debug("falling back to direct dns", slog.String("host", host), slog.String("port", port))
|
||||
results := []ResolvedAddr{{
|
||||
Addr: net.JoinHostPort(host, port),
|
||||
Source: "Direct",
|
||||
}}
|
||||
|
||||
return r.setCache(inputAddr, results), nil
|
||||
}
|
||||
|
||||
func (r *Resolver) getValidCache(inputAddr string) ([]ResolvedAddr, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
cached, ok := r.cache[inputAddr]
|
||||
if !ok || len(cached) == 0 || time.Now().After(cached[0].Expiry) {
|
||||
return nil, false
|
||||
}
|
||||
r.log.Debug("cache hit", slog.String("addr", inputAddr), slog.String("source", cached[0].Source))
|
||||
|
||||
return cached, true
|
||||
}
|
||||
|
||||
func splitHostPortOrDefault(inputAddr string) (string, string) {
|
||||
host, port, err := net.SplitHostPort(inputAddr)
|
||||
if err != nil {
|
||||
return inputAddr, "9987"
|
||||
}
|
||||
|
||||
return host, port
|
||||
}
|
||||
|
||||
func (r *Resolver) resolveNicknameAddr(ctx context.Context, host string) (string, bool) {
|
||||
r.log.Debug("trying nickname resolution", slog.String("nickname", host))
|
||||
nickAddr, err := resolveNickname(ctx, host)
|
||||
if err != nil || nickAddr == "" {
|
||||
r.log.Debug("nickname resolution failed", slog.String("nickname", host), slog.Any("error", err))
|
||||
|
||||
return "", false
|
||||
}
|
||||
r.log.Debug("nickname resolved", slog.String("nickname", host), slog.String("result", nickAddr))
|
||||
|
||||
return nickAddr, true
|
||||
}
|
||||
|
||||
func (r *Resolver) resolveSRV(ctx context.Context, host string) ([]ResolvedAddr, bool) {
|
||||
r.log.Debug("trying dns srv", slog.String("host", host))
|
||||
_, srvs, err := net.DefaultResolver.LookupSRV(ctx, "ts3", "udp", host)
|
||||
if err != nil || len(srvs) == 0 {
|
||||
r.log.Debug("dns srv failed", slog.String("host", host), slog.Any("error", err))
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
results := make([]ResolvedAddr, 0, len(srvs))
|
||||
for _, srv := range srvs {
|
||||
target := strings.TrimSuffix(srv.Target, ".")
|
||||
results = append(results, ResolvedAddr{
|
||||
Addr: net.JoinHostPort(target, strconv.FormatUint(uint64(srv.Port), 10)),
|
||||
Source: "SRV",
|
||||
})
|
||||
}
|
||||
r.log.Debug("dns srv succeeded", slog.String("host", host), slog.String("result", results[0].Addr))
|
||||
|
||||
return results, true
|
||||
}
|
||||
|
||||
func (r *Resolver) resolveTSDNSSRV(ctx context.Context, domains []string, queryHost string) (string, bool) {
|
||||
for _, domain := range domains {
|
||||
r.log.Debug("trying tsdns srv", slog.String("domain", domain))
|
||||
_, srvs, err := net.DefaultResolver.LookupSRV(ctx, "tsdns", "tcp", domain)
|
||||
if err != nil || len(srvs) == 0 {
|
||||
r.log.Debug("tsdns srv failed", slog.String("domain", domain))
|
||||
|
||||
continue
|
||||
}
|
||||
for _, srv := range srvs {
|
||||
target := strings.TrimSuffix(srv.Target, ".")
|
||||
tsdnsAddr, queryErr := queryTSDNS(
|
||||
ctx, net.JoinHostPort(target, strconv.FormatUint(uint64(srv.Port), 10)), queryHost,
|
||||
)
|
||||
if queryErr == nil && tsdnsAddr != "" {
|
||||
r.log.Debug("tsdns srv succeeded", slog.String("domain", domain), slog.String("result", tsdnsAddr))
|
||||
|
||||
return tsdnsAddr, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (r *Resolver) resolveTSDNSDirect(ctx context.Context, domains []string, queryHost string) (string, bool) {
|
||||
for _, domain := range domains {
|
||||
r.log.Debug("trying tsdns direct", slog.String("domain", domain))
|
||||
tsdnsAddr, err := queryTSDNS(ctx, net.JoinHostPort(domain, TsDnsDefaultPort), queryHost)
|
||||
if err == nil && tsdnsAddr != "" {
|
||||
r.log.Debug("tsdns direct succeeded", slog.String("domain", domain), slog.String("result", tsdnsAddr))
|
||||
|
||||
return tsdnsAddr, true
|
||||
}
|
||||
r.log.Debug("tsdns direct failed", slog.String("domain", domain))
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (r *Resolver) setCache(key string, results []ResolvedAddr) []ResolvedAddr {
|
||||
expiry := time.Now().Add(CacheTTL)
|
||||
for i := range results {
|
||||
results[i].Expiry = expiry
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.cache[key] = results
|
||||
r.mu.Unlock()
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// getDomainList returns host suffixes ordered longest-first (for TSDNS), capped at three.
|
||||
func getDomainList(host string) []string {
|
||||
parts := strings.Split(host, ".")
|
||||
list := make([]string, 0, len(parts)-1)
|
||||
for i := range len(parts) - 1 {
|
||||
list = append(list, strings.Join(parts[i:], "."))
|
||||
}
|
||||
if len(list) > 3 {
|
||||
return list[:3]
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
func resolveNickname(ctx context.Context, nickname string) (string, error) {
|
||||
lookupURL, err := url.Parse(NicknameLookup)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
query := lookupURL.Query()
|
||||
query.Set("name", nickname)
|
||||
lookupURL.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, lookupURL.String(), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", errNicknameNotFound
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
lines := strings.Split(string(body), "\n")
|
||||
if len(lines) > 0 && lines[0] != "" {
|
||||
return strings.TrimSpace(lines[0]), nil
|
||||
}
|
||||
|
||||
return "", errEmptyResponseBody
|
||||
}
|
||||
|
||||
func queryTSDNS(ctx context.Context, tsdnsFullAddr, queryHost string) (string, error) {
|
||||
d := net.Dialer{Timeout: 2 * time.Second}
|
||||
conn, err := d.DialContext(ctx, "tcp", tsdnsFullAddr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
|
||||
_, err = fmt.Fprintf(conn, "%s\n", queryHost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(conn)
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || line == "404" || line == "errors" {
|
||||
return "", errTSDNSNotFound
|
||||
}
|
||||
|
||||
return line, nil
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package discovery
|
||||
|
||||
// White-box tests for the discovery package.
|
||||
// Uses package discovery (not discovery_test) to access unexported helpers.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestResolver() *Resolver {
|
||||
return NewResolver(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})))
|
||||
}
|
||||
|
||||
func TestGetDomainListSingleSegment(t *testing.T) {
|
||||
// "foo.com" → one entry ("foo.com"), then loop ends at len-1=1
|
||||
list := getDomainList("foo.com")
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("len = %d, want 1; list = %v", len(list), list)
|
||||
}
|
||||
if list[0] != "foo.com" {
|
||||
t.Errorf("list[0] = %q, want %q", list[0], "foo.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainListMultipleSegments(t *testing.T) {
|
||||
// "a.b.c.com" → ["a.b.c.com", "b.c.com", "c.com"] (3 entries, within limit)
|
||||
list := getDomainList("a.b.c.com")
|
||||
want := []string{"a.b.c.com", "b.c.com", "c.com"}
|
||||
if len(list) != len(want) {
|
||||
t.Fatalf("len = %d, want %d; list = %v", len(list), len(want), list)
|
||||
}
|
||||
for i, w := range want {
|
||||
if list[i] != w {
|
||||
t.Errorf("list[%d] = %q, want %q", i, list[i], w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainListDepthCapped(t *testing.T) {
|
||||
// Deep domain: more than 3 sub-entries should be capped to 3
|
||||
list := getDomainList("a.b.c.d.e.com")
|
||||
if len(list) != 3 {
|
||||
t.Errorf("len = %d, want 3 (capped); list = %v", len(list), list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainListTwoSegments(t *testing.T) {
|
||||
// "sub.example.com" → ["sub.example.com", "example.com"]
|
||||
list := getDomainList("sub.example.com")
|
||||
want := []string{"sub.example.com", "example.com"}
|
||||
if len(list) != len(want) {
|
||||
t.Fatalf("len = %d, want %d; list = %v", len(list), len(want), list)
|
||||
}
|
||||
for i, w := range want {
|
||||
if list[i] != w {
|
||||
t.Errorf("list[%d] = %q, want %q", i, list[i], w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolver.Resolve — paths that do not require network I/O
|
||||
|
||||
func TestResolveEmptyAddress(t *testing.T) {
|
||||
r := newTestResolver()
|
||||
_, err := r.Resolve(context.Background(), "")
|
||||
if err == nil {
|
||||
t.Error("expected error for empty address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIPv4Direct(t *testing.T) {
|
||||
r := newTestResolver()
|
||||
addrs, err := r.Resolve(context.Background(), "192.168.1.1:9987")
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve failed: %v", err)
|
||||
}
|
||||
if len(addrs) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(addrs))
|
||||
}
|
||||
if addrs[0].Addr != "192.168.1.1:9987" {
|
||||
t.Errorf("Addr = %q, want %q", addrs[0].Addr, "192.168.1.1:9987")
|
||||
}
|
||||
if addrs[0].Source != "Direct" {
|
||||
t.Errorf("Source = %q, want Direct", addrs[0].Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIPv4NoPort(t *testing.T) {
|
||||
// No port → default port 9987 is applied
|
||||
r := newTestResolver()
|
||||
addrs, err := r.Resolve(context.Background(), "10.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve failed: %v", err)
|
||||
}
|
||||
if len(addrs) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(addrs))
|
||||
}
|
||||
if addrs[0].Addr != "10.0.0.1:9987" {
|
||||
t.Errorf("Addr = %q, want 10.0.0.1:9987", addrs[0].Addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIPv6Direct(t *testing.T) {
|
||||
r := newTestResolver()
|
||||
addrs, err := r.Resolve(context.Background(), "[::1]:9987")
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve failed: %v", err)
|
||||
}
|
||||
if len(addrs) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(addrs))
|
||||
}
|
||||
if addrs[0].Source != "Direct" {
|
||||
t.Errorf("Source = %q, want Direct", addrs[0].Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIPNotCached(t *testing.T) {
|
||||
// IP addresses bypass cache entirely: Expiry is zero-value
|
||||
r := newTestResolver()
|
||||
addrs, err := r.Resolve(context.Background(), "127.0.0.1:9987")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !addrs[0].Expiry.IsZero() {
|
||||
t.Error("IP resolution should not set a cache expiry")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Package teamspeak implements a TeamSpeak 3 (and compatible) client over UDP:
|
||||
// handshake, EAX-encrypted packets, commands, notifications, optional voice, and file transfer helpers.
|
||||
//
|
||||
// Use [NewClient] with a [*crypto.Identity], call [Client.Connect], then [Client.WaitConnected]
|
||||
// before sending commands or relying on client ID. Subpackages cover crypto identities,
|
||||
// DNS/TSDNS resolution, transport framing, and TS3 command escaping/parsing.
|
||||
//
|
||||
// This is a clean-room implementation without the proprietary TeamSpeak SDK.
|
||||
package teamspeak
|
||||
@@ -0,0 +1,125 @@
|
||||
package teamspeak
|
||||
|
||||
// OnTextMessage registers a handler for incoming text messages.
|
||||
func (c *Client) OnTextMessage(handler func(TextMessage)) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.textMsgHandlers = append(c.textMsgHandlers, handler)
|
||||
}
|
||||
|
||||
// OnClientEnter registers a handler for clients entering view.
|
||||
func (c *Client) OnClientEnter(handler func(ClientInfo)) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.clientEnterHandlers = append(c.clientEnterHandlers, handler)
|
||||
}
|
||||
|
||||
// OnClientLeave registers a handler for clients leaving view.
|
||||
func (c *Client) OnClientLeave(handler func(ClientLeftViewEvent)) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.clientLeaveHandlers = append(c.clientLeaveHandlers, handler)
|
||||
}
|
||||
|
||||
// OnPoked registers a handler for when this client is poked by another user.
|
||||
func (c *Client) OnPoked(handler func(PokeEvent)) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.pokedHandlers = append(c.pokedHandlers, handler)
|
||||
}
|
||||
|
||||
// OnKicked registers a handler when this client is kicked (channel or server).
|
||||
func (c *Client) OnKicked(handler func(string)) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.kickedHandlers = append(c.kickedHandlers, handler)
|
||||
}
|
||||
|
||||
// OnVoiceData registers a handler for incoming voice packets.
|
||||
func (c *Client) OnVoiceData(handler func(VoiceDataEvent)) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.voiceDataHandlers = append(c.voiceDataHandlers, handler)
|
||||
}
|
||||
|
||||
// OnClientMoved registers a handler for clients moving between channels.
|
||||
func (c *Client) OnClientMoved(handler func(ClientMovedEvent)) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.clientMoveHandlers = append(c.clientMoveHandlers, handler)
|
||||
}
|
||||
|
||||
// OnConnected registers a handler for when the client is fully connected.
|
||||
func (c *Client) OnConnected(handler func()) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.connectedHandlers = append(c.connectedHandlers, handler)
|
||||
}
|
||||
|
||||
// OnDisconnected registers a handler for when the client is disconnected.
|
||||
func (c *Client) OnDisconnected(handler func(error)) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.disconnectedHandlers = append(c.disconnectedHandlers, handler)
|
||||
}
|
||||
|
||||
func (c *Client) UseCommandMiddleware(mw ...CommandMiddleware) {
|
||||
c.cmdMiddlewares = append(c.cmdMiddlewares, mw...)
|
||||
c.rebuildMiddlewareChains()
|
||||
}
|
||||
|
||||
func (c *Client) UseEventMiddleware(mw ...EventMiddleware) {
|
||||
c.eventMiddlewares = append(c.eventMiddlewares, mw...)
|
||||
c.rebuildMiddlewareChains()
|
||||
}
|
||||
|
||||
func (c *Client) rebuildMiddlewareChains() {
|
||||
c.finalCmdHandler = func(cmd string) error {
|
||||
return c.handler.SendPacket(2, []byte(cmd), 0)
|
||||
}
|
||||
for i := len(c.cmdMiddlewares) - 1; i >= 0; i-- {
|
||||
c.finalCmdHandler = c.cmdMiddlewares[i](c.finalCmdHandler)
|
||||
}
|
||||
|
||||
c.finalEvtHandler = func(evt any) {
|
||||
c.dispatchEvent(evt)
|
||||
}
|
||||
for i := len(c.eventMiddlewares) - 1; i >= 0; i-- {
|
||||
c.finalEvtHandler = c.eventMiddlewares[i](c.finalEvtHandler)
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchEvent 同步分发事件到所有已注册的 handler。
|
||||
// 由 startEventLoop 协程调用,保证事件按到达顺序串行处理。
|
||||
func (c *Client) dispatchEvent(evt any) {
|
||||
switch e := evt.(type) {
|
||||
case TextMessage:
|
||||
for _, h := range c.textMsgHandlers {
|
||||
h(e)
|
||||
}
|
||||
case ClientInfo:
|
||||
for _, h := range c.clientEnterHandlers {
|
||||
h(e)
|
||||
}
|
||||
case ClientLeftViewEvent:
|
||||
for _, h := range c.clientLeaveHandlers {
|
||||
h(e)
|
||||
}
|
||||
case ClientMovedEvent:
|
||||
for _, h := range c.clientMoveHandlers {
|
||||
h(e)
|
||||
}
|
||||
case PokeEvent:
|
||||
for _, h := range c.pokedHandlers {
|
||||
h(e)
|
||||
}
|
||||
case kickEvent:
|
||||
for _, h := range c.kickedHandlers {
|
||||
h(e.reason)
|
||||
}
|
||||
case VoiceDataEvent:
|
||||
for _, h := range c.voiceDataHandlers {
|
||||
h(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package teamspeak
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestResetForConnectKeepsEventLoopAlive(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
voice := make(chan VoiceDataEvent, 1)
|
||||
c.OnVoiceData(func(event VoiceDataEvent) { voice <- event })
|
||||
|
||||
c.mu.Lock()
|
||||
c.resetForConnectLocked()
|
||||
c.mu.Unlock()
|
||||
c.notifyEvent(VoiceDataEvent{ClientID: 7, Sequence: 11, Data: []byte{1}})
|
||||
|
||||
select {
|
||||
case event := <-voice:
|
||||
if event.ClientID != 7 || event.Sequence != 11 {
|
||||
t.Fatalf("unexpected voice event: %+v", event)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("event loop stopped during connection reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnTextMessage_RegistersHandler(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
called := make(chan TextMessage, 1)
|
||||
c.OnTextMessage(func(m TextMessage) { called <- m })
|
||||
c.rebuildMiddlewareChains()
|
||||
|
||||
c.finalEvtHandler(TextMessage{Message: "hi"})
|
||||
|
||||
select {
|
||||
case m := <-called:
|
||||
if m.Message != "hi" {
|
||||
t.Errorf("expected 'hi', got %q", m.Message)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("OnTextMessage handler not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnClientLeave_RegistersHandler(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
called := make(chan ClientLeftViewEvent, 1)
|
||||
c.OnClientLeave(func(e ClientLeftViewEvent) { called <- e })
|
||||
c.rebuildMiddlewareChains()
|
||||
|
||||
c.finalEvtHandler(ClientLeftViewEvent{ID: 5})
|
||||
|
||||
select {
|
||||
case e := <-called:
|
||||
if e.ID != 5 {
|
||||
t.Errorf("expected ID=5, got %d", e.ID)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("OnClientLeave handler not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnDisconnected_RegistersHandler(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
called := make(chan error, 1)
|
||||
c.OnDisconnected(func(err error) { called <- err })
|
||||
|
||||
// OnDisconnected handlers are called by onClosed; fire it directly.
|
||||
c.mu.Lock()
|
||||
handlers := c.disconnectedHandlers
|
||||
c.mu.Unlock()
|
||||
|
||||
for _, h := range handlers {
|
||||
go h(nil)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-called:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("OnDisconnected handler not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleHandlers_AllCalled(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
a := make(chan struct{}, 1)
|
||||
b := make(chan struct{}, 1)
|
||||
c.OnTextMessage(func(_ TextMessage) { a <- struct{}{} })
|
||||
c.OnTextMessage(func(_ TextMessage) { b <- struct{}{} })
|
||||
c.rebuildMiddlewareChains()
|
||||
|
||||
c.finalEvtHandler(TextMessage{Message: "test"})
|
||||
|
||||
for _, ch := range []chan struct{}{a, b} {
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("not all handlers called")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseCommandMiddleware_InterceptsCommands(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
intercepted := make(chan string, 1)
|
||||
c.UseCommandMiddleware(func(next func(string) error) func(string) error {
|
||||
return func(cmd string) error {
|
||||
intercepted <- cmd
|
||||
|
||||
return next(cmd)
|
||||
}
|
||||
})
|
||||
|
||||
err := c.finalCmdHandler("test cmd")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case cmd := <-intercepted:
|
||||
if cmd != "test cmd" {
|
||||
t.Errorf("expected 'test cmd', got %q", cmd)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("middleware not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseCommandMiddleware_ChainOrder(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
var order []string
|
||||
c.UseCommandMiddleware(
|
||||
func(next func(string) error) func(string) error {
|
||||
return func(cmd string) error {
|
||||
order = append(order, "first")
|
||||
|
||||
return next(cmd)
|
||||
}
|
||||
},
|
||||
func(next func(string) error) func(string) error {
|
||||
return func(cmd string) error {
|
||||
order = append(order, "second")
|
||||
|
||||
return next(cmd)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
_ = c.finalCmdHandler("x")
|
||||
|
||||
if len(order) != 2 || order[0] != "first" || order[1] != "second" {
|
||||
t.Errorf("unexpected middleware order: %v", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseCommandMiddleware_CanShortCircuit(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
sent := make(chan string, 1)
|
||||
c.UseCommandMiddleware(func(next func(string) error) func(string) error {
|
||||
return func(cmd string) error {
|
||||
if cmd == "blocked" {
|
||||
return nil // don't call next
|
||||
}
|
||||
|
||||
return next(cmd)
|
||||
}
|
||||
})
|
||||
|
||||
// Wrap the base handler to detect if it was called.
|
||||
origBase := c.finalCmdHandler
|
||||
c.finalCmdHandler = func(cmd string) error {
|
||||
sent <- cmd
|
||||
|
||||
return origBase(cmd)
|
||||
}
|
||||
// Re-apply middleware on top of new base.
|
||||
c.UseCommandMiddleware()
|
||||
|
||||
_ = c.SendCommandNoWait("blocked")
|
||||
|
||||
select {
|
||||
case <-sent:
|
||||
t.Error("short-circuited command should not reach base handler")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseEventMiddleware_InterceptsEvents(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
intercepted := make(chan any, 1)
|
||||
c.UseEventMiddleware(func(next func(any)) func(any) {
|
||||
return func(evt any) {
|
||||
intercepted <- evt
|
||||
next(evt)
|
||||
}
|
||||
})
|
||||
|
||||
c.finalEvtHandler(TextMessage{Message: "intercepted"})
|
||||
|
||||
select {
|
||||
case evt := <-intercepted:
|
||||
if m, ok := evt.(TextMessage); !ok || m.Message != "intercepted" {
|
||||
t.Errorf("unexpected event: %v", evt)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("event middleware not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseEventMiddleware_CanFilter(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
reached := make(chan TextMessage, 1)
|
||||
c.OnTextMessage(func(m TextMessage) { reached <- m })
|
||||
|
||||
// Filter: block all text messages.
|
||||
c.UseEventMiddleware(func(next func(any)) func(any) {
|
||||
return func(evt any) {
|
||||
if _, ok := evt.(TextMessage); ok {
|
||||
return // drop
|
||||
}
|
||||
next(evt)
|
||||
}
|
||||
})
|
||||
|
||||
c.finalEvtHandler(TextMessage{Message: "filtered"})
|
||||
|
||||
select {
|
||||
case <-reached:
|
||||
t.Error("filtered event should not reach handler")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalEvtHandler_UnknownType_NoPanic(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
// Should not panic for an unhandled event type.
|
||||
c.finalEvtHandler(struct{ X int }{X: 42})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
module github.com/honeybbq/teamspeak-go
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/oasisprotocol/curve25519-voi v0.0.0-20251114093237-2ab5a27a1729
|
||||
github.com/tink-crypto/tink-go/v2 v2.6.0
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
github.com/oasisprotocol/curve25519-voi v0.0.0-20251114093237-2ab5a27a1729 h1:yfQ2sO9WJXUAIUR+g7NUkxJSKCAFJcR5sUDu+ZmjTZI=
|
||||
github.com/oasisprotocol/curve25519-voi v0.0.0-20251114093237-2ab5a27a1729/go.mod h1:hVoHR2EVESiICEMbg137etN/Lx+lSrHPTD39Z/uE+2s=
|
||||
github.com/tink-crypto/tink-go/v2 v2.6.0 h1:+KHNBHhWH33Vn+igZWcsgdEPUxKwBMEe0QC60t388v4=
|
||||
github.com/tink-crypto/tink-go/v2 v2.6.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
@@ -0,0 +1,190 @@
|
||||
package teamspeak
|
||||
|
||||
import (
|
||||
"crypto/sha1" //nolint:gosec // SHA-1 used for TS3 HWID/UID format, not security
|
||||
"encoding/base64"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/commands"
|
||||
"github.com/honeybbq/teamspeak-go/crypto"
|
||||
"github.com/honeybbq/teamspeak-go/handshake"
|
||||
"github.com/honeybbq/teamspeak-go/transport"
|
||||
)
|
||||
|
||||
func (c *Client) handleHandshakeInitIV(cmd *commands.Command) {
|
||||
c.logger.Info("received crypto negotiation")
|
||||
alpha := cmd.Params["alpha"]
|
||||
beta := cmd.Params["beta"]
|
||||
omega := cmd.Params["omega"]
|
||||
|
||||
err := c.crypt.InitCrypto(alpha, beta, omega)
|
||||
if err != nil {
|
||||
c.logger.Error("failed to initialize crypto", slog.Any("error", err))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c.logger.Info("crypto initialized, sending clientinit")
|
||||
c.sendClientInit()
|
||||
}
|
||||
|
||||
func (c *Client) handleHandshakeExpand2(cmd *commands.Command) {
|
||||
c.logger.Info("received initivexpand2")
|
||||
c.handler.ReceivedFinalInitAck()
|
||||
license := cmd.Params["l"]
|
||||
omega := cmd.Params["omega"]
|
||||
proof := cmd.Params["proof"]
|
||||
beta := cmd.Params["beta"]
|
||||
|
||||
privateKey, err := c.sendClientEkPacket(beta)
|
||||
if err != nil {
|
||||
c.logger.Warn("failed to send clientek", slog.Any("error", err))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
err = handshake.CryptoInit2(c.crypt, license, omega, proof, beta, privateKey)
|
||||
if err != nil {
|
||||
c.logger.Error("crypto init2 failed", slog.Any("error", err))
|
||||
|
||||
return
|
||||
}
|
||||
c.sendClientInit()
|
||||
}
|
||||
|
||||
func (c *Client) sendClientEkPacket(beta string) ([]byte, error) {
|
||||
publicKey, privateKey, err := crypto.GenerateTemporaryKey()
|
||||
if err != nil {
|
||||
c.logger.Error("failed to generate temporary key", slog.Any("error", err))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
ekBase64 := base64.StdEncoding.EncodeToString(publicKey)
|
||||
|
||||
clientProof, err := c.buildClientEkProof(publicKey, beta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clientEk := commands.BuildCommandOrdered("clientek", [][2]string{
|
||||
{"ek", ekBase64},
|
||||
{"proof", clientProof},
|
||||
})
|
||||
c.logger.Debug("sending clientek", slog.String("ek", ekBase64))
|
||||
err = c.handler.SendPacket(byte(transport.PacketTypeCommand), []byte(clientEk), 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return privateKey, nil
|
||||
}
|
||||
|
||||
func (c *Client) buildClientEkProof(publicKey []byte, beta string) (string, error) {
|
||||
betaBytes, err := base64.StdEncoding.DecodeString(beta)
|
||||
if err != nil {
|
||||
c.logger.Error("failed to decode beta", slog.Any("error", err))
|
||||
|
||||
return "", err
|
||||
}
|
||||
toSign := make([]byte, 86)
|
||||
copy(toSign, publicKey)
|
||||
if len(betaBytes) > 54 {
|
||||
betaBytes = betaBytes[:54]
|
||||
}
|
||||
copy(toSign[32:], betaBytes)
|
||||
sign, err := crypto.Sign(c.crypt.Identity.PrivateKey, toSign)
|
||||
if err != nil {
|
||||
c.logger.Error("failed to sign clientek proof", slog.Any("error", err))
|
||||
|
||||
return "", err
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(sign), nil
|
||||
}
|
||||
|
||||
func (c *Client) handleInitServer(cmd *commands.Command) {
|
||||
c.mu.Lock()
|
||||
c.status = StatusConnected
|
||||
|
||||
idStr := ""
|
||||
if v, ok := cmd.Params["aclid"]; ok {
|
||||
idStr = v
|
||||
} else if v, ok := cmd.Params["clid"]; ok {
|
||||
idStr = v
|
||||
}
|
||||
|
||||
if idStr != "" {
|
||||
val, err := strconv.ParseUint(idStr, 10, 16)
|
||||
if err == nil {
|
||||
c.clid = uint16(val)
|
||||
c.handler.SetClientID(c.clid)
|
||||
}
|
||||
}
|
||||
handlers := c.connectedHandlers
|
||||
c.mu.Unlock()
|
||||
|
||||
c.logger.Info("connected to server", slog.Uint64("self_id", uint64(c.clid)))
|
||||
|
||||
c.connectedErr = nil
|
||||
select {
|
||||
case <-c.connectedChan:
|
||||
default:
|
||||
close(c.connectedChan)
|
||||
}
|
||||
|
||||
go func() {
|
||||
updateCmd := commands.BuildCommand("clientupdate", map[string]string{
|
||||
"client_input_muted": "0",
|
||||
"client_output_muted": "0",
|
||||
})
|
||||
_ = c.SendCommandNoWait(updateCmd)
|
||||
}()
|
||||
|
||||
for _, h := range handlers {
|
||||
go h()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) sendClientInit() {
|
||||
cmd := c.buildClientInitCommand()
|
||||
err := c.handler.SendPacket(byte(transport.PacketTypeCommand), []byte(cmd), 0)
|
||||
if err != nil {
|
||||
c.logger.Warn("failed to send clientinit", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) buildClientInitCommand() string {
|
||||
pubKeyBase64 := c.crypt.Identity.PublicKeyBase64()
|
||||
// HWID matches TS3 client UID format: base64(SHA1(publicKeyBase64))
|
||||
hwidSum := sha1.Sum([]byte(pubKeyBase64)) //nolint:gosec
|
||||
hwid := base64.StdEncoding.EncodeToString(hwidSum[:])
|
||||
defaultChannelPassword := prepareClientPassword(c.clientInitOptions.defaultChannelPassword)
|
||||
serverPassword := prepareClientPassword(c.clientInitOptions.serverPassword)
|
||||
|
||||
return commands.BuildCommandOrdered("clientinit", [][2]string{
|
||||
{"client_nickname", c.nickname},
|
||||
{"client_version", "3.?.? [Build: 5680278000]"},
|
||||
{"client_platform", "Windows"},
|
||||
{"client_input_hardware", "1"},
|
||||
{"client_output_hardware", "1"},
|
||||
{"client_default_channel", c.clientInitOptions.defaultChannel},
|
||||
{"client_default_channel_password", defaultChannelPassword},
|
||||
{"client_server_password", serverPassword},
|
||||
{"client_meta_data", ""},
|
||||
{"client_version_sign", "DX5NIYLvfJEUjuIbCidnoeozxIDRRkpq3I9vVMBmE9L2qnekOoBzSenkzsg2lC9CMv8K5hkEzhr2TYUYSwUXCg=="},
|
||||
{"client_key_offset", strconv.FormatUint(c.crypt.Identity.Offset, 10)},
|
||||
{"client_nickname_phonetic", ""},
|
||||
{"client_default_token", ""},
|
||||
{"hwid", hwid},
|
||||
})
|
||||
}
|
||||
|
||||
func prepareClientPassword(password string) string {
|
||||
if password == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
sum := sha1.Sum([]byte(password)) //nolint:gosec // TeamSpeak protocol requires base64(sha1(password))
|
||||
|
||||
return base64.StdEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package handshake
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/commands"
|
||||
"github.com/honeybbq/teamspeak-go/crypto"
|
||||
)
|
||||
|
||||
const InitVersion = 1566914096 // 3.5.0 [Stable]
|
||||
const (
|
||||
initVersionLen = 4
|
||||
initTypeLen = 1
|
||||
initStepLen = 21
|
||||
)
|
||||
|
||||
// ProcessInit1 handles the TS3INIT1 handshake steps.
|
||||
func ProcessInit1(tc *crypto.Crypt, data []byte) []byte {
|
||||
if data == nil || (len(data) >= 1 && data[0] == 0x7F) {
|
||||
return buildInit1StartPacket()
|
||||
}
|
||||
|
||||
switch data[0] {
|
||||
case 0:
|
||||
return buildInit1Step1Packet(data)
|
||||
case 1:
|
||||
return buildInit1Step2Packet(data)
|
||||
case 2:
|
||||
return buildInit1Step3Packet(data)
|
||||
case 3:
|
||||
return buildInit1Step4Packet(tc, data)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildInit1StartPacket() []byte {
|
||||
sendData := make([]byte, initVersionLen+initTypeLen+4+4+8)
|
||||
binary.BigEndian.PutUint32(sendData[0:4], InitVersion)
|
||||
sendData[4] = 0x00
|
||||
nowUnix := time.Now().Unix()
|
||||
nowUnix = max(nowUnix, 0)
|
||||
nowUnix = min(nowUnix, int64(math.MaxUint32))
|
||||
binary.BigEndian.PutUint32(sendData[5:9], uint32(nowUnix))
|
||||
_, err := rand.Read(sendData[9:13])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return sendData
|
||||
}
|
||||
|
||||
func buildInit1Step1Packet(data []byte) []byte {
|
||||
if len(data) != initStepLen {
|
||||
return nil
|
||||
}
|
||||
sendData := make([]byte, initTypeLen+16+4)
|
||||
sendData[0] = 0x01
|
||||
tsRand := binary.LittleEndian.Uint32(data[initVersionLen+initTypeLen+4 : initVersionLen+initTypeLen+8])
|
||||
binary.BigEndian.PutUint32(sendData[initTypeLen+16:initTypeLen+16+4], tsRand)
|
||||
|
||||
return sendData
|
||||
}
|
||||
|
||||
func buildInit1Step2Packet(data []byte) []byte {
|
||||
if len(data) != initStepLen {
|
||||
return nil
|
||||
}
|
||||
sendData := make([]byte, initVersionLen+initTypeLen+16+4)
|
||||
binary.BigEndian.PutUint32(sendData[0:4], InitVersion)
|
||||
sendData[4] = 0x02
|
||||
copy(sendData[5:25], data[1:21])
|
||||
|
||||
return sendData
|
||||
}
|
||||
|
||||
func buildInit1Step3Packet(data []byte) []byte {
|
||||
if len(data) != initVersionLen+initTypeLen+16+4 {
|
||||
return nil
|
||||
}
|
||||
sendData := make([]byte, initTypeLen+64+64+4+100)
|
||||
sendData[0] = 0x03
|
||||
sendData[initTypeLen+64-1] = 1
|
||||
sendData[initTypeLen+64+64-1] = 1
|
||||
binary.BigEndian.PutUint32(sendData[initTypeLen+64+64:initTypeLen+64+64+4], 1)
|
||||
|
||||
return sendData
|
||||
}
|
||||
|
||||
func buildInit1Step4Packet(tc *crypto.Crypt, data []byte) []byte {
|
||||
if len(data) != initTypeLen+64+64+4+100 {
|
||||
return nil
|
||||
}
|
||||
level := int(binary.BigEndian.Uint32(data[1+128 : 1+128+4]))
|
||||
y, err := tc.SolveRsaChallenge(data, 1, level)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
tc.AlphaTmp = make([]byte, 10)
|
||||
_, err = rand.Read(tc.AlphaTmp)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := commands.BuildCommandOrdered("clientinitiv", [][2]string{
|
||||
{"alpha", base64.StdEncoding.EncodeToString(tc.AlphaTmp)},
|
||||
{"omega", tc.Identity.PublicKeyBase64()},
|
||||
{"ot", "1"},
|
||||
{"ip", ""},
|
||||
})
|
||||
cmdBytes := []byte(cmd)
|
||||
|
||||
sendData := make([]byte, initVersionLen+initTypeLen+232+64+len(cmdBytes))
|
||||
binary.BigEndian.PutUint32(sendData[0:4], InitVersion)
|
||||
sendData[4] = 0x04
|
||||
copy(sendData[5:5+232], data[1:1+232])
|
||||
copy(sendData[5+232:5+232+64], y)
|
||||
copy(sendData[5+232+64:], cmdBytes)
|
||||
|
||||
return sendData
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package handshake_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/commands"
|
||||
"github.com/honeybbq/teamspeak-go/crypto"
|
||||
"github.com/honeybbq/teamspeak-go/handshake"
|
||||
)
|
||||
|
||||
const (
|
||||
versionLen = 4
|
||||
initTypeLen = 1
|
||||
)
|
||||
|
||||
// testIdentity is a fixed, low-security-level identity for tests.
|
||||
const testIdentityStr = "W2OSGpWxkzBPJjt8iyJFsMnqnwHCnxOlmE9gWFOFnKs=:0"
|
||||
|
||||
func newTestCrypt(t *testing.T) *crypto.Crypt {
|
||||
t.Helper()
|
||||
id, err := crypto.IdentityFromString(testIdentityStr)
|
||||
if err != nil {
|
||||
t.Fatalf("IdentityFromString failed: %v", err)
|
||||
}
|
||||
|
||||
return crypto.NewCrypt(id)
|
||||
}
|
||||
|
||||
func TestProcessInit1Start_NilData(t *testing.T) {
|
||||
tc := newTestCrypt(t)
|
||||
out := handshake.ProcessInit1(tc, nil)
|
||||
if out == nil {
|
||||
t.Fatal("expected non-nil output for nil data (Start)")
|
||||
}
|
||||
// 21 bytes: 4 (version) + 1 (type 0x00) + 4 (timestamp) + 4 (rand) + 8 (padding)
|
||||
if len(out) != 21 {
|
||||
t.Errorf("expected 21 bytes, got %d", len(out))
|
||||
}
|
||||
ver := binary.BigEndian.Uint32(out[0:4])
|
||||
if ver != handshake.InitVersion {
|
||||
t.Errorf("expected InitVersion %d, got %d", handshake.InitVersion, ver)
|
||||
}
|
||||
if out[4] != 0x00 {
|
||||
t.Errorf("expected step byte 0x00, got 0x%02x", out[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessInit1Start_RestartByte(t *testing.T) {
|
||||
tc := newTestCrypt(t)
|
||||
out := handshake.ProcessInit1(tc, []byte{0x7F})
|
||||
if out == nil {
|
||||
t.Fatal("expected non-nil output for 0x7F restart byte")
|
||||
}
|
||||
if len(out) != 21 {
|
||||
t.Errorf("expected 21 bytes, got %d", len(out))
|
||||
}
|
||||
if out[4] != 0x00 {
|
||||
t.Errorf("expected step byte 0x00, got 0x%02x", out[4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessInit1Step0(t *testing.T) {
|
||||
tc := newTestCrypt(t)
|
||||
|
||||
// Craft a valid 21-byte step-0 input (simulating server → client).
|
||||
// Layout: [1 type=0][...][4 tsRand LE at bytes 9-12][...]
|
||||
// ProcessInit1 reads tsRand as LittleEndian from data[versionLen+initTypeLen+4 : versionLen+initTypeLen+8]
|
||||
// = data[9:13]
|
||||
input := make([]byte, 21)
|
||||
input[0] = 0x00
|
||||
// bytes 9-12: tsRand (LittleEndian) — will be echoed back BigEndian at output offset 17
|
||||
binary.LittleEndian.PutUint32(input[9:13], 0xDEADBEEF)
|
||||
|
||||
out := handshake.ProcessInit1(tc, input)
|
||||
if out == nil {
|
||||
t.Fatal("expected non-nil output for step 0")
|
||||
}
|
||||
// 21 bytes: [1 type=1][16 zeros][4 tsRand BE]
|
||||
if len(out) != 21 {
|
||||
t.Errorf("expected 21 bytes output, got %d", len(out))
|
||||
}
|
||||
if out[0] != 0x01 {
|
||||
t.Errorf("expected step type 0x01, got 0x%02x", out[0])
|
||||
}
|
||||
echoed := binary.BigEndian.Uint32(out[17:21])
|
||||
if echoed != 0xDEADBEEF {
|
||||
t.Errorf("expected echoed tsRand 0xDEADBEEF, got 0x%X", echoed)
|
||||
}
|
||||
// Middle 16 bytes should be zeros.
|
||||
if !bytes.Equal(out[1:17], make([]byte, 16)) {
|
||||
t.Error("expected zeros in bytes 1:17")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessInit1Step0_WrongLength(t *testing.T) {
|
||||
tc := newTestCrypt(t)
|
||||
out := handshake.ProcessInit1(tc, []byte{0x00, 0x01}) // only 2 bytes
|
||||
if out != nil {
|
||||
t.Error("expected nil output for wrong-length step 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessInit1Step1(t *testing.T) {
|
||||
tc := newTestCrypt(t)
|
||||
|
||||
// Valid 21-byte step-1 input.
|
||||
input := make([]byte, 21)
|
||||
input[0] = 0x01
|
||||
// Fill payload bytes 1-20 with recognizable data.
|
||||
for i := 1; i < 21; i++ {
|
||||
input[i] = byte(i)
|
||||
}
|
||||
|
||||
out := handshake.ProcessInit1(tc, input)
|
||||
if out == nil {
|
||||
t.Fatal("expected non-nil output for step 1")
|
||||
}
|
||||
// 25 bytes: [4 version][1 type=2][20 echo of input[1:21]]
|
||||
if len(out) != 25 {
|
||||
t.Errorf("expected 25 bytes output, got %d", len(out))
|
||||
}
|
||||
ver := binary.BigEndian.Uint32(out[0:4])
|
||||
if ver != handshake.InitVersion {
|
||||
t.Errorf("expected InitVersion %d, got %d", handshake.InitVersion, ver)
|
||||
}
|
||||
if out[4] != 0x02 {
|
||||
t.Errorf("expected step byte 0x02, got 0x%02x", out[4])
|
||||
}
|
||||
if !bytes.Equal(out[5:25], input[1:21]) {
|
||||
t.Error("expected echo of input[1:21] in output[5:25]")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessInit1Step1_WrongLength(t *testing.T) {
|
||||
tc := newTestCrypt(t)
|
||||
out := handshake.ProcessInit1(tc, []byte{0x01}) // only 1 byte
|
||||
if out != nil {
|
||||
t.Error("expected nil output for wrong-length step 1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessInit1Step2(t *testing.T) {
|
||||
tc := newTestCrypt(t)
|
||||
|
||||
// Valid step-2 input: exactly versionLen+initTypeLen+16+4 = 25 bytes.
|
||||
input := make([]byte, 25)
|
||||
input[0] = 0x02
|
||||
|
||||
out := handshake.ProcessInit1(tc, input)
|
||||
if out == nil {
|
||||
t.Fatal("expected non-nil output for step 2")
|
||||
}
|
||||
// 133 bytes: [1 type=3][64 x with last byte=1][64 n with last byte=1][4 BE uint=1][100 zeros]
|
||||
expectedLen := initTypeLen + 64 + 64 + 4 + 100
|
||||
if len(out) != expectedLen {
|
||||
t.Errorf("expected %d bytes output, got %d", expectedLen, len(out))
|
||||
}
|
||||
if out[0] != 0x03 {
|
||||
t.Errorf("expected step byte 0x03, got 0x%02x", out[0])
|
||||
}
|
||||
if out[initTypeLen+64-1] != 1 {
|
||||
t.Errorf("expected out[64] == 1, got %d", out[initTypeLen+64-1])
|
||||
}
|
||||
if out[initTypeLen+64+64-1] != 1 {
|
||||
t.Errorf("expected out[128] == 1, got %d", out[initTypeLen+64+64-1])
|
||||
}
|
||||
level := binary.BigEndian.Uint32(out[initTypeLen+128 : initTypeLen+128+4])
|
||||
if level != 1 {
|
||||
t.Errorf("expected level=1, got %d", level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessInit1Step2_WrongLength(t *testing.T) {
|
||||
tc := newTestCrypt(t)
|
||||
out := handshake.ProcessInit1(tc, []byte{0x02, 0x00}) // too short
|
||||
if out != nil {
|
||||
t.Error("expected nil output for wrong-length step 2")
|
||||
}
|
||||
}
|
||||
|
||||
// buildStep3Input builds a 233-byte step-3 input with level=0 (instant RSA solve).
|
||||
func buildStep3Input() []byte {
|
||||
// 233 bytes: [1 type=3][64 x][64 n][4 level][100 padding]
|
||||
input := make([]byte, 233)
|
||||
input[0] = 0x03
|
||||
|
||||
// x = 2 (at offset 1, 64 bytes big-endian)
|
||||
input[1+63] = 0x02 // last byte of 64-byte big-endian x
|
||||
|
||||
// n = 15 (at offset 65, 64 bytes big-endian) — small modulus, level=0 → y=x=2
|
||||
input[1+64+63] = 0x0F
|
||||
|
||||
// level = 0 → y = x^(2^0) mod n = x^1 mod n = 2
|
||||
binary.BigEndian.PutUint32(input[1+128:1+132], 0)
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
func TestProcessInit1Step3_Level0(t *testing.T) {
|
||||
tc := newTestCrypt(t)
|
||||
input := buildStep3Input()
|
||||
|
||||
out := handshake.ProcessInit1(tc, input)
|
||||
if out == nil {
|
||||
t.Fatal("expected non-nil output for step 3 with level=0")
|
||||
}
|
||||
|
||||
// Output: [4 version][1 type=0x04][232 data from input[1:233]][64 y][cmdBytes]
|
||||
minLen := versionLen + initTypeLen + 232 + 64
|
||||
if len(out) < minLen {
|
||||
t.Fatalf("expected at least %d bytes output, got %d", minLen, len(out))
|
||||
}
|
||||
ver := binary.BigEndian.Uint32(out[0:4])
|
||||
if ver != handshake.InitVersion {
|
||||
t.Errorf("expected InitVersion, got %d", ver)
|
||||
}
|
||||
if out[4] != 0x04 {
|
||||
t.Errorf("expected step byte 0x04, got 0x%02x", out[4])
|
||||
}
|
||||
if !bytes.Equal(out[5:5+232], input[1:233]) {
|
||||
t.Error("expected input[1:233] echoed in output[5:237]")
|
||||
}
|
||||
|
||||
assertStep3ClientInitIV(t, tc, out)
|
||||
}
|
||||
|
||||
func assertStep3ClientInitIV(t *testing.T, tc *crypto.Crypt, out []byte) {
|
||||
t.Helper()
|
||||
|
||||
if len(tc.AlphaTmp) != 10 {
|
||||
t.Errorf("expected AlphaTmp length 10, got %d", len(tc.AlphaTmp))
|
||||
}
|
||||
|
||||
cmdPart := string(out[5+232+64:])
|
||||
cmd := commands.ParseCommand(cmdPart)
|
||||
if cmd == nil || cmd.Name != "clientinitiv" {
|
||||
t.Errorf("expected clientinitiv command, got %q", cmdPart)
|
||||
|
||||
return
|
||||
}
|
||||
if cmd.Params["ot"] != "1" {
|
||||
t.Errorf("expected ot=1, got %q", cmd.Params["ot"])
|
||||
}
|
||||
alphaB64 := cmd.Params["alpha"]
|
||||
alphaBytes, err := base64.StdEncoding.DecodeString(alphaB64)
|
||||
if err != nil || len(alphaBytes) != 10 {
|
||||
t.Errorf("expected 10-byte alpha, got %d bytes (err=%v)", len(alphaBytes), err)
|
||||
}
|
||||
omega := cmd.Params["omega"]
|
||||
if len(omega) < 20 {
|
||||
t.Errorf("omega looks too short: %q", omega)
|
||||
}
|
||||
if !strings.HasSuffix(omega, "=") && len(omega)%4 != 0 {
|
||||
t.Errorf("omega is not valid base64: %q", omega)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessInit1Step3_WrongLength(t *testing.T) {
|
||||
tc := newTestCrypt(t)
|
||||
out := handshake.ProcessInit1(tc, []byte{0x03, 0x00}) // too short
|
||||
if out != nil {
|
||||
t.Error("expected nil output for wrong-length step 3")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessInit1Step3_LevelOutOfRange(t *testing.T) {
|
||||
tc := newTestCrypt(t)
|
||||
input := buildStep3Input()
|
||||
// Set level = 2000000 (exceeds the 1000000 limit)
|
||||
binary.BigEndian.PutUint32(input[1+128:1+132], 2000000)
|
||||
out := handshake.ProcessInit1(tc, input)
|
||||
if out != nil {
|
||||
t.Error("expected nil output for RSA level out of range")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessInit1UnknownStep(t *testing.T) {
|
||||
tc := newTestCrypt(t)
|
||||
out := handshake.ProcessInit1(tc, []byte{0x05})
|
||||
if out != nil {
|
||||
t.Error("expected nil output for unknown step byte")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package handshake
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/crypto"
|
||||
)
|
||||
|
||||
var (
|
||||
errAlphaNotInitialized = errors.New("alpha is not initialized")
|
||||
errInitProofInvalid = errors.New("init proof is not valid")
|
||||
)
|
||||
|
||||
type init2Payload struct {
|
||||
license []byte
|
||||
omega []byte
|
||||
proof []byte
|
||||
beta []byte
|
||||
}
|
||||
|
||||
// CryptoInit2 performs the second stage of crypto initialization (Ed25519 ECDH).
|
||||
func CryptoInit2(tc *crypto.Crypt, license, omega, proof, beta string, privateKey []byte) error {
|
||||
if len(tc.AlphaTmp) == 0 {
|
||||
return errAlphaNotInitialized
|
||||
}
|
||||
payload, err := decodeInit2Payload(license, omega, proof, beta)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
serverPubKey, err := crypto.ImportPublicKey(payload.omega)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !crypto.VerifySign(serverPubKey, payload.license, payload.proof) {
|
||||
return errInitProofInvalid
|
||||
}
|
||||
|
||||
licenses, err := ParseLicenses(payload.license)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key, err := licenses.DeriveKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sharedSecret, err := crypto.GetSharedSecret2(key, privateKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tc.SetSharedSecret(tc.AlphaTmp, payload.beta, sharedSecret)
|
||||
}
|
||||
|
||||
func decodeInit2Payload(license, omega, proof, beta string) (*init2Payload, error) {
|
||||
licenseBytes, err := base64.StdEncoding.DecodeString(license)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid license: %w", err)
|
||||
}
|
||||
omegaBytes, err := base64.StdEncoding.DecodeString(omega)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid omega: %w", err)
|
||||
}
|
||||
proofBytes, err := base64.StdEncoding.DecodeString(proof)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid proof: %w", err)
|
||||
}
|
||||
betaBytes, err := base64.StdEncoding.DecodeString(beta)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid beta: %w", err)
|
||||
}
|
||||
|
||||
return &init2Payload{
|
||||
license: licenseBytes,
|
||||
omega: omegaBytes,
|
||||
proof: proofBytes,
|
||||
beta: betaBytes,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package handshake_test
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/crypto"
|
||||
"github.com/honeybbq/teamspeak-go/handshake"
|
||||
)
|
||||
|
||||
type cryptoInit2Fixtures struct {
|
||||
tc *crypto.Crypt
|
||||
license string
|
||||
omega string
|
||||
proof string
|
||||
beta string
|
||||
privateKey []byte
|
||||
}
|
||||
|
||||
// buildCryptoInit2Fixtures creates a complete, cryptographically valid set of
|
||||
// parameters for CryptoInit2. It uses a generated server Identity so that we
|
||||
// can call the existing PublicKeyBase64() and Sign() helpers without accessing
|
||||
// deprecated ecdsa.PublicKey.X / .Y fields directly.
|
||||
func buildCryptoInit2Fixtures(t *testing.T) cryptoInit2Fixtures {
|
||||
t.Helper()
|
||||
|
||||
// Client Crypt with initialized AlphaTmp.
|
||||
id, err := crypto.IdentityFromString(testIdentityStr)
|
||||
if err != nil {
|
||||
t.Fatalf("IdentityFromString: %v", err)
|
||||
}
|
||||
tc := crypto.NewCrypt(id)
|
||||
tc.AlphaTmp = make([]byte, 10)
|
||||
_, err = rand.Read(tc.AlphaTmp)
|
||||
if err != nil {
|
||||
t.Fatalf("rand.Read AlphaTmp: %v", err)
|
||||
}
|
||||
|
||||
// Use a fresh generated Identity as the "server" key (avoids deprecated X/Y access).
|
||||
serverID, err := generateFreshIdentity(t)
|
||||
if err != nil {
|
||||
t.Fatalf("generate server identity: %v", err)
|
||||
}
|
||||
omega := serverID.PublicKeyBase64()
|
||||
|
||||
// License data: use the real-world test license.
|
||||
licenseBytes := decodeTestLicense(t)
|
||||
license := base64.StdEncoding.EncodeToString(licenseBytes)
|
||||
|
||||
// Proof: server signs the license data.
|
||||
sig, err := crypto.Sign(serverID.PrivateKey, licenseBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign proof: %v", err)
|
||||
}
|
||||
proof := base64.StdEncoding.EncodeToString(sig)
|
||||
|
||||
// Beta: random 54 bytes (same length as real server beta).
|
||||
betaBytes := make([]byte, 54)
|
||||
_, err = rand.Read(betaBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("rand.Read beta: %v", err)
|
||||
}
|
||||
beta := base64.StdEncoding.EncodeToString(betaBytes)
|
||||
|
||||
// Client temporary Ed25519 key pair.
|
||||
_, privateKey, err := crypto.GenerateTemporaryKey()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateTemporaryKey: %v", err)
|
||||
}
|
||||
|
||||
return cryptoInit2Fixtures{
|
||||
tc: tc,
|
||||
license: license,
|
||||
omega: omega,
|
||||
proof: proof,
|
||||
beta: beta,
|
||||
privateKey: privateKey,
|
||||
}
|
||||
}
|
||||
|
||||
// generateFreshIdentity generates a new P-256 identity for use as a server key.
|
||||
func generateFreshIdentity(t *testing.T) (*crypto.Identity, error) {
|
||||
t.Helper()
|
||||
// IdentityFromString requires an existing base64 D scalar; generate one via UpgradeToLevel.
|
||||
// Alternatively, use SecurityLevel which already generates a new key.
|
||||
// We use a known valid identity string and derive a new one via the upgrade path.
|
||||
// Simplest: pick a random 32-byte D value and construct the identity.
|
||||
// Use SecurityLevel on a fresh crypt to generate a proper key pair.
|
||||
id, err := crypto.IdentityFromString(testIdentityStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The testIdentityStr identity is valid; return it (the "server" just needs a P-256 key pair).
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func TestCryptoInit2_Success(t *testing.T) {
|
||||
f := buildCryptoInit2Fixtures(t)
|
||||
|
||||
err := handshake.CryptoInit2(f.tc, f.license, f.omega, f.proof, f.beta, f.privateKey)
|
||||
if err != nil {
|
||||
t.Fatalf("CryptoInit2 failed: %v", err)
|
||||
}
|
||||
if !f.tc.CryptoInitComplete {
|
||||
t.Error("expected CryptoInitComplete to be true after CryptoInit2")
|
||||
}
|
||||
if len(f.tc.IvStruct) == 0 {
|
||||
t.Error("expected IvStruct to be populated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptoInit2_AlphaNotInitialized(t *testing.T) {
|
||||
f := buildCryptoInit2Fixtures(t)
|
||||
|
||||
id, _ := crypto.IdentityFromString(testIdentityStr)
|
||||
tcEmpty := crypto.NewCrypt(id)
|
||||
// AlphaTmp is nil by default.
|
||||
|
||||
err := handshake.CryptoInit2(tcEmpty, f.license, f.omega, f.proof, f.beta, f.privateKey)
|
||||
if err == nil {
|
||||
t.Error("expected error when AlphaTmp is not initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptoInit2_InvalidLicenseBase64(t *testing.T) {
|
||||
f := buildCryptoInit2Fixtures(t)
|
||||
err := handshake.CryptoInit2(f.tc, "not-valid-base64!!!", f.omega, f.proof, f.beta, f.privateKey)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid license base64")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptoInit2_InvalidOmegaBase64(t *testing.T) {
|
||||
f := buildCryptoInit2Fixtures(t)
|
||||
err := handshake.CryptoInit2(f.tc, f.license, "not-valid-base64!!!", f.proof, f.beta, f.privateKey)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid omega base64")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptoInit2_InvalidProofBase64(t *testing.T) {
|
||||
f := buildCryptoInit2Fixtures(t)
|
||||
err := handshake.CryptoInit2(f.tc, f.license, f.omega, "not-valid-base64!!!", f.beta, f.privateKey)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid proof base64")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptoInit2_InvalidBetaBase64(t *testing.T) {
|
||||
f := buildCryptoInit2Fixtures(t)
|
||||
err := handshake.CryptoInit2(f.tc, f.license, f.omega, f.proof, "not-valid-base64!!!", f.privateKey)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid beta base64")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptoInit2_InvalidOmegaKey(t *testing.T) {
|
||||
f := buildCryptoInit2Fixtures(t)
|
||||
// Valid base64, but not a valid P-256 public key.
|
||||
badOmega := base64.StdEncoding.EncodeToString([]byte("this is not a valid public key"))
|
||||
err := handshake.CryptoInit2(f.tc, f.license, badOmega, f.proof, f.beta, f.privateKey)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid omega key bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCryptoInit2_ProofVerificationFails(t *testing.T) {
|
||||
f := buildCryptoInit2Fixtures(t)
|
||||
|
||||
// Decode, flip a bit, re-encode → invalid signature.
|
||||
proofBytes, _ := base64.StdEncoding.DecodeString(f.proof)
|
||||
proofBytes[0] ^= 0xFF
|
||||
tampered := base64.StdEncoding.EncodeToString(proofBytes)
|
||||
|
||||
err := handshake.CryptoInit2(f.tc, f.license, f.omega, tampered, f.beta, f.privateKey)
|
||||
if err == nil {
|
||||
t.Error("expected error when proof verification fails")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package handshake
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/crypto"
|
||||
"github.com/oasisprotocol/curve25519-voi/curve"
|
||||
"github.com/oasisprotocol/curve25519-voi/curve/scalar"
|
||||
)
|
||||
|
||||
var (
|
||||
errLicenseTooShort = errors.New("license too short")
|
||||
errUnsupportedLicenseVersion = errors.New("unsupported license version")
|
||||
errInvalidLicenseTimes = errors.New("license times are invalid")
|
||||
errIssuerStringNotTerminated = errors.New("non-null-terminated issuer string")
|
||||
errWrongKeyKindInLicense = errors.New("wrong key kind in license")
|
||||
errInvalidLicenseBlockType = errors.New("invalid license block type")
|
||||
)
|
||||
|
||||
var licenseRootKey = []byte{
|
||||
0xcd, 0x0d, 0xe2, 0xae, 0xd4, 0x63, 0x45, 0x50, 0x9a, 0x7e, 0x3c, 0xfd, 0x8f, 0x68, 0xb3, 0xdc, 0x75, 0x55, 0xb2,
|
||||
0x9d, 0xcc, 0xec, 0x73, 0xcd, 0x18, 0x75, 0x0f, 0x99, 0x38, 0x12, 0x40, 0x8a,
|
||||
}
|
||||
|
||||
type licenseBlockType byte
|
||||
|
||||
const (
|
||||
licenseBlockIntermediate licenseBlockType = 0
|
||||
licenseBlockServer licenseBlockType = 2
|
||||
licenseBlockTs5Server licenseBlockType = 8
|
||||
licenseBlockEphemeral licenseBlockType = 32
|
||||
)
|
||||
|
||||
type licenseBlock struct {
|
||||
key []byte
|
||||
hash []byte
|
||||
properties [][]byte // TS5/TS6 server license properties
|
||||
issuer string
|
||||
notValidBefore time.Time
|
||||
notValidAfter time.Time
|
||||
blockType licenseBlockType
|
||||
serverType byte
|
||||
}
|
||||
|
||||
type LicenseChain struct {
|
||||
Blocks []licenseBlock
|
||||
}
|
||||
|
||||
type blockPayload struct {
|
||||
read int
|
||||
issuer string
|
||||
serverType byte
|
||||
properties [][]byte
|
||||
}
|
||||
|
||||
func ParseLicenses(data []byte) (*LicenseChain, error) {
|
||||
if len(data) < 1 {
|
||||
return nil, errLicenseTooShort
|
||||
}
|
||||
if data[0] != 1 {
|
||||
return nil, errUnsupportedLicenseVersion
|
||||
}
|
||||
|
||||
data = data[1:]
|
||||
res := &LicenseChain{}
|
||||
for len(data) > 0 {
|
||||
block, read, err := parseLicenseBlock(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Blocks = append(res.Blocks, block)
|
||||
data = data[read:]
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (lc *LicenseChain) DeriveKey() ([]byte, error) {
|
||||
round := make([]byte, len(licenseRootKey))
|
||||
copy(round, licenseRootKey)
|
||||
for _, block := range lc.Blocks {
|
||||
next, err := block.deriveKey(round)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
round = next
|
||||
}
|
||||
|
||||
return round, nil
|
||||
}
|
||||
|
||||
func parseLicenseBlock(data []byte) (licenseBlock, int, error) {
|
||||
const minBlockLen = 42
|
||||
if len(data) < minBlockLen {
|
||||
return licenseBlock{}, 0, errLicenseTooShort
|
||||
}
|
||||
if data[0] != 0 {
|
||||
return licenseBlock{}, 0, fmt.Errorf("%w: %d", errWrongKeyKindInLicense, data[0])
|
||||
}
|
||||
|
||||
blockType := licenseBlockType(data[33])
|
||||
payload, err := parseBlockPayload(blockType, data, minBlockLen)
|
||||
if err != nil {
|
||||
return licenseBlock{}, 0, err
|
||||
}
|
||||
|
||||
notValidBefore := unixTimeStart.Add(time.Duration(binary.BigEndian.Uint32(data[34:38])+0x50e22700) * time.Second)
|
||||
notValidAfter := unixTimeStart.Add(time.Duration(binary.BigEndian.Uint32(data[38:42])+0x50e22700) * time.Second)
|
||||
if notValidAfter.Before(notValidBefore) {
|
||||
return licenseBlock{}, 0, errInvalidLicenseTimes
|
||||
}
|
||||
|
||||
key := make([]byte, 32)
|
||||
copy(key, data[1:33])
|
||||
allLen := minBlockLen + payload.read
|
||||
hash := crypto.Hash512(data[1:allLen])
|
||||
block := licenseBlock{
|
||||
blockType: blockType,
|
||||
issuer: payload.issuer,
|
||||
notValidBefore: notValidBefore,
|
||||
notValidAfter: notValidAfter,
|
||||
key: key,
|
||||
hash: hash[:32],
|
||||
serverType: payload.serverType,
|
||||
properties: payload.properties,
|
||||
}
|
||||
|
||||
return block, allLen, nil
|
||||
}
|
||||
|
||||
func parseBlockPayload(blockType licenseBlockType, data []byte, minBlockLen int) (blockPayload, error) {
|
||||
switch blockType {
|
||||
case licenseBlockIntermediate:
|
||||
return parseIntermediatePayload(data)
|
||||
case licenseBlockServer:
|
||||
return parseServerPayload(data)
|
||||
case licenseBlockTs5Server:
|
||||
return parseTs5ServerPayload(data, minBlockLen)
|
||||
case licenseBlockEphemeral:
|
||||
return blockPayload{}, nil
|
||||
default:
|
||||
return blockPayload{}, fmt.Errorf("%w: %d", errInvalidLicenseBlockType, blockType)
|
||||
}
|
||||
}
|
||||
|
||||
func parseIntermediatePayload(data []byte) (blockPayload, error) {
|
||||
issuer, read, err := readNullString(data[46:])
|
||||
if err != nil {
|
||||
return blockPayload{}, err
|
||||
}
|
||||
|
||||
return blockPayload{issuer: issuer, read: 5 + read}, nil
|
||||
}
|
||||
|
||||
func parseServerPayload(data []byte) (blockPayload, error) {
|
||||
issuer, read, err := readNullString(data[47:])
|
||||
if err != nil {
|
||||
return blockPayload{}, err
|
||||
}
|
||||
|
||||
return blockPayload{
|
||||
issuer: issuer,
|
||||
read: 6 + read,
|
||||
serverType: data[42],
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseTs5ServerPayload(data []byte, minBlockLen int) (blockPayload, error) {
|
||||
propertyCount := int(data[43])
|
||||
pos := 44
|
||||
properties := make([][]byte, 0, propertyCount)
|
||||
for range propertyCount {
|
||||
if pos >= len(data) {
|
||||
return blockPayload{}, errLicenseTooShort
|
||||
}
|
||||
propLen := int(data[pos])
|
||||
pos++
|
||||
if pos+propLen > len(data) {
|
||||
return blockPayload{}, errLicenseTooShort
|
||||
}
|
||||
prop := make([]byte, propLen)
|
||||
copy(prop, data[pos:pos+propLen])
|
||||
properties = append(properties, prop)
|
||||
pos += propLen
|
||||
}
|
||||
|
||||
return blockPayload{
|
||||
read: pos - minBlockLen,
|
||||
serverType: data[42],
|
||||
properties: properties,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (lb *licenseBlock) deriveKey(parent []byte) ([]byte, error) {
|
||||
scalarBytes := make([]byte, 32)
|
||||
copy(scalarBytes, lb.hash)
|
||||
crypto.ClampScalar(scalarBytes)
|
||||
sc, err := scalar.NewFromBits(scalarBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pub := curve.NewEdwardsPoint()
|
||||
err = pub.UnmarshalBinary(lb.key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pub.Neg(pub)
|
||||
|
||||
par := curve.NewEdwardsPoint()
|
||||
err = par.UnmarshalBinary(parent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
par.Neg(par)
|
||||
|
||||
res := curve.NewEdwardsPoint().Mul(pub, sc)
|
||||
res.Add(res, par)
|
||||
|
||||
final, err := res.MarshalBinary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
final[31] ^= 0x80
|
||||
|
||||
return final, nil
|
||||
}
|
||||
|
||||
func readNullString(data []byte) (string, int, error) {
|
||||
for i, b := range data {
|
||||
if b == 0 {
|
||||
return string(data[:i]), i, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", 0, errIssuerStringNotTerminated
|
||||
}
|
||||
|
||||
var unixTimeStart = time.Unix(0, 0)
|
||||
@@ -0,0 +1,222 @@
|
||||
package handshake_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/handshake"
|
||||
)
|
||||
|
||||
// Real-world TeamSpeak anonymous license captured from a live server handshake.
|
||||
const testLicenseBase64 = "AQBgjAAqtcBUrw5futTtkl3+EM3OW4Lal6OTPlwuv4xV/gIRFlEAG0Nl" +
|
||||
"AAcAAAAgQW5vbnltb3VzAACWSZf+Mjl5RT5mu4rvf8nhAZp9TjXO10XfGHQ9HQPtHiAYiqjtGItRrQ=="
|
||||
|
||||
func decodeTestLicense(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
data, err := base64.StdEncoding.DecodeString(testLicenseBase64)
|
||||
if err != nil {
|
||||
t.Fatalf("base64 decode failed: %v", err)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func TestParseLicensesValid(t *testing.T) {
|
||||
data := decodeTestLicense(t)
|
||||
chain, err := handshake.ParseLicenses(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLicenses failed: %v", err)
|
||||
}
|
||||
if len(chain.Blocks) != 2 {
|
||||
t.Errorf("expected 2 blocks, got %d", len(chain.Blocks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLicensesEmptyInput(t *testing.T) {
|
||||
_, err := handshake.ParseLicenses([]byte{})
|
||||
if err == nil {
|
||||
t.Error("expected error for empty input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLicensesWrongVersion(t *testing.T) {
|
||||
// Version byte at index 0 must be 1
|
||||
_, err := handshake.ParseLicenses([]byte{0x02, 0x00})
|
||||
if err == nil {
|
||||
t.Error("expected error for unsupported version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLicensesTooShortBlock(t *testing.T) {
|
||||
// Version OK but block data too short (< 42 bytes)
|
||||
data := make([]byte, 10)
|
||||
data[0] = 0x01 // valid version
|
||||
// remaining 9 bytes are not enough for a license block (needs 42)
|
||||
_, err := handshake.ParseLicenses(data)
|
||||
if err == nil {
|
||||
t.Error("expected error for truncated block")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveKeyLength(t *testing.T) {
|
||||
data := decodeTestLicense(t)
|
||||
chain, err := handshake.ParseLicenses(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, err := chain.DeriveKey()
|
||||
if err != nil {
|
||||
t.Fatalf("DeriveKey failed: %v", err)
|
||||
}
|
||||
if len(key) != 32 {
|
||||
t.Errorf("expected 32-byte key, got %d bytes", len(key))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveKeyDeterministic(t *testing.T) {
|
||||
data := decodeTestLicense(t)
|
||||
chain, err := handshake.ParseLicenses(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key1, err := chain.DeriveKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key2, err := chain.DeriveKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hex.EncodeToString(key1) != hex.EncodeToString(key2) {
|
||||
t.Error("DeriveKey is not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveKeyNonZero(t *testing.T) {
|
||||
data := decodeTestLicense(t)
|
||||
chain, err := handshake.ParseLicenses(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, err := chain.DeriveKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
allZero := true
|
||||
for _, b := range key {
|
||||
if b != 0 {
|
||||
allZero = false
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
if allZero {
|
||||
t.Error("derived key should not be all zeros")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLicensesExpectedKeyKnownValue(t *testing.T) {
|
||||
// Known expected key derived from this specific anonymous license.
|
||||
const expectedKeyHex = "82a168e11f9f3e3496fbf8479cd3e17d9b0945e224a71fb371af619a256b8446"
|
||||
data := decodeTestLicense(t)
|
||||
chain, err := handshake.ParseLicenses(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, err := chain.DeriveKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := hex.EncodeToString(key); got != expectedKeyHex {
|
||||
t.Errorf("DeriveKey = %s, want %s", got, expectedKeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
// TS5/TS6 server license block (type 8) tests
|
||||
|
||||
// buildTs5LicenseBlob constructs a synthetic version-1 license containing a
|
||||
// single Ts5Server block (type 8) with the given properties.
|
||||
func buildTs5LicenseBlob(props [][]byte) []byte {
|
||||
// Block layout:
|
||||
// [0] key kind = 0
|
||||
// [1:33] 32-byte Ed25519 public key (identity point)
|
||||
// [33] block type = 8
|
||||
// [34:38] not valid before (BE uint32)
|
||||
// [38:42] not valid after (BE uint32)
|
||||
// [42] server license type
|
||||
// [43] property count
|
||||
// [44+] length-prefixed properties
|
||||
const headerSize = 44
|
||||
totalPropsSize := 0
|
||||
for _, p := range props {
|
||||
totalPropsSize += 1 + len(p)
|
||||
}
|
||||
block := make([]byte, headerSize, headerSize+totalPropsSize)
|
||||
block[0] = 0x00
|
||||
block[1] = 0x01 // Ed25519 identity point (0,1)
|
||||
block[33] = 0x08
|
||||
binary.BigEndian.PutUint32(block[34:38], 0x00000000)
|
||||
binary.BigEndian.PutUint32(block[38:42], 0x7FFFFFFF)
|
||||
block[42] = 7
|
||||
block[43] = byte(len(props))
|
||||
for _, p := range props {
|
||||
block = append(block, byte(len(p)))
|
||||
block = append(block, p...)
|
||||
}
|
||||
|
||||
return append([]byte{0x01}, block...) // version prefix
|
||||
}
|
||||
|
||||
func TestParseTs5ServerBlock(t *testing.T) {
|
||||
data := buildTs5LicenseBlob([][]byte{
|
||||
[]byte("issuer.example.com"),
|
||||
{0xDE, 0xAD},
|
||||
})
|
||||
chain, err := handshake.ParseLicenses(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLicenses failed: %v", err)
|
||||
}
|
||||
if len(chain.Blocks) != 1 {
|
||||
t.Errorf("expected 1 block, got %d", len(chain.Blocks))
|
||||
}
|
||||
key, err := chain.DeriveKey()
|
||||
if err != nil {
|
||||
t.Fatalf("DeriveKey failed: %v", err)
|
||||
}
|
||||
if len(key) != 32 {
|
||||
t.Errorf("expected 32-byte key, got %d", len(key))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTs5ServerBlockZeroProperties(t *testing.T) {
|
||||
data := buildTs5LicenseBlob(nil)
|
||||
chain, err := handshake.ParseLicenses(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseLicenses failed: %v", err)
|
||||
}
|
||||
if len(chain.Blocks) != 1 {
|
||||
t.Errorf("expected 1 block, got %d", len(chain.Blocks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTs5ServerBlockTruncatedPropertyData(t *testing.T) {
|
||||
data := buildTs5LicenseBlob([][]byte{{0x01, 0x02, 0x03}})
|
||||
// Chop off last byte so the property data is incomplete.
|
||||
data = data[:len(data)-1]
|
||||
_, err := handshake.ParseLicenses(data)
|
||||
if err == nil {
|
||||
t.Error("expected error for truncated property data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTs5ServerBlockTruncatedPropertyLength(t *testing.T) {
|
||||
// Claim 2 properties but only provide 1.
|
||||
data := buildTs5LicenseBlob([][]byte{{0xAA}})
|
||||
data[1+43] = 2 // override property count to 2
|
||||
_, err := handshake.ParseLicenses(data)
|
||||
if err == nil {
|
||||
t.Error("expected error when property count exceeds available data")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package teamspeak
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/commands"
|
||||
"github.com/honeybbq/teamspeak-go/crypto"
|
||||
"github.com/honeybbq/teamspeak-go/transport"
|
||||
)
|
||||
|
||||
func TestHandleInitServer_SetsStatusConnected(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
c.handleCommand("initserver aclid=7 virtualserver_name=TestServer")
|
||||
|
||||
c.mu.Lock()
|
||||
status := c.status
|
||||
clid := c.clid
|
||||
c.mu.Unlock()
|
||||
|
||||
if status != StatusConnected {
|
||||
t.Errorf("expected StatusConnected, got %v", status)
|
||||
}
|
||||
if clid != 7 {
|
||||
t.Errorf("expected clid=7, got %d", clid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleInitServer_FallsBackToClid(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
c.handleCommand("initserver clid=42 virtualserver_name=X")
|
||||
|
||||
c.mu.Lock()
|
||||
clid := c.clid
|
||||
c.mu.Unlock()
|
||||
|
||||
if clid != 42 {
|
||||
t.Errorf("expected clid=42, got %d", clid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleInitServer_ClosesConnectedChan(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
c.handleCommand("initserver aclid=3 virtualserver_name=X")
|
||||
|
||||
select {
|
||||
case <-c.connectedChan:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("connectedChan was not closed after initserver")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleInitServer_TriggersOnConnected(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
connected := make(chan struct{}, 1)
|
||||
c.OnConnected(func() { connected <- struct{}{} })
|
||||
|
||||
c.handleCommand("initserver aclid=1")
|
||||
|
||||
select {
|
||||
case <-connected:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("OnConnected not called after initserver")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleInitServer_CalledTwice_StillConnected(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
c.handleCommand("initserver aclid=1")
|
||||
c.handleCommand("initserver aclid=2")
|
||||
|
||||
c.mu.Lock()
|
||||
clid := c.clid
|
||||
c.mu.Unlock()
|
||||
|
||||
if clid != 2 {
|
||||
t.Errorf("expected clid=2 after second initserver, got %d", clid)
|
||||
}
|
||||
}
|
||||
|
||||
// handleHandshakeInitIV (old path)
|
||||
|
||||
func TestHandleHandshakeInitIV_InvalidAlpha_NoSendClientInit(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
// Invalid base64 for alpha: InitCrypto fails, handleHandshakeInitIV returns.
|
||||
// Must not panic.
|
||||
c.handleCommand("clientinitiv alpha=!!! beta=AAAAAAAAAA omega=")
|
||||
}
|
||||
|
||||
func TestHandleHandshakeInitIV_ValidParams_SendsClientInit(t *testing.T) {
|
||||
c, serverConn := newTestClientWithPipe(t)
|
||||
|
||||
// Drain the initial Init1 packet from Start().
|
||||
_ = readFromPipe(t, serverConn)
|
||||
|
||||
// Wire OnPacket so that the handler routes Init1 packets to handlePacket.
|
||||
c.handler.OnPacket = c.handlePacket
|
||||
c.handler.OnClosed = func(err error) {}
|
||||
|
||||
// A valid old-style handshake: alpha + beta are 10-byte base64, omega is the
|
||||
// server's public key. We use the test identity's own public key as omega
|
||||
// (InitCrypto only validates format, not that it comes from a server).
|
||||
pubKey := c.crypt.Identity.PublicKeyBase64()
|
||||
|
||||
cmd := "clientinitiv alpha=AAAAAAAAAA== beta=AAAAAAAAAA== omega=" + pubKey
|
||||
c.handleCommand(cmd)
|
||||
|
||||
// After successful InitCrypto, sendClientInit is called and a Command packet
|
||||
// (type 2) is sent through the handler. The payload is encrypted.
|
||||
pkt := readFromPipe(t, serverConn)
|
||||
if len(pkt) < 13 {
|
||||
t.Fatalf("expected clientinit packet, got %d bytes", len(pkt))
|
||||
}
|
||||
// C2S header[4] byte (index 12 in S2C layout) = TypeFlagged; lower nibble = type.
|
||||
pktType := pkt[12] & 0x0F
|
||||
if pktType != 0x02 {
|
||||
t.Errorf("expected PacketTypeCommand (2), got %d", pktType)
|
||||
}
|
||||
}
|
||||
|
||||
// handleHandshakeExpand2 (new path)
|
||||
|
||||
func TestHandleHandshakeExpand2_InvalidBeta_NoSendClientInit(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
// Invalid base64 for beta: DecodeString fails, returns early.
|
||||
c.handleCommand("initivexpand2 l= omega= proof= beta=!!!")
|
||||
}
|
||||
|
||||
func TestBuildClientInitCommand_DefaultAuthFieldsAreEmpty(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
cmd := commands.ParseCommand(c.buildClientInitCommand())
|
||||
if cmd == nil {
|
||||
t.Fatal("expected clientinit command")
|
||||
}
|
||||
|
||||
if got := cmd.Params["client_default_channel"]; got != "" {
|
||||
t.Errorf("expected empty client_default_channel, got %q", got)
|
||||
}
|
||||
if got := cmd.Params["client_default_channel_password"]; got != "" {
|
||||
t.Errorf("expected empty client_default_channel_password, got %q", got)
|
||||
}
|
||||
if got := cmd.Params["client_server_password"]; got != "" {
|
||||
t.Errorf("expected empty client_server_password, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClientInitCommand_IncludesConfiguredHandshakeCredentials(t *testing.T) {
|
||||
id, err := crypto.IdentityFromString(testClientIdentity)
|
||||
if err != nil {
|
||||
t.Fatalf("IdentityFromString: %v", err)
|
||||
}
|
||||
|
||||
c := NewClient(
|
||||
id,
|
||||
"127.0.0.1:9987",
|
||||
"Test Bot",
|
||||
WithServerPassword("server secret"),
|
||||
WithDefaultChannel("Lobby Alpha"),
|
||||
WithDefaultChannelPassword("channel secret"),
|
||||
)
|
||||
|
||||
cmd := commands.ParseCommand(c.buildClientInitCommand())
|
||||
if cmd == nil {
|
||||
t.Fatal("expected clientinit command")
|
||||
}
|
||||
|
||||
if got := cmd.Params["client_server_password"]; got != prepareClientPassword("server secret") {
|
||||
t.Errorf("expected client_server_password to be %q, got %q", prepareClientPassword("server secret"), got)
|
||||
}
|
||||
if got := cmd.Params["client_default_channel"]; got != "Lobby Alpha" {
|
||||
t.Errorf("expected client_default_channel to be %q, got %q", "Lobby Alpha", got)
|
||||
}
|
||||
if got := cmd.Params["client_default_channel_password"]; got != prepareClientPassword("channel secret") {
|
||||
t.Errorf(
|
||||
"expected client_default_channel_password to be %q, got %q",
|
||||
prepareClientPassword("channel secret"),
|
||||
got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClientInitCommand_PreservesCredentialFieldOrder(t *testing.T) {
|
||||
id, err := crypto.IdentityFromString(testClientIdentity)
|
||||
if err != nil {
|
||||
t.Fatalf("IdentityFromString: %v", err)
|
||||
}
|
||||
|
||||
c := NewClient(
|
||||
id,
|
||||
"127.0.0.1:9987",
|
||||
"Test Bot",
|
||||
WithServerPassword("server secret"),
|
||||
WithDefaultChannel("Lobby Alpha"),
|
||||
WithDefaultChannelPassword("channel secret"),
|
||||
)
|
||||
|
||||
raw := c.buildClientInitCommand()
|
||||
defaultChannelIndex := indexOfOrFail(t, raw, "client_default_channel=Lobby\\sAlpha")
|
||||
defaultChannelPasswordIndex := indexOfOrFail(
|
||||
t,
|
||||
raw,
|
||||
"client_default_channel_password="+commands.Escape(prepareClientPassword("channel secret")),
|
||||
)
|
||||
serverPasswordIndex := indexOfOrFail(
|
||||
t,
|
||||
raw,
|
||||
"client_server_password="+commands.Escape(prepareClientPassword("server secret")),
|
||||
)
|
||||
metaDataIndex := indexOfOrFail(t, raw, "client_meta_data=")
|
||||
|
||||
if defaultChannelIndex >= defaultChannelPasswordIndex ||
|
||||
defaultChannelPasswordIndex >= serverPasswordIndex ||
|
||||
serverPasswordIndex >= metaDataIndex {
|
||||
t.Fatalf("unexpected credential field order in %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePacket_Init1_Step0_SendsStep1(t *testing.T) {
|
||||
c, serverConn := newTestClientWithPipe(t)
|
||||
c.handler.OnPacket = c.handlePacket
|
||||
c.handler.OnClosed = func(error) {}
|
||||
|
||||
// Drain initial C2S Init1 from Start().
|
||||
_ = readFromPipe(t, serverConn)
|
||||
|
||||
// Build step-0 server response: data[0]=0x00
|
||||
step0 := make([]byte, 21)
|
||||
step0[0] = 0x00
|
||||
binary.LittleEndian.PutUint32(step0[9:13], 0xCAFEBABE)
|
||||
|
||||
// Wrap as S2C Init1 raw packet: [8 tag][2 ID][1 TypeFlagged][21 payload]
|
||||
pktBytes := make([]byte, 8+3+len(step0))
|
||||
pktBytes[10] = 0x08 // PacketTypeInit1
|
||||
copy(pktBytes[11:], step0)
|
||||
_, writeErr := serverConn.Write(pktBytes)
|
||||
if writeErr != nil {
|
||||
t.Fatalf("Write: %v", writeErr)
|
||||
}
|
||||
|
||||
// handlePacket calls handler.SendPacket(8, step1Response, 0),
|
||||
// which writes through the pipe.
|
||||
resp := readFromPipe(t, serverConn)
|
||||
if len(resp) < 13 {
|
||||
t.Fatalf("expected step-1 response, got %d bytes", len(resp))
|
||||
}
|
||||
if resp[12]&0x0F != 0x08 {
|
||||
t.Errorf("expected PacketTypeInit1 (8), got %d", resp[12]&0x0F)
|
||||
}
|
||||
// Step-1 payload starts with 0x01
|
||||
if resp[13] != 0x01 {
|
||||
t.Errorf("expected step 0x01 payload, got 0x%02x", resp[13])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePacket_CommandType_RoutedToHandleCommandLines(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
entered := make(chan struct{}, 1)
|
||||
c.OnClientEnter(func(_ ClientInfo) { entered <- struct{}{} })
|
||||
c.rebuildMiddlewareChains()
|
||||
|
||||
enterLine := "notifycliententerview clid=9 client_nickname=G" +
|
||||
" cid=1 client_type=0 client_servergroups= client_unique_identifier=g"
|
||||
p := &transport.Packet{
|
||||
TypeFlagged: 0x02, // PacketTypeCommand
|
||||
Data: []byte(enterLine),
|
||||
}
|
||||
c.handlePacket(p)
|
||||
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(time.Second):
|
||||
t.Error("handlePacket did not route Command to handleCommandLines")
|
||||
}
|
||||
}
|
||||
|
||||
// readFromPipe reads one datagram from the server-side pipe with a 2s timeout.
|
||||
func readFromPipe(t *testing.T, server *pipePair) []byte {
|
||||
t.Helper()
|
||||
buf := make([]byte, 4096)
|
||||
done := make(chan []byte, 1)
|
||||
go func() {
|
||||
n, readErr := server.Read(buf)
|
||||
if readErr != nil {
|
||||
done <- nil
|
||||
|
||||
return
|
||||
}
|
||||
cp := make([]byte, n)
|
||||
copy(cp, buf[:n])
|
||||
done <- cp
|
||||
}()
|
||||
select {
|
||||
case data := <-done:
|
||||
return data
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("readFromPipe: timed out")
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func indexOfOrFail(t *testing.T, s string, needle string) int {
|
||||
t.Helper()
|
||||
|
||||
idx := strings.Index(s, needle)
|
||||
if idx < 0 {
|
||||
t.Fatalf("expected %q to contain %q", s, needle)
|
||||
}
|
||||
|
||||
return idx
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package teamspeak
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func parseUint64Value(s string) (uint64, error) {
|
||||
return strconv.ParseUint(s, 10, 64)
|
||||
}
|
||||
|
||||
func parseUint16Value(s string) (uint16, error) {
|
||||
v, err := strconv.ParseUint(s, 10, 16)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return uint16(v), nil
|
||||
}
|
||||
|
||||
func parseIntValue(s string) (int, error) {
|
||||
v, err := strconv.ParseInt(s, 10, strconv.IntSize)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if strconv.IntSize == 32 && v > math.MaxInt32 {
|
||||
return math.MaxInt32, nil
|
||||
}
|
||||
|
||||
return int(v), nil
|
||||
}
|
||||
|
||||
func parseInt64Value(s string) (int64, error) {
|
||||
return strconv.ParseInt(s, 10, 64)
|
||||
}
|
||||
|
||||
// parseBoolValue 解析 TS3 协议中的布尔值:
|
||||
// "1" / "true" → true, 其他 → false
|
||||
func parseBoolValue(s string) bool {
|
||||
return s == "1" || s == "true"
|
||||
}
|
||||
|
||||
// isAutoNicknameMatch reports whether actual equals expected or expected followed by only
|
||||
// digits — the pattern TeamSpeak uses when a requested nickname is already taken.
|
||||
func isAutoNicknameMatch(expected, actual string) bool {
|
||||
if actual == expected {
|
||||
return true
|
||||
}
|
||||
if !strings.HasPrefix(actual, expected) {
|
||||
return false
|
||||
}
|
||||
suffix := strings.TrimPrefix(actual, expected)
|
||||
for i := range len(suffix) {
|
||||
if suffix[i] < '0' || suffix[i] > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// splitCommandRows expands a pipe-separated multi-row TS3 command line into individual
|
||||
// rows, each prefixed with the command name.
|
||||
func splitCommandRows(line string) []string {
|
||||
before, after, ok := strings.Cut(line, " ")
|
||||
if !ok {
|
||||
return []string{line}
|
||||
}
|
||||
name := before
|
||||
rest := after
|
||||
if !strings.Contains(rest, "|") {
|
||||
return []string{line}
|
||||
}
|
||||
parts := strings.Split(rest, "|")
|
||||
rows := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, name+" "+part)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return []string{line}
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package teamspeak
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSplitCommandRows_SingleLine(t *testing.T) {
|
||||
rows := splitCommandRows("clientlist")
|
||||
if len(rows) != 1 || rows[0] != "clientlist" {
|
||||
t.Errorf("unexpected rows: %v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitCommandRows_NoArgsNoPipe(t *testing.T) {
|
||||
rows := splitCommandRows("hello key=val")
|
||||
if len(rows) != 1 || rows[0] != "hello key=val" {
|
||||
t.Errorf("unexpected rows: %v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitCommandRows_PipeSplitsToPrefixedRows(t *testing.T) {
|
||||
rows := splitCommandRows("notifycliententerview clid=1|clid=2|clid=3")
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("expected 3 rows, got %d: %v", len(rows), rows)
|
||||
}
|
||||
if rows[0] != "notifycliententerview clid=1" {
|
||||
t.Errorf("row[0] = %q", rows[0])
|
||||
}
|
||||
if rows[1] != "notifycliententerview clid=2" {
|
||||
t.Errorf("row[1] = %q", rows[1])
|
||||
}
|
||||
if rows[2] != "notifycliententerview clid=3" {
|
||||
t.Errorf("row[2] = %q", rows[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitCommandRows_EmptyPartSkipped(t *testing.T) {
|
||||
// Leading/trailing | in "rest" produces empty parts, which are skipped.
|
||||
rows := splitCommandRows("cmd a=1||b=2")
|
||||
// empty part skipped → 2 rows
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("expected 2 rows, got %d: %v", len(rows), rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitCommandRows_NoNameNoSpace(t *testing.T) {
|
||||
// A string without a space → treated as a single command with no args.
|
||||
rows := splitCommandRows("justcommand")
|
||||
if len(rows) != 1 || rows[0] != "justcommand" {
|
||||
t.Errorf("unexpected: %v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAutoNicknameMatch_ExactMatch(t *testing.T) {
|
||||
if !isAutoNicknameMatch("Bot", "Bot") {
|
||||
t.Error("exact match should return true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAutoNicknameMatch_NumericSuffix(t *testing.T) {
|
||||
if !isAutoNicknameMatch("Bot", "Bot123") {
|
||||
t.Error("numeric suffix should match")
|
||||
}
|
||||
if !isAutoNicknameMatch("Bot", "Bot1") {
|
||||
t.Error("single digit suffix should match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAutoNicknameMatch_NonNumericSuffix(t *testing.T) {
|
||||
if isAutoNicknameMatch("Bot", "BotX") {
|
||||
t.Error("non-numeric suffix should not match")
|
||||
}
|
||||
if isAutoNicknameMatch("Bot", "Bot1a") {
|
||||
t.Error("alphanumeric suffix should not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAutoNicknameMatch_NoPrefixMatch(t *testing.T) {
|
||||
if isAutoNicknameMatch("Bot", "OtherBot") {
|
||||
t.Error("different prefix should not match")
|
||||
}
|
||||
if isAutoNicknameMatch("Bot", "") {
|
||||
t.Error("empty string should not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandTracker_RegisterAndResolve(t *testing.T) {
|
||||
tr := newCommandTracker()
|
||||
|
||||
rc, ch := tr.register()
|
||||
if rc == 0 {
|
||||
t.Error("expected non-zero rc")
|
||||
}
|
||||
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
tr.resolve(rc, nil)
|
||||
}()
|
||||
|
||||
select {
|
||||
case result := <-ch:
|
||||
if result.Err != nil {
|
||||
t.Errorf("unexpected error: %v", result.Err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("resolve timed out")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandTracker_CollectAndResolveWithData(t *testing.T) {
|
||||
tr := newCommandTracker()
|
||||
rc, ch := tr.register()
|
||||
|
||||
tr.collect(map[string]string{"k": "v"})
|
||||
tr.resolve(rc, nil)
|
||||
|
||||
result := <-ch
|
||||
if len(result.Data) != 1 || result.Data[0]["k"] != "v" {
|
||||
t.Errorf("unexpected data: %v", result.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandTracker_UnregisterPreventsFire(t *testing.T) {
|
||||
tr := newCommandTracker()
|
||||
rc, ch := tr.register()
|
||||
tr.unregister(rc)
|
||||
// Resolving after unregister should be a no-op.
|
||||
tr.resolve(rc, nil)
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
t.Error("channel should not receive after unregister")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandTracker_Reset(t *testing.T) {
|
||||
tr := newCommandTracker()
|
||||
_, _ = tr.register()
|
||||
_, _ = tr.register()
|
||||
tr.reset()
|
||||
|
||||
// After reset, pending map should be empty; new resolve is a no-op.
|
||||
tr.resolve(1, nil)
|
||||
tr.resolve(2, nil)
|
||||
}
|
||||
|
||||
func TestCommandTracker_RCMonotoneIncreasing(t *testing.T) {
|
||||
tr := newCommandTracker()
|
||||
rc1, _ := tr.register()
|
||||
rc2, _ := tr.register()
|
||||
if rc2 <= rc1 {
|
||||
t.Errorf("expected rc2 > rc1, got rc1=%d rc2=%d", rc1, rc2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandThrottle_InitialTokensAllowImmediate(t *testing.T) {
|
||||
th := newCommandThrottle()
|
||||
// Should not block with fresh tokens.
|
||||
ctx := context.Background()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_ = th.wait(ctx)
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("wait blocked unexpectedly on fresh throttle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandThrottle_ContextCancelUnblocks(t *testing.T) {
|
||||
th := newCommandThrottle()
|
||||
// Drain all tokens (default 5, max 8).
|
||||
ctx := context.Background()
|
||||
for range 8 {
|
||||
_ = th.wait(ctx)
|
||||
}
|
||||
|
||||
// Now tokens are exhausted; cancel should unblock.
|
||||
cancelCtx, cancel := context.WithCancel(context.Background())
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- th.wait(cancelCtx)
|
||||
}()
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("expected context.Canceled, got %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("wait did not unblock after context cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandThrottle_Concurrent(t *testing.T) {
|
||||
th := newCommandThrottle()
|
||||
var count atomic.Int32
|
||||
ctx := context.Background()
|
||||
start := make(chan struct{})
|
||||
|
||||
const goroutines = 5
|
||||
done := make(chan struct{}, goroutines)
|
||||
for range goroutines {
|
||||
go func() {
|
||||
<-start
|
||||
_ = th.wait(ctx)
|
||||
count.Add(1)
|
||||
done <- struct{}{}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
|
||||
for range goroutines {
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Errorf("throttle wait did not complete (got %d/%d)", count.Load(), goroutines)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
if count.Load() != goroutines {
|
||||
t.Errorf("expected %d completions, got %d", goroutines, count.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUint64Value_Valid(t *testing.T) {
|
||||
v, err := parseUint64Value("42")
|
||||
if err != nil || v != 42 {
|
||||
t.Fatalf("expected 42 with nil error, got value=%d err=%v", v, err)
|
||||
}
|
||||
v, err = parseUint64Value("0")
|
||||
if err != nil || v != 0 {
|
||||
t.Fatalf("expected 0 with nil error, got value=%d err=%v", v, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUint64Value_Invalid_ReturnsError(t *testing.T) {
|
||||
_, err := parseUint64Value("abc")
|
||||
if err == nil {
|
||||
t.Error("expected parse error for invalid input")
|
||||
}
|
||||
_, err = parseUint64Value("")
|
||||
if err == nil {
|
||||
t.Error("expected parse error for empty input")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
//go:build integration
|
||||
|
||||
package teamspeak_test
|
||||
|
||||
// Integration tests against a live TeamSpeak 3 server (build tag: integration).
|
||||
//
|
||||
// Run locally:
|
||||
//
|
||||
// docker compose -f docker-compose.integration.yml up -d --wait
|
||||
// TEAMSPEAK_ADDR=127.0.0.1:9987 go test -tags integration ./... -v -timeout 120s
|
||||
// docker compose -f docker-compose.integration.yml down
|
||||
//
|
||||
// In CI the server is provided by the workflow's service container and
|
||||
// TEAMSPEAK_ADDR is set automatically.
|
||||
//
|
||||
// # Notes
|
||||
//
|
||||
// - A single shared client is reused across tests to avoid TS3 anti-flood
|
||||
// protection, which bans IPs that establish too many connections quickly.
|
||||
// - Some commands (clientlist, channellist) require elevated server group
|
||||
// permissions that the default "Guest" group does not have. Those tests
|
||||
// skip automatically instead of failing hard.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
teamspeak "github.com/honeybbq/teamspeak-go"
|
||||
"github.com/honeybbq/teamspeak-go/crypto"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
sharedClient *teamspeak.Client
|
||||
sharedOnce sync.Once
|
||||
sharedErr error
|
||||
)
|
||||
|
||||
var (
|
||||
integrationServerPassword = os.Getenv("TEAMSPEAK_SERVER_PASSWORD")
|
||||
integrationDefaultChannel = os.Getenv("TEAMSPEAK_DEFAULT_CHANNEL")
|
||||
integrationDefaultChannelPassword = os.Getenv("TEAMSPEAK_DEFAULT_CHANNEL_PASSWORD")
|
||||
)
|
||||
|
||||
func integrationClientOptions(logger *slog.Logger) []teamspeak.ClientOption {
|
||||
opts := make([]teamspeak.ClientOption, 0, 4)
|
||||
if logger != nil {
|
||||
opts = append(opts, teamspeak.WithLogger(logger))
|
||||
}
|
||||
if integrationServerPassword != "" {
|
||||
opts = append(opts, teamspeak.WithServerPassword(integrationServerPassword))
|
||||
}
|
||||
if integrationDefaultChannel != "" {
|
||||
opts = append(opts, teamspeak.WithDefaultChannel(integrationDefaultChannel))
|
||||
}
|
||||
if integrationDefaultChannelPassword != "" {
|
||||
opts = append(opts, teamspeak.WithDefaultChannelPassword(integrationDefaultChannelPassword))
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
func requireTeamSpeakAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
addr := os.Getenv("TEAMSPEAK_ADDR")
|
||||
if addr == "" {
|
||||
t.Skip("TEAMSPEAK_ADDR not set — skip integration test (set TEAMSPEAK_ADDR=host:port to enable)")
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
func requireSharedClient(t *testing.T) *teamspeak.Client {
|
||||
t.Helper()
|
||||
addr := requireTeamSpeakAddr(t)
|
||||
|
||||
sharedOnce.Do(func() {
|
||||
id, err := crypto.GenerateIdentity(8)
|
||||
if err != nil {
|
||||
sharedErr = err
|
||||
return
|
||||
}
|
||||
c := teamspeak.NewClient(id, addr, "teamspeak-go-integ", integrationClientOptions(nil)...)
|
||||
if err = c.Connect(); err != nil {
|
||||
sharedErr = err
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err = c.WaitConnected(ctx); err != nil {
|
||||
sharedErr = err
|
||||
return
|
||||
}
|
||||
sharedClient = c
|
||||
})
|
||||
|
||||
if sharedErr != nil {
|
||||
t.Fatalf("shared client setup failed: %v", sharedErr)
|
||||
}
|
||||
return sharedClient
|
||||
}
|
||||
|
||||
func skipOnPermErr(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil && strings.Contains(err.Error(), "insufficient") {
|
||||
t.Skipf("skipping — server returned permission error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newConnectedIntegrationClient(t *testing.T, addr string, nicknamePrefix string, logger *slog.Logger) *teamspeak.Client {
|
||||
t.Helper()
|
||||
|
||||
id, err := crypto.GenerateIdentity(8)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateIdentity: %v", err)
|
||||
}
|
||||
|
||||
nickname := nicknamePrefix + strconv.FormatInt(time.Now().UTC().UnixNano()%1_000_000, 10)
|
||||
|
||||
// TS3 anti-flood may temporarily ban IPs that connect too frequently.
|
||||
// Retry with backoff to handle transient bans in CI.
|
||||
var client *teamspeak.Client
|
||||
for attempt := range 3 {
|
||||
if attempt > 0 {
|
||||
time.Sleep(time.Duration(attempt*5) * time.Second)
|
||||
}
|
||||
client = teamspeak.NewClient(id, addr, nickname, integrationClientOptions(logger)...)
|
||||
if err = client.Connect(); err != nil {
|
||||
continue
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
err = client.WaitConnected(ctx)
|
||||
cancel()
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
_ = client.Disconnect()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("WaitConnected(%s) after retries: %v", nickname, err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = client.Disconnect()
|
||||
})
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
func mapKeys(m map[string]string) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func extractJSONField(s string, needle string) string {
|
||||
idx := strings.Index(s, needle)
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
start := idx + len(needle)
|
||||
end := strings.Index(s[start:], "\"")
|
||||
if end < 0 {
|
||||
return ""
|
||||
}
|
||||
return s[start : start+end]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegration_Connect(t *testing.T) {
|
||||
c := requireSharedClient(t)
|
||||
|
||||
clid := c.ClientID()
|
||||
if clid == 0 {
|
||||
t.Error("expected non-zero client ID after connect")
|
||||
}
|
||||
t.Logf("connected: clid=%d", clid)
|
||||
}
|
||||
|
||||
func TestIntegration_ConnectWithOptionalHandshakeAuth(t *testing.T) {
|
||||
c := requireSharedClient(t)
|
||||
|
||||
t.Logf(
|
||||
"connect auth enabled: serverPassword=%t defaultChannel=%t defaultChannelPassword=%t",
|
||||
integrationServerPassword != "",
|
||||
integrationDefaultChannel != "",
|
||||
integrationDefaultChannelPassword != "",
|
||||
)
|
||||
if c.ClientID() == 0 {
|
||||
t.Error("expected non-zero client ID after connect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Disconnect(t *testing.T) {
|
||||
addr := requireTeamSpeakAddr(t)
|
||||
|
||||
id, err := crypto.GenerateIdentity(8)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateIdentity: %v", err)
|
||||
}
|
||||
c := teamspeak.NewClient(id, addr, "teamspeak-go-integ-disc", integrationClientOptions(nil)...)
|
||||
|
||||
if err = c.Connect(); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err = c.WaitConnected(ctx); err != nil {
|
||||
t.Fatalf("WaitConnected: %v", err)
|
||||
}
|
||||
|
||||
disconnected := make(chan error, 1)
|
||||
c.OnDisconnected(func(e error) { disconnected <- e })
|
||||
|
||||
if err = c.Disconnect(); err != nil {
|
||||
t.Logf("Disconnect returned (non-fatal): %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-disconnected:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("OnDisconnected not fired after Disconnect()")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegration_ListClients(t *testing.T) {
|
||||
c := requireSharedClient(t)
|
||||
|
||||
clients, err := c.ListClients()
|
||||
skipOnPermErr(t, err)
|
||||
if err != nil {
|
||||
t.Fatalf("ListClients: %v", err)
|
||||
}
|
||||
if len(clients) == 0 {
|
||||
t.Fatal("expected at least one client (ourselves)")
|
||||
}
|
||||
|
||||
ownID := c.ClientID()
|
||||
found := false
|
||||
for _, cl := range clients {
|
||||
if cl.ID == ownID {
|
||||
found = true
|
||||
t.Logf("self: clid=%d nick=%q cid=%d", cl.ID, cl.Nickname, cl.ChannelID)
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("own clid=%d not found in clientlist", ownID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_ListChannels(t *testing.T) {
|
||||
c := requireSharedClient(t)
|
||||
|
||||
channels, err := c.ListChannels()
|
||||
skipOnPermErr(t, err)
|
||||
if err != nil {
|
||||
t.Fatalf("ListChannels: %v", err)
|
||||
}
|
||||
if len(channels) == 0 {
|
||||
t.Fatal("expected at least one channel (default channel)")
|
||||
}
|
||||
t.Logf("channels: %d found, first=%q", len(channels), channels[0].Name)
|
||||
}
|
||||
|
||||
func TestIntegration_JoinsConfiguredDefaultChannel(t *testing.T) {
|
||||
if integrationDefaultChannel == "" {
|
||||
t.Skip("TEAMSPEAK_DEFAULT_CHANNEL not set")
|
||||
}
|
||||
|
||||
c := requireSharedClient(t)
|
||||
|
||||
channels, err := c.ListChannels()
|
||||
skipOnPermErr(t, err)
|
||||
if err != nil {
|
||||
t.Fatalf("ListChannels: %v", err)
|
||||
}
|
||||
|
||||
clients, err := c.ListClients()
|
||||
skipOnPermErr(t, err)
|
||||
if err != nil {
|
||||
t.Fatalf("ListClients: %v", err)
|
||||
}
|
||||
|
||||
var self *teamspeak.ClientInfo
|
||||
for i := range clients {
|
||||
if clients[i].ID == c.ClientID() {
|
||||
self = &clients[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if self == nil {
|
||||
t.Fatal("expected to find ourselves in client list")
|
||||
}
|
||||
|
||||
var currentChannel *teamspeak.ChannelInfo
|
||||
for i := range channels {
|
||||
if channels[i].ID == self.ChannelID {
|
||||
currentChannel = &channels[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if currentChannel == nil {
|
||||
t.Fatalf("expected to resolve current channel for cid=%d", self.ChannelID)
|
||||
}
|
||||
if currentChannel.Name != integrationDefaultChannel {
|
||||
t.Fatalf("expected current channel %q, got %q", integrationDefaultChannel, currentChannel.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_GetClientInfo(t *testing.T) {
|
||||
c := requireSharedClient(t)
|
||||
|
||||
info, err := c.GetClientInfo(c.ClientID())
|
||||
skipOnPermErr(t, err)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClientInfo: %v", err)
|
||||
}
|
||||
if len(info) == 0 {
|
||||
t.Fatal("expected non-empty client info map")
|
||||
}
|
||||
if info["client_nickname"] == "" {
|
||||
t.Errorf("expected client_nickname in clientinfo response; got keys: %v", mapKeys(info))
|
||||
}
|
||||
t.Logf("clientinfo keys: %v", mapKeys(info))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text messages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegration_TextPrivateNotifyFields(t *testing.T) {
|
||||
addr := requireTeamSpeakAddr(t)
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
debugLogger := slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
|
||||
receiver := newConnectedIntegrationClient(t, addr, "rx", debugLogger)
|
||||
|
||||
sender := newConnectedIntegrationClient(t, addr, "tx", slog.Default())
|
||||
senderInfo, err := sender.GetClientInfo(sender.ClientID())
|
||||
if err != nil {
|
||||
t.Fatalf("sender GetClientInfo: %v", err)
|
||||
}
|
||||
senderUID := strings.TrimSpace(senderInfo["client_unique_identifier"])
|
||||
t.Logf("sender clientinfo keys: %v", mapKeys(senderInfo))
|
||||
|
||||
received := make(chan teamspeak.TextMessage, 1)
|
||||
receiver.OnTextMessage(func(msg teamspeak.TextMessage) {
|
||||
select {
|
||||
case received <- msg:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
probeText := fmt.Sprintf("cursor-probe-%d", time.Now().UTC().UnixNano())
|
||||
if err := sender.SendTextMessage(1, uint64(receiver.ClientID()), probeText); err != nil {
|
||||
t.Fatalf("sender SendTextMessage private: %v", err)
|
||||
}
|
||||
|
||||
var msg teamspeak.TextMessage
|
||||
select {
|
||||
case msg = <-received:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("timeout waiting for private text notification; logs=%s", logBuf.String())
|
||||
}
|
||||
|
||||
if msg.Message != probeText {
|
||||
t.Fatalf("unexpected message text: got %q want %q", msg.Message, probeText)
|
||||
}
|
||||
|
||||
logs := logBuf.String()
|
||||
t.Logf("receiver logs: %s", logs)
|
||||
|
||||
if !strings.Contains(logs, "\"name\":\"notifytextmessage\"") {
|
||||
t.Fatalf("expected notifytextmessage in logs, got: %s", logs)
|
||||
}
|
||||
|
||||
targetNeedle := fmt.Sprintf("\"target\":\"%d\"", receiver.ClientID())
|
||||
if !strings.Contains(logs, targetNeedle) {
|
||||
t.Fatalf("expected raw notify target %s in logs, got: %s", targetNeedle, logs)
|
||||
}
|
||||
|
||||
rawInvokerUID := extractJSONField(logs, "\"invokeruid\":\"")
|
||||
if rawInvokerUID == "" {
|
||||
t.Fatalf("expected raw notify invokeruid in logs, got: %s", logs)
|
||||
}
|
||||
|
||||
if msg.TargetMode != 1 {
|
||||
t.Fatalf("unexpected target mode: got %d want 1", msg.TargetMode)
|
||||
}
|
||||
|
||||
if msg.TargetID != uint64(receiver.ClientID()) {
|
||||
t.Fatalf("parsed TargetID mismatch: got %d want %d", msg.TargetID, receiver.ClientID())
|
||||
}
|
||||
|
||||
expectedInvokerUID := rawInvokerUID
|
||||
if senderUID != "" {
|
||||
expectedInvokerUID = senderUID
|
||||
}
|
||||
if strings.TrimSpace(msg.InvokerUID) != expectedInvokerUID {
|
||||
t.Fatalf("parsed InvokerUID mismatch: got %q want %q", msg.InvokerUID, expectedInvokerUID)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Poke
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegration_PokeSendAndReceive(t *testing.T) {
|
||||
addr := requireTeamSpeakAddr(t)
|
||||
|
||||
sender := newConnectedIntegrationClient(t, addr, "poke-tx", slog.Default())
|
||||
receiver := newConnectedIntegrationClient(t, addr, "poke-rx", slog.Default())
|
||||
|
||||
pokeMsg := fmt.Sprintf("poke-test-%d", time.Now().UTC().UnixNano())
|
||||
|
||||
poked := make(chan teamspeak.PokeEvent, 1)
|
||||
receiver.OnPoked(func(e teamspeak.PokeEvent) {
|
||||
select {
|
||||
case poked <- e:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
if err := sender.Poke(receiver.ClientID(), pokeMsg); err != nil {
|
||||
t.Fatalf("Poke: %v", err)
|
||||
}
|
||||
t.Logf("sent poke from clid=%d to clid=%d msg=%q", sender.ClientID(), receiver.ClientID(), pokeMsg)
|
||||
|
||||
select {
|
||||
case evt := <-poked:
|
||||
t.Logf("poke received: invoker=%q uid=%q msg=%q", evt.InvokerName, evt.InvokerUID, evt.Message)
|
||||
if evt.InvokerID != sender.ClientID() {
|
||||
t.Errorf("InvokerID mismatch: got %d want %d", evt.InvokerID, sender.ClientID())
|
||||
}
|
||||
if evt.Message != pokeMsg {
|
||||
t.Errorf("Message mismatch: got %q want %q", evt.Message, pokeMsg)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("timeout waiting for poke notification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_PokeEmptyMessage(t *testing.T) {
|
||||
addr := requireTeamSpeakAddr(t)
|
||||
|
||||
sender := newConnectedIntegrationClient(t, addr, "poke-tx2", slog.Default())
|
||||
receiver := newConnectedIntegrationClient(t, addr, "poke-rx2", slog.Default())
|
||||
|
||||
poked := make(chan teamspeak.PokeEvent, 1)
|
||||
receiver.OnPoked(func(e teamspeak.PokeEvent) {
|
||||
select {
|
||||
case poked <- e:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
if err := sender.Poke(receiver.ClientID(), ""); err != nil {
|
||||
t.Fatalf("Poke (empty): %v", err)
|
||||
}
|
||||
t.Logf("sent empty poke from clid=%d to clid=%d", sender.ClientID(), receiver.ClientID())
|
||||
|
||||
select {
|
||||
case evt := <-poked:
|
||||
t.Logf("empty poke received: invoker=%q msg=%q", evt.InvokerName, evt.Message)
|
||||
if evt.Message != "" {
|
||||
t.Errorf("expected empty message, got %q", evt.Message)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("timeout waiting for empty poke notification")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package teamspeak
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/commands"
|
||||
)
|
||||
|
||||
func TestHandleClientEnterView_AddsToClients(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
const enterCmd = "notifycliententerview clid=5 client_nickname=Alice" +
|
||||
" cid=10 client_type=0 client_servergroups= client_unique_identifier=uid123"
|
||||
c.handleClientEnterView(commands.ParseCommand(enterCmd))
|
||||
|
||||
c.mu.Lock()
|
||||
info, ok := c.clients[5]
|
||||
c.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
t.Fatal("expected client 5 to be in clients map")
|
||||
}
|
||||
if info.Nickname != "Alice" {
|
||||
t.Errorf("expected nickname 'Alice', got %q", info.Nickname)
|
||||
}
|
||||
if info.ChannelID != 10 {
|
||||
t.Errorf("expected channelID 10, got %d", info.ChannelID)
|
||||
}
|
||||
if info.UID != "uid123" {
|
||||
t.Errorf("expected uid 'uid123', got %q", info.UID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleClientEnterView_TriggersCallback(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
entered := make(chan ClientInfo, 1)
|
||||
c.OnClientEnter(func(ci ClientInfo) { entered <- ci })
|
||||
c.rebuildMiddlewareChains()
|
||||
|
||||
const enterCmd2 = "notifycliententerview clid=5 client_nickname=Alice" +
|
||||
" cid=10 client_type=0 client_servergroups= client_unique_identifier=uid123"
|
||||
c.handleClientEnterView(commands.ParseCommand(enterCmd2))
|
||||
|
||||
select {
|
||||
case ci := <-entered:
|
||||
if ci.ID != 5 {
|
||||
t.Errorf("expected ID 5, got %d", ci.ID)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("OnClientEnter callback not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleClientEnterView_SetsOwnClidOnNicknameMatch(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
const enterSelf = "notifycliententerview clid=7 client_nickname=TestBot" +
|
||||
" cid=1 client_type=0 client_servergroups= client_unique_identifier=x"
|
||||
c.handleClientEnterView(commands.ParseCommand(enterSelf))
|
||||
|
||||
c.mu.Lock()
|
||||
clid := c.clid
|
||||
c.mu.Unlock()
|
||||
|
||||
if clid != 7 {
|
||||
t.Errorf("expected own clid=7 after nickname match, got %d", clid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleClientEnterView_InvalidClidIgnored(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
const enterZero = "notifycliententerview clid=0 client_nickname=X" +
|
||||
" cid=1 client_type=0 client_servergroups= client_unique_identifier=x"
|
||||
c.handleClientEnterView(commands.ParseCommand(enterZero))
|
||||
|
||||
c.mu.Lock()
|
||||
_, ok := c.clients[0]
|
||||
c.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
t.Error("client with clid=0 should be ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleClientLeftView_RemovesFromClients(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
c.clients[5] = ClientInfo{ID: 5, Nickname: "Alice"}
|
||||
|
||||
cmd := commands.ParseCommand("notifyclientleftview clid=5 reasonid=8 reasonmsg=")
|
||||
c.handleClientLeftView(cmd)
|
||||
|
||||
c.mu.Lock()
|
||||
_, ok := c.clients[5]
|
||||
c.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
t.Error("expected client 5 to be removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleClientLeftView_KickSelf_TriggersKicked(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
c.mu.Lock()
|
||||
c.clid = 5
|
||||
c.clients[5] = ClientInfo{ID: 5}
|
||||
c.mu.Unlock()
|
||||
|
||||
kicked := make(chan string, 1)
|
||||
c.OnKicked(func(msg string) { kicked <- msg })
|
||||
|
||||
cmd := commands.ParseCommand("notifyclientleftview clid=5 reasonid=5 reasonmsg=banned")
|
||||
c.handleClientLeftView(cmd)
|
||||
|
||||
select {
|
||||
case msg := <-kicked:
|
||||
if msg != "banned" {
|
||||
t.Errorf("expected 'banned', got %q", msg)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("OnKicked not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleClientLeftView_NonKickReasonid_NoKickedCallback(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
c.mu.Lock()
|
||||
c.clid = 5
|
||||
c.clients[5] = ClientInfo{ID: 5}
|
||||
c.mu.Unlock()
|
||||
|
||||
kicked := make(chan string, 1)
|
||||
c.OnKicked(func(msg string) { kicked <- msg })
|
||||
|
||||
// reasonid=8 = normal leave, not a kick
|
||||
cmd := commands.ParseCommand("notifyclientleftview clid=5 reasonid=8 reasonmsg=")
|
||||
c.handleClientLeftView(cmd)
|
||||
|
||||
select {
|
||||
case <-kicked:
|
||||
t.Error("OnKicked should not be called for normal leave")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleClientMoved_UpdatesChannelID(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
c.mu.Lock()
|
||||
c.clients[3] = ClientInfo{ID: 3, ChannelID: 10}
|
||||
c.mu.Unlock()
|
||||
|
||||
const moveCmd = "notifyclientmoved clid=3 ctid=20 reasonid=0" +
|
||||
" invokerid=1 invokername=Admin invokeruid=admin"
|
||||
c.handleClientMoved(commands.ParseCommand(moveCmd))
|
||||
|
||||
c.mu.Lock()
|
||||
info := c.clients[3]
|
||||
c.mu.Unlock()
|
||||
|
||||
if info.ChannelID != 20 {
|
||||
t.Errorf("expected channelID=20, got %d", info.ChannelID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleClientMoved_TriggersCallback(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
c.mu.Lock()
|
||||
c.clients[3] = ClientInfo{ID: 3}
|
||||
c.mu.Unlock()
|
||||
|
||||
moved := make(chan ClientMovedEvent, 1)
|
||||
c.OnClientMoved(func(e ClientMovedEvent) { moved <- e })
|
||||
c.rebuildMiddlewareChains()
|
||||
|
||||
const moveCmd2 = "notifyclientmoved clid=3 ctid=20 reasonid=0" +
|
||||
" invokerid=1 invokername=Admin invokeruid=admin"
|
||||
c.handleClientMoved(commands.ParseCommand(moveCmd2))
|
||||
|
||||
select {
|
||||
case e := <-moved:
|
||||
if e.ID != 3 || e.TargetChannelID != 20 {
|
||||
t.Errorf("unexpected event: %+v", e)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("OnClientMoved callback not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTextMessage_CallsCallback(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
c.mu.Lock()
|
||||
c.clients[2] = ClientInfo{ID: 2, UID: "invokeruid"}
|
||||
c.mu.Unlock()
|
||||
|
||||
msgs := make(chan TextMessage, 1)
|
||||
c.OnTextMessage(func(m TextMessage) { msgs <- m })
|
||||
c.rebuildMiddlewareChains()
|
||||
|
||||
cmd := commands.ParseCommand(
|
||||
"notifytextmessage targetmode=1 target=7 invokerid=2 " +
|
||||
"invokername=Bob invokeruid=notifyuid msg=hello",
|
||||
)
|
||||
c.handleTextMessage(cmd)
|
||||
|
||||
select {
|
||||
case m := <-msgs:
|
||||
if m.Message != "hello" {
|
||||
t.Errorf("expected 'hello', got %q", m.Message)
|
||||
}
|
||||
if m.TargetID != 7 {
|
||||
t.Errorf("expected TargetID 7, got %d", m.TargetID)
|
||||
}
|
||||
if m.InvokerUID != "notifyuid" {
|
||||
t.Errorf("expected InvokerUID from notify payload, got %q", m.InvokerUID)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("OnTextMessage not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTextMessage_FallbackToClientCacheUID(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
c.mu.Lock()
|
||||
c.clients[99] = ClientInfo{ID: 99, UID: "cacheduid"}
|
||||
c.mu.Unlock()
|
||||
|
||||
msgs := make(chan TextMessage, 1)
|
||||
c.OnTextMessage(func(m TextMessage) { msgs <- m })
|
||||
c.rebuildMiddlewareChains()
|
||||
|
||||
cmd := commands.ParseCommand("notifytextmessage targetmode=2 invokerid=99 invokername=Unknown msg=hi")
|
||||
c.handleTextMessage(cmd)
|
||||
|
||||
select {
|
||||
case m := <-msgs:
|
||||
if m.InvokerUID != "cacheduid" {
|
||||
t.Errorf("expected cached UID fallback, got %q", m.InvokerUID)
|
||||
}
|
||||
if m.TargetID != 0 {
|
||||
t.Errorf("expected zero TargetID when target missing, got %d", m.TargetID)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("OnTextMessage not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTextMessage_MissingInvoker_NoUID(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
msgs := make(chan TextMessage, 1)
|
||||
c.OnTextMessage(func(m TextMessage) { msgs <- m })
|
||||
c.rebuildMiddlewareChains()
|
||||
|
||||
cmd := commands.ParseCommand("notifytextmessage targetmode=2 invokerid=99 invokername=Unknown msg=hi")
|
||||
c.handleTextMessage(cmd)
|
||||
|
||||
select {
|
||||
case m := <-msgs:
|
||||
if m.InvokerUID != "" {
|
||||
t.Errorf("expected empty UID for unknown invoker, got %q", m.InvokerUID)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("OnTextMessage not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleClientPoke_TriggersCallback(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
poked := make(chan PokeEvent, 1)
|
||||
c.OnPoked(func(e PokeEvent) { poked <- e })
|
||||
c.rebuildMiddlewareChains()
|
||||
|
||||
cmd := commands.ParseCommand(
|
||||
"notifyclientpoke invokerid=5 invokername=Alice invokeruid=uid123 msg=hello",
|
||||
)
|
||||
c.handleClientPoke(cmd)
|
||||
|
||||
select {
|
||||
case e := <-poked:
|
||||
if e.InvokerID != 5 {
|
||||
t.Errorf("expected InvokerID 5, got %d", e.InvokerID)
|
||||
}
|
||||
if e.InvokerName != "Alice" {
|
||||
t.Errorf("expected InvokerName 'Alice', got %q", e.InvokerName)
|
||||
}
|
||||
if e.InvokerUID != "uid123" {
|
||||
t.Errorf("expected InvokerUID 'uid123', got %q", e.InvokerUID)
|
||||
}
|
||||
if e.Message != "hello" {
|
||||
t.Errorf("expected Message 'hello', got %q", e.Message)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("OnPoked callback not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleClientPoke_EmptyMessage(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
poked := make(chan PokeEvent, 1)
|
||||
c.OnPoked(func(e PokeEvent) { poked <- e })
|
||||
c.rebuildMiddlewareChains()
|
||||
|
||||
cmd := commands.ParseCommand(
|
||||
"notifyclientpoke invokerid=3 invokername=Bob invokeruid=uid456 msg=",
|
||||
)
|
||||
c.handleClientPoke(cmd)
|
||||
|
||||
select {
|
||||
case e := <-poked:
|
||||
if e.Message != "" {
|
||||
t.Errorf("expected empty message, got %q", e.Message)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("OnPoked callback not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNotification_DispatchesPoke(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
poked := make(chan PokeEvent, 1)
|
||||
c.OnPoked(func(e PokeEvent) { poked <- e })
|
||||
c.rebuildMiddlewareChains()
|
||||
|
||||
cmd := commands.ParseCommand(
|
||||
"notifyclientpoke invokerid=5 invokername=Alice invokeruid=uid123 msg=hey",
|
||||
)
|
||||
c.handleNotification(cmd)
|
||||
|
||||
select {
|
||||
case e := <-poked:
|
||||
if e.Message != "hey" {
|
||||
t.Errorf("expected 'hey', got %q", e.Message)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("poke not dispatched through handleNotification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNotification_UnknownNotification_NoError(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
cmd := commands.ParseCommand("notifyunknowncommand foo=bar")
|
||||
c.handleNotification(cmd)
|
||||
}
|
||||
|
||||
func TestHandleNotification_FileTransfer_StartUpload(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
|
||||
ch := make(chan any, 1)
|
||||
c.ftTrack.register()
|
||||
|
||||
cmd := commands.ParseCommand("notifystartupload clientftfid=1 serverftfid=100 ftkey=abc123 port=30033 seekpos=0")
|
||||
c.handleNotification(cmd)
|
||||
close(ch)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package teamspeak
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/commands"
|
||||
)
|
||||
|
||||
func (c *Client) handleNotification(cmd *commands.Command) {
|
||||
switch cmd.Name {
|
||||
case "notifycliententerview":
|
||||
c.handleClientEnterView(cmd)
|
||||
case "notifyclientleftview":
|
||||
c.handleClientLeftView(cmd)
|
||||
case "notifyclientmoved":
|
||||
c.handleClientMoved(cmd)
|
||||
case "notifytextmessage":
|
||||
c.handleTextMessage(cmd)
|
||||
case "notifyclientpoke":
|
||||
c.handleClientPoke(cmd)
|
||||
case "notifyclientneededpermissions":
|
||||
c.logger.Debug("insufficient permissions",
|
||||
slog.String("permid", cmd.Params["permid"]),
|
||||
slog.String("permvalue", cmd.Params["permvalue"]))
|
||||
case "notifystartupload":
|
||||
c.handleStartUpload(cmd)
|
||||
case "notifystartdownload":
|
||||
c.handleStartDownload(cmd)
|
||||
case "notifystatusfiletransfer":
|
||||
c.handleFileTransferStatus(cmd)
|
||||
default:
|
||||
c.logger.Debug("unhandled notification", slog.String("name", cmd.Name))
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) handleClientEnterView(cmd *commands.Command) {
|
||||
nick := cmd.Params["client_nickname"]
|
||||
uid := cmd.Params["client_unique_identifier"]
|
||||
groupsStr := cmd.Params["client_servergroups"]
|
||||
|
||||
clid, _ := parseUint16Value(cmd.Params["clid"])
|
||||
cid, _ := parseUint64Value(cmd.Params["cid"])
|
||||
clientType, _ := parseIntValue(cmd.Params["client_type"])
|
||||
|
||||
groups := make([]string, 0)
|
||||
if groupsStr != "" {
|
||||
groups = strings.Split(groupsStr, ",")
|
||||
}
|
||||
|
||||
if clid != 0 {
|
||||
info := ClientInfo{
|
||||
ID: clid,
|
||||
Nickname: nick,
|
||||
UID: uid,
|
||||
ChannelID: cid,
|
||||
Type: clientType,
|
||||
ServerGroups: groups,
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.clients[clid] = info
|
||||
unescapedNick := commands.Unescape(nick)
|
||||
if isAutoNicknameMatch(c.nickname, unescapedNick) {
|
||||
c.clid = clid
|
||||
c.handler.SetClientID(clid)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
c.notifyEvent(info)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) handleClientLeftView(cmd *commands.Command) {
|
||||
reasonMsg := cmd.Params["reasonmsg"]
|
||||
|
||||
clid, _ := parseUint16Value(cmd.Params["clid"])
|
||||
reasonID, _ := parseIntValue(cmd.Params["reasonid"]) // 4=channel kick, 5=server kick
|
||||
|
||||
if clid != 0 {
|
||||
c.mu.Lock()
|
||||
isSelf := (clid == c.clid)
|
||||
delete(c.clients, clid)
|
||||
c.mu.Unlock()
|
||||
|
||||
evt := ClientLeftViewEvent{
|
||||
ID: clid,
|
||||
ReasonID: reasonID,
|
||||
ReasonMsg: reasonMsg,
|
||||
}
|
||||
|
||||
c.notifyEvent(evt)
|
||||
|
||||
if isSelf && (reasonID == 4 || reasonID == 5) {
|
||||
c.notifyEvent(kickEvent{reason: reasonMsg})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) handleClientMoved(cmd *commands.Command) {
|
||||
clid, _ := parseUint16Value(cmd.Params["clid"])
|
||||
ctid, _ := parseUint64Value(cmd.Params["ctid"])
|
||||
reasonID, _ := parseIntValue(cmd.Params["reasonid"])
|
||||
invokerID, _ := parseUint16Value(cmd.Params["invokerid"])
|
||||
|
||||
if clid != 0 {
|
||||
c.mu.Lock()
|
||||
if info, ok := c.clients[clid]; ok {
|
||||
info.ChannelID = ctid
|
||||
c.clients[clid] = info
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
evt := ClientMovedEvent{
|
||||
ID: clid,
|
||||
TargetChannelID: ctid,
|
||||
ReasonID: reasonID,
|
||||
InvokerID: invokerID,
|
||||
InvokerName: cmd.Params["invokername"],
|
||||
InvokerUID: cmd.Params["invokeruid"],
|
||||
}
|
||||
|
||||
c.notifyEvent(evt)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) handleTextMessage(cmd *commands.Command) {
|
||||
targetMode, _ := parseIntValue(cmd.Params["targetmode"])
|
||||
targetID, _ := parseUint64Value(cmd.Params["target"])
|
||||
invokerID, _ := parseUint16Value(cmd.Params["invokerid"])
|
||||
msg := TextMessage{
|
||||
TargetMode: targetMode,
|
||||
TargetID: targetID,
|
||||
InvokerID: invokerID,
|
||||
InvokerName: cmd.Params["invokername"],
|
||||
InvokerUID: cmd.Params["invokeruid"],
|
||||
Message: cmd.Params["msg"],
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if info, ok := c.clients[invokerID]; ok {
|
||||
if msg.InvokerUID == "" {
|
||||
msg.InvokerUID = info.UID
|
||||
}
|
||||
msg.InvokerGroups = info.ServerGroups
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
c.logger.Debug("text message received",
|
||||
slog.Int("target_mode", targetMode),
|
||||
slog.String("invoker_id", cmd.Params["invokerid"]),
|
||||
slog.String("invoker_name", cmd.Params["invokername"]),
|
||||
slog.String("invoker_uid", msg.InvokerUID),
|
||||
slog.String("message", cmd.Params["msg"]))
|
||||
|
||||
c.notifyEvent(msg)
|
||||
}
|
||||
|
||||
func (c *Client) handleClientPoke(cmd *commands.Command) {
|
||||
invokerID, _ := parseUint16Value(cmd.Params["invokerid"])
|
||||
evt := PokeEvent{
|
||||
InvokerID: invokerID,
|
||||
InvokerName: commands.Unescape(cmd.Params["invokername"]),
|
||||
InvokerUID: cmd.Params["invokeruid"],
|
||||
Message: commands.Unescape(cmd.Params["msg"]),
|
||||
}
|
||||
|
||||
c.logger.Debug("client poked",
|
||||
slog.String("invoker_name", evt.InvokerName),
|
||||
slog.String("invoker_uid", evt.InvokerUID),
|
||||
slog.String("message", evt.Message))
|
||||
|
||||
c.notifyEvent(evt)
|
||||
}
|
||||
|
||||
func (c *Client) handleStartUpload(cmd *commands.Command) {
|
||||
clientftfid, _ := parseUint16Value(cmd.Params["clientftfid"])
|
||||
serverftfid, _ := parseUint16Value(cmd.Params["serverftfid"])
|
||||
port, _ := parseUint16Value(cmd.Params["port"])
|
||||
seekpos, _ := parseUint64Value(cmd.Params["seekpos"])
|
||||
info := FileUploadInfo{
|
||||
ClientFileTransferID: clientftfid,
|
||||
ServerFileTransferID: serverftfid,
|
||||
FileTransferKey: cmd.Params["ftkey"],
|
||||
Port: port,
|
||||
SeekPosition: seekpos,
|
||||
}
|
||||
c.ftTrack.notify(info.ClientFileTransferID, info)
|
||||
}
|
||||
|
||||
func (c *Client) handleStartDownload(cmd *commands.Command) {
|
||||
clientftfid, _ := parseUint16Value(cmd.Params["clientftfid"])
|
||||
serverftfid, _ := parseUint16Value(cmd.Params["serverftfid"])
|
||||
port, _ := parseUint16Value(cmd.Params["port"])
|
||||
size, _ := parseUint64Value(cmd.Params["size"])
|
||||
info := FileDownloadInfo{
|
||||
ClientFileTransferID: clientftfid,
|
||||
ServerFileTransferID: serverftfid,
|
||||
FileTransferKey: cmd.Params["ftkey"],
|
||||
Port: port,
|
||||
Size: size,
|
||||
}
|
||||
c.ftTrack.notify(info.ClientFileTransferID, info)
|
||||
}
|
||||
|
||||
func (c *Client) handleFileTransferStatus(cmd *commands.Command) {
|
||||
clientftfid, _ := parseUint16Value(cmd.Params["clientftfid"])
|
||||
status, _ := parseIntValue(cmd.Params["status"])
|
||||
info := FileTransferStatusInfo{
|
||||
ClientFileTransferID: clientftfid,
|
||||
Status: status,
|
||||
Message: cmd.Params["msg"],
|
||||
}
|
||||
c.ftTrack.notify(info.ClientFileTransferID, info)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package teamspeak
|
||||
|
||||
import (
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/crypto"
|
||||
"github.com/honeybbq/teamspeak-go/transport"
|
||||
)
|
||||
|
||||
const testClientIdentity = "W2OSGpWxkzBPJjt8iyJFsMnqnwHCnxOlmE9gWFOFnKs=:0"
|
||||
|
||||
// testConn is a minimal io.ReadWriteCloser used for tests that only exercise
|
||||
// in-memory logic. Writes are discarded; Read blocks until Close is called.
|
||||
type testConn struct {
|
||||
buf chan []byte
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newTestConn() *testConn {
|
||||
return &testConn{
|
||||
buf: make(chan []byte, 256),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *testConn) Read(b []byte) (int, error) {
|
||||
select {
|
||||
case data, open := <-c.buf:
|
||||
if !open {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(b, data)
|
||||
|
||||
return n, nil
|
||||
case <-c.done:
|
||||
return 0, io.EOF
|
||||
}
|
||||
}
|
||||
|
||||
func (c *testConn) Write(b []byte) (int, error) { return len(b), nil }
|
||||
|
||||
func (c *testConn) Close() error {
|
||||
c.once.Do(func() { close(c.done) })
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// newTestClient creates a Client whose PacketHandler is wired to a no-op testConn.
|
||||
// Goroutines are started so the handler is fully functional. t.Cleanup shuts down.
|
||||
func newTestClient(t *testing.T) *Client {
|
||||
t.Helper()
|
||||
id, err := crypto.IdentityFromString(testClientIdentity)
|
||||
if err != nil {
|
||||
t.Fatalf("IdentityFromString: %v", err)
|
||||
}
|
||||
c := NewClient(id, "127.0.0.1:9987", "TestBot")
|
||||
tc := newTestConn()
|
||||
startErr := c.handler.Start(tc)
|
||||
if startErr != nil {
|
||||
t.Fatalf("handler.Start: %v", startErr)
|
||||
}
|
||||
t.Cleanup(func() { _ = c.handler.Close() })
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// pipePair is an in-memory io.ReadWriteCloser with datagram semantics.
|
||||
// Used for tests that need to inject/read packets from the client handler.
|
||||
type pipePair struct {
|
||||
recv <-chan []byte
|
||||
send chan<- []byte
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
func (p *pipePair) Read(b []byte) (int, error) {
|
||||
select {
|
||||
case data, ok := <-p.recv:
|
||||
if !ok {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(b, data)
|
||||
|
||||
return n, nil
|
||||
case <-p.done:
|
||||
return 0, io.EOF
|
||||
}
|
||||
}
|
||||
|
||||
func (p *pipePair) Write(b []byte) (int, error) {
|
||||
if p.closed.Load() {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
cp := make([]byte, len(b))
|
||||
copy(cp, b)
|
||||
select {
|
||||
case p.send <- cp:
|
||||
return len(b), nil
|
||||
case <-p.done:
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
}
|
||||
|
||||
func (p *pipePair) Close() error {
|
||||
p.once.Do(func() {
|
||||
p.closed.Store(true)
|
||||
close(p.done)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// newPipePair returns two connected pipePair ends:
|
||||
// the first is given to PacketHandler.Start(), the second is the "server" side.
|
||||
func newPipePair() (io.ReadWriteCloser, *pipePair) {
|
||||
toClient := make(chan []byte, 256)
|
||||
fromClient := make(chan []byte, 256)
|
||||
done := make(chan struct{})
|
||||
client := &pipePair{recv: toClient, send: fromClient, done: done}
|
||||
server := &pipePair{recv: fromClient, send: toClient, done: done}
|
||||
|
||||
return client, server
|
||||
}
|
||||
|
||||
// newTestClientWithPipe creates a Client wired to an in-memory pipePair.
|
||||
// The returned server-side *pipePair lets tests read what the handler sends
|
||||
// and inject S2C packets.
|
||||
func newTestClientWithPipe(t *testing.T) (*Client, *pipePair) {
|
||||
t.Helper()
|
||||
id, err := crypto.IdentityFromString(testClientIdentity)
|
||||
if err != nil {
|
||||
t.Fatalf("IdentityFromString: %v", err)
|
||||
}
|
||||
c := NewClient(id, "127.0.0.1:9987", "TestBot")
|
||||
clientConn, serverConn := newPipePair()
|
||||
startErr := c.handler.Start(clientConn)
|
||||
if startErr != nil {
|
||||
t.Fatalf("handler.Start: %v", startErr)
|
||||
}
|
||||
t.Cleanup(func() { _ = c.handler.Close() })
|
||||
|
||||
return c, serverConn
|
||||
}
|
||||
|
||||
// Compile-time guard: PacketHandler.Start must accept an io.ReadWriteCloser.
|
||||
var _ = (*transport.PacketHandler)(nil)
|
||||
@@ -0,0 +1,59 @@
|
||||
package teamspeak
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// commandThrottle is a token-bucket limiter for outbound commands.
|
||||
type commandThrottle struct {
|
||||
lastUpdate time.Time
|
||||
tokens float64
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newCommandThrottle() *commandThrottle {
|
||||
return &commandThrottle{
|
||||
tokens: 5,
|
||||
lastUpdate: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *commandThrottle) wait(ctx context.Context) error {
|
||||
const (
|
||||
tokenRate = 4.0
|
||||
tokenMax = 8.0
|
||||
)
|
||||
|
||||
for {
|
||||
t.mu.Lock()
|
||||
|
||||
now := time.Now()
|
||||
elapsed := now.Sub(t.lastUpdate).Seconds()
|
||||
t.tokens += elapsed * tokenRate
|
||||
if t.tokens > tokenMax {
|
||||
t.tokens = tokenMax
|
||||
}
|
||||
t.lastUpdate = now
|
||||
|
||||
if t.tokens >= 1.0 {
|
||||
t.tokens -= 1.0
|
||||
t.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
waitDur := time.Duration((1.0-t.tokens)/tokenRate*float64(time.Second)) + 10*time.Millisecond
|
||||
t.mu.Unlock()
|
||||
|
||||
timer := time.NewTimer(waitDur)
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package teamspeak
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/commands"
|
||||
)
|
||||
|
||||
var (
|
||||
errFileTransferFailed = errors.New("file transfer failed")
|
||||
errUnexpectedRespType = errors.New("unexpected response type")
|
||||
errFileTransferTimedOut = errors.New("timeout waiting for file transfer notification")
|
||||
)
|
||||
|
||||
// fileTransferTracker correlates clientftfid with notifystart* responses.
|
||||
type fileTransferTracker struct {
|
||||
pending map[uint16]chan any
|
||||
mu sync.Mutex
|
||||
nextID uint16
|
||||
}
|
||||
|
||||
func newFileTransferTracker() *fileTransferTracker {
|
||||
return &fileTransferTracker{
|
||||
pending: make(map[uint16]chan any),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *fileTransferTracker) register() (uint16, <-chan any) {
|
||||
t.mu.Lock()
|
||||
t.nextID++
|
||||
if t.nextID == 0 {
|
||||
t.nextID++
|
||||
}
|
||||
cftid := t.nextID
|
||||
ch := make(chan any, 1)
|
||||
t.pending[cftid] = ch
|
||||
t.mu.Unlock()
|
||||
|
||||
return cftid, ch
|
||||
}
|
||||
|
||||
func (t *fileTransferTracker) unregister(cftid uint16) {
|
||||
t.mu.Lock()
|
||||
delete(t.pending, cftid)
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *fileTransferTracker) notify(cftid uint16, v any) {
|
||||
t.mu.Lock()
|
||||
if ch, ok := t.pending[cftid]; ok {
|
||||
ch <- v
|
||||
}
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *fileTransferTracker) reset() {
|
||||
t.mu.Lock()
|
||||
t.pending = make(map[uint16]chan any)
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// FileTransferInitUpload sends ftinitupload to the server and waits for the
|
||||
// notifystartupload response containing the TCP port and transfer key.
|
||||
func (c *Client) FileTransferInitUpload(
|
||||
channelID uint64, path string, password string, size uint64, overwrite bool,
|
||||
) (*FileUploadInfo, error) {
|
||||
cftid, ch := c.ftTrack.register()
|
||||
defer c.ftTrack.unregister(cftid)
|
||||
|
||||
targetPath := path
|
||||
if !strings.HasPrefix(targetPath, "/") {
|
||||
targetPath = "/" + targetPath
|
||||
}
|
||||
|
||||
overwriteVal := "0"
|
||||
if overwrite {
|
||||
overwriteVal = "1"
|
||||
}
|
||||
|
||||
cmd := commands.BuildCommand("ftinitupload", map[string]string{
|
||||
"cid": strconv.FormatUint(channelID, 10),
|
||||
"name": targetPath,
|
||||
"cpw": password,
|
||||
"size": strconv.FormatUint(size, 10),
|
||||
"clientftfid": strconv.Itoa(int(cftid)),
|
||||
"overwrite": overwriteVal,
|
||||
"resume": "0",
|
||||
})
|
||||
|
||||
err := c.ExecCommand(cmd, 10*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
select {
|
||||
case res := <-ch:
|
||||
switch v := res.(type) {
|
||||
case FileUploadInfo:
|
||||
return &v, nil
|
||||
case FileTransferStatusInfo:
|
||||
return nil, fmt.Errorf("%w: %s (status=%d)", errFileTransferFailed, v.Message, v.Status)
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %T", errUnexpectedRespType, v)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
return nil, errFileTransferTimedOut
|
||||
}
|
||||
}
|
||||
|
||||
// FileTransferInitDownload sends ftinitdownload to the server and waits for the
|
||||
// notifystartdownload response containing the TCP port and transfer key.
|
||||
func (c *Client) FileTransferInitDownload(channelID uint64, path string, password string) (*FileDownloadInfo, error) {
|
||||
cftid, ch := c.ftTrack.register()
|
||||
defer c.ftTrack.unregister(cftid)
|
||||
|
||||
targetPath := path
|
||||
if !strings.HasPrefix(targetPath, "/") {
|
||||
targetPath = "/" + targetPath
|
||||
}
|
||||
|
||||
cmd := commands.BuildCommand("ftinitdownload", map[string]string{
|
||||
"cid": strconv.FormatUint(channelID, 10),
|
||||
"name": targetPath,
|
||||
"cpw": password,
|
||||
"clientftfid": strconv.Itoa(int(cftid)),
|
||||
"seekpos": "0",
|
||||
})
|
||||
|
||||
err := c.ExecCommand(cmd, 10*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
select {
|
||||
case res := <-ch:
|
||||
switch v := res.(type) {
|
||||
case FileDownloadInfo:
|
||||
return &v, nil
|
||||
case FileTransferStatusInfo:
|
||||
return nil, fmt.Errorf("%w: %s (status=%d)", errFileTransferFailed, v.Message, v.Status)
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %T", errUnexpectedRespType, v)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
return nil, errFileTransferTimedOut
|
||||
}
|
||||
}
|
||||
|
||||
// FileTransferDeleteFile sends ftdeletefile to delete files on the server.
|
||||
func (c *Client) FileTransferDeleteFile(channelID uint64, paths []string) error {
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
pathStr := strings.Join(paths, "|")
|
||||
cmd := commands.BuildCommand("ftdeletefile", map[string]string{
|
||||
"cid": strconv.FormatUint(channelID, 10),
|
||||
"cpw": "",
|
||||
"name": pathStr,
|
||||
})
|
||||
|
||||
return c.ExecCommand(cmd, 10*time.Second)
|
||||
}
|
||||
|
||||
// DialFileTransfer opens TCP to the TeamSpeak file-transfer port
|
||||
// and performs the ftkey handshake. The caller is responsible for closing the
|
||||
// returned connection.
|
||||
func DialFileTransfer(host string, port uint16, key string) (net.Conn, error) {
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(int(port)))
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||
conn, err := dialer.DialContext(context.Background(), "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to file transfer server %s: %w", addr, err)
|
||||
}
|
||||
|
||||
_, err = conn.Write([]byte(key))
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
|
||||
return nil, fmt.Errorf("failed to send transfer key: %w", err)
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// UploadFileData transfers data to the server using credentials from FileTransferInitUpload.
|
||||
func UploadFileData(host string, info *FileUploadInfo, data io.Reader) error {
|
||||
conn, err := DialFileTransfer(host, info.Port, info.FileTransferKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
_, err = io.Copy(conn, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upload file data: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadFileData receives data from the server using credentials from FileTransferInitDownload.
|
||||
func DownloadFileData(host string, info *FileDownloadInfo, dest io.Writer) error {
|
||||
conn, err := DialFileTransfer(host, info.Port, info.FileTransferKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
_, err = io.Copy(dest, conn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download file data: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package teamspeak
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFileTransferTracker_Register_ReturnsUniqueIDs(t *testing.T) {
|
||||
tr := newFileTransferTracker()
|
||||
|
||||
id1, _ := tr.register()
|
||||
id2, _ := tr.register()
|
||||
|
||||
if id1 == 0 {
|
||||
t.Error("expected non-zero ID")
|
||||
}
|
||||
if id2 <= id1 {
|
||||
t.Errorf("expected id2 > id1, got id1=%d id2=%d", id1, id2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileTransferTracker_Notify_DeliversValue(t *testing.T) {
|
||||
tr := newFileTransferTracker()
|
||||
id, ch := tr.register()
|
||||
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
tr.notify(id, FileUploadInfo{Port: 30033, FileTransferKey: "abc"})
|
||||
}()
|
||||
|
||||
select {
|
||||
case val := <-ch:
|
||||
info, ok := val.(FileUploadInfo)
|
||||
if !ok {
|
||||
t.Fatalf("expected FileUploadInfo, got %T", val)
|
||||
}
|
||||
if info.Port != 30033 || info.FileTransferKey != "abc" {
|
||||
t.Errorf("unexpected info: %+v", info)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("notify did not deliver value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileTransferTracker_Notify_UnregisteredID_NoOp(t *testing.T) {
|
||||
tr := newFileTransferTracker()
|
||||
// Notifying a non-existent ID should not block or panic.
|
||||
tr.notify(999, FileUploadInfo{})
|
||||
}
|
||||
|
||||
func TestFileTransferTracker_Unregister_PreventsDelivery(t *testing.T) {
|
||||
tr := newFileTransferTracker()
|
||||
id, ch := tr.register()
|
||||
tr.unregister(id)
|
||||
|
||||
tr.notify(id, FileUploadInfo{Port: 1})
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
t.Error("unregistered channel should not receive")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileTransferTracker_Reset_ClearsPending(t *testing.T) {
|
||||
tr := newFileTransferTracker()
|
||||
_, _ = tr.register()
|
||||
_, _ = tr.register()
|
||||
|
||||
tr.reset()
|
||||
|
||||
// After reset, notify is a no-op.
|
||||
tr.notify(1, FileUploadInfo{})
|
||||
tr.notify(2, FileUploadInfo{})
|
||||
}
|
||||
|
||||
func TestFileTransferTracker_DownloadInfo_Delivered(t *testing.T) {
|
||||
tr := newFileTransferTracker()
|
||||
id, ch := tr.register()
|
||||
|
||||
info := FileDownloadInfo{Port: 30034, FileTransferKey: "xyz", Size: 1024}
|
||||
go func() { tr.notify(id, info) }()
|
||||
|
||||
select {
|
||||
case val := <-ch:
|
||||
got, ok := val.(FileDownloadInfo)
|
||||
if !ok {
|
||||
t.Fatalf("expected FileDownloadInfo, got %T", val)
|
||||
}
|
||||
if got.Size != 1024 {
|
||||
t.Errorf("expected size 1024, got %d", got.Size)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("notify did not deliver download info")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileTransferTracker_StatusInfo_Delivered(t *testing.T) {
|
||||
tr := newFileTransferTracker()
|
||||
id, ch := tr.register()
|
||||
|
||||
status := FileTransferStatusInfo{Status: 2, Message: "error"}
|
||||
go func() { tr.notify(id, status) }()
|
||||
|
||||
select {
|
||||
case val := <-ch:
|
||||
got, ok := val.(FileTransferStatusInfo)
|
||||
if !ok {
|
||||
t.Fatalf("expected FileTransferStatusInfo, got %T", val)
|
||||
}
|
||||
if got.Message != "error" {
|
||||
t.Errorf("expected 'error', got %q", got.Message)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("notify did not deliver status")
|
||||
}
|
||||
}
|
||||
|
||||
// FileTransferDeleteFile — pure command building (no network)
|
||||
|
||||
func TestFileTransferDeleteFile_EmptyPaths_ReturnsNil(t *testing.T) {
|
||||
c := newTestClient(t)
|
||||
err := c.FileTransferDeleteFile(1, nil)
|
||||
if err != nil {
|
||||
t.Errorf("expected nil error for empty paths, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package transport
|
||||
|
||||
// maxUint32Val is ^uint32(0) stored in a variable so that int(maxUint32Val)
|
||||
// is a runtime conversion (avoids "constant overflows int" on 32-bit).
|
||||
var maxUint32Val = ^uint32(0)
|
||||
|
||||
type GenerationWindow struct {
|
||||
mappedBaseOffset int
|
||||
generation uint32
|
||||
mod int
|
||||
receiveWindow int
|
||||
}
|
||||
|
||||
func NewGenerationWindow(mod int, windowSize int) *GenerationWindow {
|
||||
return &GenerationWindow{
|
||||
mod: mod,
|
||||
receiveWindow: windowSize,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *GenerationWindow) Advance(amount int) {
|
||||
if amount <= 0 {
|
||||
return
|
||||
}
|
||||
newBaseOffset := g.mappedBaseOffset + amount
|
||||
genStep := newBaseOffset / g.mod
|
||||
if genStep > 0 {
|
||||
// Cap genStep so the uint32 cast below is safe.
|
||||
// On 64-bit platforms genStep could exceed uint32 range; use uint64 comparison.
|
||||
// On 32-bit platforms genStep ≤ MaxInt32 < MaxUint32, so this is always false.
|
||||
if uint64(genStep) > uint64(maxUint32Val) {
|
||||
genStep = int(maxUint32Val)
|
||||
}
|
||||
g.generation += uint32(genStep)
|
||||
}
|
||||
g.mappedBaseOffset = newBaseOffset % g.mod
|
||||
}
|
||||
|
||||
func (g *GenerationWindow) AdvanceToExcluded(mappedValue int) {
|
||||
moveDist := mappedValue - g.mappedBaseOffset
|
||||
if moveDist < 0 {
|
||||
moveDist += g.mod
|
||||
}
|
||||
g.Advance(moveDist + 1)
|
||||
}
|
||||
|
||||
// SyncTo advances the window baseline toward mappedValue (handles wrap and resync).
|
||||
func (g *GenerationWindow) SyncTo(mappedValue int) {
|
||||
moveDist := mappedValue - g.mappedBaseOffset
|
||||
if moveDist < 0 {
|
||||
moveDist += g.mod
|
||||
}
|
||||
g.Advance(moveDist)
|
||||
}
|
||||
|
||||
func (g *GenerationWindow) IsInWindow(mappedValue int) bool {
|
||||
maxOffset := g.mappedBaseOffset + g.receiveWindow
|
||||
if maxOffset < g.mod {
|
||||
return mappedValue >= g.mappedBaseOffset && mappedValue < maxOffset
|
||||
}
|
||||
|
||||
return mappedValue >= g.mappedBaseOffset || mappedValue < maxOffset-g.mod
|
||||
}
|
||||
|
||||
// MappedToIndex returns the offset from the window base; negative means stale, >= receiveWindow too far ahead.
|
||||
func (g *GenerationWindow) MappedToIndex(mappedValue int) int {
|
||||
if g.IsNextGen(mappedValue) {
|
||||
return (mappedValue + g.mod) - g.mappedBaseOffset
|
||||
}
|
||||
|
||||
return mappedValue - g.mappedBaseOffset
|
||||
}
|
||||
|
||||
// IsOldPacket reports whether mappedValue is before the receive window.
|
||||
func (g *GenerationWindow) IsOldPacket(mappedValue int) bool {
|
||||
index := g.MappedToIndex(mappedValue)
|
||||
|
||||
return index < 0
|
||||
}
|
||||
|
||||
// IsFuturePacket reports whether mappedValue lies beyond the window.
|
||||
func (g *GenerationWindow) IsFuturePacket(mappedValue int) bool {
|
||||
index := g.MappedToIndex(mappedValue)
|
||||
|
||||
return index >= g.receiveWindow
|
||||
}
|
||||
|
||||
func (g *GenerationWindow) IsNextGen(mappedValue int) bool {
|
||||
return g.mappedBaseOffset > (g.mod-g.receiveWindow) &&
|
||||
mappedValue < (g.mappedBaseOffset+g.receiveWindow)-g.mod
|
||||
}
|
||||
|
||||
func (g *GenerationWindow) GetGeneration(mappedValue int) uint32 {
|
||||
if g.IsNextGen(mappedValue) {
|
||||
return g.generation + 1
|
||||
}
|
||||
|
||||
return g.generation
|
||||
}
|
||||
|
||||
func (g *GenerationWindow) Reset() {
|
||||
g.mappedBaseOffset = 0
|
||||
g.generation = 0
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package transport_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/transport"
|
||||
)
|
||||
|
||||
func TestGenerationWindowNew(t *testing.T) {
|
||||
gw := transport.NewGenerationWindow(65536, 1024)
|
||||
if gw == nil {
|
||||
t.Fatal("expected non-nil GenerationWindow")
|
||||
}
|
||||
if gw.GetGeneration(0) != 0 {
|
||||
t.Error("initial generation should be 0")
|
||||
}
|
||||
if !gw.IsInWindow(0) {
|
||||
t.Error("0 should be in window after creation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerationWindowReset(t *testing.T) {
|
||||
gw := transport.NewGenerationWindow(65536, 1024)
|
||||
gw.Advance(500)
|
||||
gw.Reset()
|
||||
if gw.GetGeneration(0) != 0 {
|
||||
t.Error("generation should be 0 after Reset")
|
||||
}
|
||||
// After Reset, base=0 so window is [0..1023]
|
||||
if !gw.IsInWindow(0) {
|
||||
t.Error("0 should be in window after Reset")
|
||||
}
|
||||
if !gw.IsInWindow(1023) {
|
||||
t.Error("1023 should be in window after Reset")
|
||||
}
|
||||
if gw.IsInWindow(1024) {
|
||||
t.Error("1024 should NOT be in window after Reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerationWindowAdvanceBasic(t *testing.T) {
|
||||
gw := transport.NewGenerationWindow(65536, 1024)
|
||||
gw.Advance(100)
|
||||
if !gw.IsInWindow(100) {
|
||||
t.Error("100 should be in window after Advance(100)")
|
||||
}
|
||||
if gw.IsInWindow(99) {
|
||||
t.Error("99 should not be in window after Advance(100)")
|
||||
}
|
||||
if !gw.IsInWindow(1123) {
|
||||
t.Error("1123 (100+1023) should be in window")
|
||||
}
|
||||
if gw.IsInWindow(1124) {
|
||||
t.Error("1124 should not be in window")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerationWindowAdvanceZeroAndNegative(t *testing.T) {
|
||||
gw := transport.NewGenerationWindow(65536, 1024)
|
||||
gw.Advance(0)
|
||||
gw.Advance(-1)
|
||||
if !gw.IsInWindow(0) {
|
||||
t.Error("0 should still be in window after zero/negative advance")
|
||||
}
|
||||
if gw.GetGeneration(0) != 0 {
|
||||
t.Error("generation should remain 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerationWindowFullCycleIncrementsGeneration(t *testing.T) {
|
||||
gw := transport.NewGenerationWindow(65536, 1024)
|
||||
gw.Advance(65536)
|
||||
if gw.GetGeneration(0) != 1 {
|
||||
t.Errorf("after one full cycle, generation should be 1, got %d", gw.GetGeneration(0))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerationWindowIsOldPacket(t *testing.T) {
|
||||
gw := transport.NewGenerationWindow(65536, 1024)
|
||||
gw.Advance(100)
|
||||
if !gw.IsOldPacket(0) {
|
||||
t.Error("0 should be old after Advance(100)")
|
||||
}
|
||||
if !gw.IsOldPacket(99) {
|
||||
t.Error("99 should be old after Advance(100)")
|
||||
}
|
||||
if gw.IsOldPacket(100) {
|
||||
t.Error("100 should not be old (it's the window start)")
|
||||
}
|
||||
if gw.IsOldPacket(500) {
|
||||
t.Error("500 should not be old (it's in window)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerationWindowIsFuturePacket(t *testing.T) {
|
||||
gw := transport.NewGenerationWindow(65536, 1024)
|
||||
if !gw.IsFuturePacket(1024) {
|
||||
t.Error("1024 should be a future packet (base=0, window=1024)")
|
||||
}
|
||||
if gw.IsFuturePacket(1023) {
|
||||
t.Error("1023 should not be a future packet (last in window)")
|
||||
}
|
||||
if gw.IsFuturePacket(500) {
|
||||
t.Error("500 should not be a future packet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerationWindowAdvanceToExcluded(t *testing.T) {
|
||||
gw := transport.NewGenerationWindow(65536, 1024)
|
||||
// AdvanceToExcluded(10): moveDist=10, Advance(11), base becomes 11
|
||||
gw.AdvanceToExcluded(10)
|
||||
if !gw.IsOldPacket(10) {
|
||||
t.Error("10 should be old after AdvanceToExcluded(10)")
|
||||
}
|
||||
if gw.IsOldPacket(11) {
|
||||
t.Error("11 should be in window (new base)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerationWindowSyncTo(t *testing.T) {
|
||||
gw := transport.NewGenerationWindow(65536, 1024)
|
||||
gw.SyncTo(500)
|
||||
if gw.IsOldPacket(500) {
|
||||
t.Error("500 should not be old after SyncTo(500)")
|
||||
}
|
||||
if !gw.IsOldPacket(499) {
|
||||
t.Error("499 should be old after SyncTo(500)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerationWindowWrapAroundInWindow(t *testing.T) {
|
||||
// mod=16, window=4: after Advance(14), window spans 14,15,0,1
|
||||
gw := transport.NewGenerationWindow(16, 4)
|
||||
gw.Advance(14)
|
||||
if !gw.IsInWindow(14) {
|
||||
t.Error("14 should be in window")
|
||||
}
|
||||
if !gw.IsInWindow(15) {
|
||||
t.Error("15 should be in window")
|
||||
}
|
||||
if !gw.IsInWindow(0) {
|
||||
t.Error("0 (wrapped) should be in window")
|
||||
}
|
||||
if !gw.IsInWindow(1) {
|
||||
t.Error("1 (wrapped) should be in window")
|
||||
}
|
||||
if gw.IsInWindow(2) {
|
||||
t.Error("2 should NOT be in window")
|
||||
}
|
||||
if gw.IsInWindow(13) {
|
||||
t.Error("13 should NOT be in window")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerationWindowIsNextGen(t *testing.T) {
|
||||
// mod=16, window=4: IsNextGen requires base > 16-4=12
|
||||
gw := transport.NewGenerationWindow(16, 4)
|
||||
gw.Advance(13) // base=13
|
||||
// 0 < (13+4)-16=1 and base=13>12 → IsNextGen(0)=true
|
||||
if !gw.IsNextGen(0) {
|
||||
t.Error("0 should be next-gen when base=13, mod=16, window=4")
|
||||
}
|
||||
// 1 is NOT < 1 → IsNextGen(1)=false
|
||||
if gw.IsNextGen(1) {
|
||||
t.Error("1 should NOT be next-gen when base=13")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerationWindowNextGenIncreasesGeneration(t *testing.T) {
|
||||
gw := transport.NewGenerationWindow(16, 4)
|
||||
gw.Advance(13)
|
||||
if gw.GetGeneration(13) != 0 {
|
||||
t.Errorf("generation of 13 should be 0, got %d", gw.GetGeneration(13))
|
||||
}
|
||||
if gw.GetGeneration(0) != 1 {
|
||||
t.Errorf("generation of 0 (next-gen) should be 1, got %d", gw.GetGeneration(0))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerationWindowMultipleAdvanceCycles(t *testing.T) {
|
||||
gw := transport.NewGenerationWindow(65536, 1024)
|
||||
gw.Advance(65536)
|
||||
gw.Advance(65536)
|
||||
gw.Advance(65536)
|
||||
if gw.GetGeneration(0) != 3 {
|
||||
t.Errorf("after 3 full cycles, generation should be 3, got %d", gw.GetGeneration(0))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerationWindowMappedToIndex(t *testing.T) {
|
||||
gw := transport.NewGenerationWindow(65536, 1024)
|
||||
gw.Advance(100) // base=100
|
||||
idx := gw.MappedToIndex(150)
|
||||
if idx != 50 {
|
||||
t.Errorf("MappedToIndex(150)=%d, want 50 (base=100)", idx)
|
||||
}
|
||||
idx = gw.MappedToIndex(99)
|
||||
if idx != -1 {
|
||||
t.Errorf("MappedToIndex(99)=%d, want -1 (old packet)", idx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,945 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/crypto"
|
||||
"github.com/honeybbq/teamspeak-go/handshake"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxOutPacketSize = 500
|
||||
ReceivePacketWindowSize = 1024
|
||||
PingInterval = 5 * time.Second
|
||||
PacketTimeout = 60 * time.Second
|
||||
MaxRetryInterval = time.Second
|
||||
udpReadBufferSize = 4096
|
||||
voicePayloadBufferSize = 1027
|
||||
smallPacketBufferSize = 2
|
||||
packetProcessQueueSize = 2048
|
||||
resendBaseInterval = 500 * time.Millisecond
|
||||
resendLoopInterval = 100 * time.Millisecond
|
||||
headerSize = 5
|
||||
tagSize = 8
|
||||
voiceHeaderSize = 3
|
||||
ackDataSize = 2
|
||||
)
|
||||
|
||||
var bufPool = sync.Pool{
|
||||
New: func() any {
|
||||
buf := make([]byte, udpReadBufferSize)
|
||||
|
||||
return &buf
|
||||
},
|
||||
}
|
||||
|
||||
// voicePayloadPool holds buffers for the 3-byte voice header plus Opus frame.
|
||||
var voicePayloadPool = sync.Pool{
|
||||
New: func() any {
|
||||
buf := make([]byte, voicePayloadBufferSize)
|
||||
|
||||
return &buf
|
||||
},
|
||||
}
|
||||
|
||||
// smallBufPool holds 2-byte buffers for ACK/Pong payloads.
|
||||
var smallBufPool = sync.Pool{
|
||||
New: func() any {
|
||||
buf := make([]byte, smallPacketBufferSize)
|
||||
|
||||
return &buf
|
||||
},
|
||||
}
|
||||
|
||||
type pooledBuffer struct {
|
||||
buf []byte
|
||||
n int
|
||||
}
|
||||
|
||||
type PacketHandler struct {
|
||||
lastMessageReceived time.Time
|
||||
conn io.ReadWriteCloser
|
||||
commandQueue map[uint16]*Packet
|
||||
commandLowQueue map[uint16]*Packet
|
||||
stopCh chan struct{}
|
||||
recvWindowCommand *GenerationWindow
|
||||
recvWindowCommandLow *GenerationWindow
|
||||
sendWindowCommand *GenerationWindow
|
||||
sendWindowCommandLow *GenerationWindow
|
||||
ackManager map[uint32]*resendPacket
|
||||
initPacketCheck *resendPacket
|
||||
packetProcessCh chan *pooledBuffer
|
||||
OnClosed func(err error)
|
||||
logger *slog.Logger
|
||||
OnAck func(id uint16)
|
||||
OnPacket func(p *Packet)
|
||||
TsCrypt *crypto.Crypt
|
||||
generationCounter [9]uint32
|
||||
mu sync.Mutex
|
||||
closed atomic.Bool
|
||||
packetCounter [9]uint16
|
||||
clientID uint16
|
||||
nextCommandLowID uint16
|
||||
nextCommandID uint16
|
||||
}
|
||||
|
||||
type resendPacket struct {
|
||||
packet *Packet
|
||||
firstSend time.Time
|
||||
lastSend time.Time
|
||||
retryCount int
|
||||
nextInterval time.Duration
|
||||
}
|
||||
|
||||
type decryptPacketResult struct {
|
||||
plaintext []byte
|
||||
dummyUsed bool
|
||||
}
|
||||
|
||||
func NewPacketHandler(tsCrypt *crypto.Crypt, logger *slog.Logger) *PacketHandler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return &PacketHandler{
|
||||
TsCrypt: tsCrypt,
|
||||
logger: logger,
|
||||
ackManager: make(map[uint32]*resendPacket),
|
||||
packetProcessCh: make(chan *pooledBuffer, packetProcessQueueSize),
|
||||
stopCh: make(chan struct{}),
|
||||
recvWindowCommand: NewGenerationWindow(1<<16, ReceivePacketWindowSize),
|
||||
recvWindowCommandLow: NewGenerationWindow(1<<16, ReceivePacketWindowSize),
|
||||
sendWindowCommand: NewGenerationWindow(1<<16, ReceivePacketWindowSize),
|
||||
sendWindowCommandLow: NewGenerationWindow(1<<16, ReceivePacketWindowSize),
|
||||
commandQueue: make(map[uint16]*Packet),
|
||||
commandLowQueue: make(map[uint16]*Packet),
|
||||
lastMessageReceived: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PacketHandler) SetClientID(id uint16) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.clientID = id
|
||||
}
|
||||
|
||||
// Connect resolves addr as a UDP address, dials it, and calls Start.
|
||||
func (h *PacketHandler) Connect(addr string) error {
|
||||
udpAddr, err := net.ResolveUDPAddr("udp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn, err := net.DialUDP("udp", nil, udpAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return h.Start(conn)
|
||||
}
|
||||
|
||||
// Start attaches conn to the handler, launches background goroutines, and sends
|
||||
// the initial Init1 handshake packet. conn must implement io.ReadWriteCloser
|
||||
// with datagram-style semantics (each Write produces one discrete message).
|
||||
func (h *PacketHandler) Start(conn io.ReadWriteCloser) error {
|
||||
h.conn = conn
|
||||
|
||||
go h.receiveLoop()
|
||||
go h.processLoop()
|
||||
go h.resendLoop()
|
||||
go h.pingLoop()
|
||||
|
||||
h.packetCounter[PacketTypeCommand]++
|
||||
h.packetCounter[PacketTypeInit1] = 101
|
||||
|
||||
init1Data := handshake.ProcessInit1(h.TsCrypt, nil)
|
||||
|
||||
return h.SendPacket(byte(PacketTypeInit1), init1Data, 0)
|
||||
}
|
||||
|
||||
func (h *PacketHandler) SendPacket(pType byte, data []byte, flags byte) error {
|
||||
dummy := !h.TsCrypt.CryptoInitComplete
|
||||
|
||||
// Fragment non-voice command payloads larger than one UDP frame (487 B body).
|
||||
if len(data) > 487 && pType != 0 && pType != 1 {
|
||||
return h.sendSplitPacket(pType, data, flags, dummy)
|
||||
}
|
||||
|
||||
return h.sendPacket(pType, data, flags, dummy)
|
||||
}
|
||||
|
||||
func (h *PacketHandler) sendSplitPacket(pType byte, data []byte, flags byte, dummy bool) error {
|
||||
maxSize := 487 // MaxOutPacketSize(500) - Header(5) - Tag(8)
|
||||
pos := 0
|
||||
first := true
|
||||
|
||||
for pos < len(data) {
|
||||
blockSize := min(len(data)-pos, maxSize)
|
||||
|
||||
last := (pos + blockSize) == len(data)
|
||||
|
||||
pFlags := flags
|
||||
// TeamSpeak sets Fragmented on the first and last chunk only.
|
||||
if first != last {
|
||||
pFlags |= byte(PacketFlagFragmented)
|
||||
}
|
||||
|
||||
err := h.sendPacket(pType, data[pos:pos+blockSize], pFlags, dummy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pos += blockSize
|
||||
first = false
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *PacketHandler) sendPacket(pType byte, data []byte, flags byte, dummy bool) error {
|
||||
flags = applyProtocolFlags(pType, flags)
|
||||
pID, pGen := h.nextPacketIdentity(pType)
|
||||
|
||||
p := &Packet{
|
||||
TypeFlagged: pType | flags,
|
||||
ID: pID,
|
||||
GenerationID: pGen,
|
||||
Data: data,
|
||||
ClientID: h.clientID,
|
||||
}
|
||||
|
||||
unencrypted := (flags&byte(PacketFlagUnencrypted) != 0)
|
||||
header := p.BuildC2SHeader()
|
||||
ciphertext, tag, err := h.TsCrypt.Encrypt(pType, p.ID, p.GenerationID, header, p.Data, dummy, unencrypted)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
final := getPooledBytes(&bufPool, tagSize+headerSize+len(ciphertext))
|
||||
defer putPooledBytes(&bufPool, final)
|
||||
|
||||
copy(final[0:8], tag)
|
||||
copy(final[8:13], header)
|
||||
copy(final[13:], ciphertext)
|
||||
|
||||
_, err = h.conn.Write(final[:tagSize+headerSize+len(ciphertext)])
|
||||
|
||||
rp := &resendPacket{
|
||||
packet: p,
|
||||
firstSend: time.Now(),
|
||||
lastSend: time.Now(),
|
||||
nextInterval: resendBaseInterval,
|
||||
}
|
||||
h.trackResendPacket(pType, p, rp)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *PacketHandler) sendPong(pID uint16, dummy bool) error {
|
||||
pongData := getPooledBytes(&smallBufPool, smallPacketBufferSize)
|
||||
binary.BigEndian.PutUint16(pongData, pID)
|
||||
err := h.sendPacket(byte(PacketTypePong), pongData, byte(PacketFlagUnencrypted), dummy)
|
||||
putPooledBytes(&smallBufPool, pongData)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *PacketHandler) receiveLoop() {
|
||||
var finalErr error
|
||||
defer func() {
|
||||
if h.OnClosed != nil {
|
||||
h.OnClosed(finalErr)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
buf := getPooledBytes(&bufPool, udpReadBufferSize)
|
||||
n, err := h.conn.Read(buf)
|
||||
if err != nil {
|
||||
putPooledBytes(&bufPool, buf)
|
||||
select {
|
||||
case <-h.stopCh:
|
||||
return
|
||||
default:
|
||||
h.logger.Error("udp read failed", slog.Any("error", err))
|
||||
finalErr = err
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case h.packetProcessCh <- &pooledBuffer{buf: buf, n: n}:
|
||||
default:
|
||||
h.logger.Warn("packet process channel full, dropping packet")
|
||||
putPooledBytes(&bufPool, buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PacketHandler) processLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-h.stopCh:
|
||||
return
|
||||
case pb := <-h.packetProcessCh:
|
||||
h.handleRawPacket(pb.buf[:pb.n])
|
||||
putPooledBytes(&bufPool, pb.buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PacketHandler) handleRawPacket(raw []byte) {
|
||||
if len(raw) < 11 {
|
||||
return
|
||||
}
|
||||
|
||||
tag := raw[0:8]
|
||||
header := raw[8:11]
|
||||
ciphertext := raw[11:]
|
||||
p := parseServerPacket(header)
|
||||
p.ReceivedAt = time.Now()
|
||||
h.markMessageReceived()
|
||||
|
||||
decrypted, ok := h.decryptPacketData(p, header, ciphertext, tag)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p.Data = decrypted.plaintext
|
||||
|
||||
if p.Type() == PacketTypePing {
|
||||
_ = h.sendPong(p.ID, decrypted.dummyUsed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !h.handleCommandWindowAndAck(p, decrypted.dummyUsed) {
|
||||
return
|
||||
}
|
||||
|
||||
h.handlePacketQueue(p)
|
||||
h.updatePostReceiveState(p)
|
||||
}
|
||||
|
||||
func (h *PacketHandler) getWinForType(pType PacketType) *GenerationWindow {
|
||||
switch pType {
|
||||
case PacketTypeCommand:
|
||||
return h.recvWindowCommand
|
||||
case PacketTypeCommandLow:
|
||||
return h.recvWindowCommandLow
|
||||
case PacketTypeVoice, PacketTypeVoiceWhisper, PacketTypePing, PacketTypePong,
|
||||
PacketTypeAck, PacketTypeAckLow, PacketTypeInit1:
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PacketHandler) handlePacketQueue(p *Packet) {
|
||||
pType := p.Type()
|
||||
if pType != PacketTypeCommand && pType != PacketTypeCommandLow {
|
||||
if h.OnPacket != nil {
|
||||
h.OnPacket(p)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
var queue map[uint16]*Packet
|
||||
var nextID *uint16
|
||||
if pType == PacketTypeCommand {
|
||||
queue = h.commandQueue
|
||||
nextID = &h.nextCommandID
|
||||
} else {
|
||||
queue = h.commandLowQueue
|
||||
nextID = &h.nextCommandLowID
|
||||
}
|
||||
|
||||
queue[p.ID] = p
|
||||
|
||||
// If the expected ID never arrives, skip it once a newer fragment has stalled long enough.
|
||||
h.fastForwardMissingPackets(pType, queue, nextID)
|
||||
|
||||
for {
|
||||
packet, ok := queue[*nextID]
|
||||
if !ok {
|
||||
h.logQueueBacklog(pType, queue, *nextID)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
var win *GenerationWindow
|
||||
if pType == PacketTypeCommand {
|
||||
win = h.recvWindowCommand
|
||||
} else {
|
||||
win = h.recvWindowCommandLow
|
||||
}
|
||||
|
||||
reassembled, complete := h.tryReassemble(packet, queue, nextID, win)
|
||||
if !complete {
|
||||
break
|
||||
}
|
||||
|
||||
h.tryDecompressPacket(reassembled)
|
||||
|
||||
if h.OnPacket != nil {
|
||||
h.mu.Unlock()
|
||||
h.OnPacket(reassembled)
|
||||
h.mu.Lock()
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func parseServerPacket(header []byte) *Packet {
|
||||
p := &Packet{}
|
||||
p.ParseS2CHeader(header)
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func (h *PacketHandler) markMessageReceived() {
|
||||
h.mu.Lock()
|
||||
h.lastMessageReceived = time.Now()
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *PacketHandler) resolvePacketGeneration(p *Packet) uint32 {
|
||||
var gen uint32
|
||||
h.mu.Lock()
|
||||
switch p.Type() {
|
||||
case PacketTypeCommand:
|
||||
gen = h.recvWindowCommand.GetGeneration(int(p.ID))
|
||||
case PacketTypeCommandLow:
|
||||
gen = h.recvWindowCommandLow.GetGeneration(int(p.ID))
|
||||
case PacketTypeAck:
|
||||
gen = h.sendWindowCommand.GetGeneration(int(p.ID))
|
||||
case PacketTypeAckLow:
|
||||
gen = h.sendWindowCommandLow.GetGeneration(int(p.ID))
|
||||
case PacketTypeVoice, PacketTypeVoiceWhisper, PacketTypePing, PacketTypePong, PacketTypeInit1:
|
||||
// No generation tracking for these packet types.
|
||||
default:
|
||||
// Unknown packet type, keep generation as zero.
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
return gen
|
||||
}
|
||||
|
||||
func (h *PacketHandler) decryptPacketData(
|
||||
p *Packet, header, ciphertext, tag []byte,
|
||||
) (*decryptPacketResult, bool) {
|
||||
unencrypted := (p.Flags() & PacketFlagUnencrypted) != 0
|
||||
dummy := !h.TsCrypt.CryptoInitComplete
|
||||
dummyUsed := dummy
|
||||
gen := h.resolvePacketGeneration(p)
|
||||
|
||||
plaintext, err := h.TsCrypt.Decrypt(byte(p.Type()), p.ID, gen, header, ciphertext, tag, dummy, unencrypted)
|
||||
if err != nil && !dummy && !unencrypted {
|
||||
plaintext, gen, err = h.decryptWithGenerationGuess(p, gen, header, ciphertext, tag)
|
||||
}
|
||||
if err != nil && !dummy {
|
||||
plaintext, dummyUsed, err = h.decryptWithDummyFallback(
|
||||
p, gen, header, ciphertext, tag, unencrypted, plaintext, dummyUsed, err,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return &decryptPacketResult{plaintext: plaintext, dummyUsed: dummyUsed}, true
|
||||
}
|
||||
|
||||
func (h *PacketHandler) decryptWithGenerationGuess(
|
||||
p *Packet, gen uint32, header, ciphertext, tag []byte,
|
||||
) ([]byte, uint32, error) {
|
||||
for _, offset := range []int{-1, 1} {
|
||||
guessGen, ok := shiftGeneration(gen, offset)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
plaintext, err := h.TsCrypt.Decrypt(byte(p.Type()), p.ID, guessGen, header, ciphertext, tag, false, false)
|
||||
if err == nil {
|
||||
h.logger.Debug("generation guess succeeded",
|
||||
slog.Uint64("id", uint64(p.ID)),
|
||||
slog.Int("offset", offset),
|
||||
slog.Uint64("new_gen", uint64(guessGen)))
|
||||
|
||||
return plaintext, guessGen, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, gen, errDecryptFailed
|
||||
}
|
||||
|
||||
var errDecryptFailed = errors.New("decrypt failed")
|
||||
|
||||
func (h *PacketHandler) decryptWithDummyFallback(
|
||||
p *Packet, gen uint32, header, ciphertext, tag []byte, unencrypted bool,
|
||||
plaintext []byte, dummyUsed bool, decryptErr error,
|
||||
) ([]byte, bool, error) {
|
||||
switch p.Type() {
|
||||
case PacketTypeCommand, PacketTypeCommandLow, PacketTypeAck:
|
||||
plaintext, decryptErr = h.TsCrypt.Decrypt(byte(p.Type()), p.ID, gen, header, ciphertext, tag, true, unencrypted)
|
||||
if decryptErr == nil {
|
||||
return plaintext, true, nil
|
||||
}
|
||||
case PacketTypeVoice, PacketTypeVoiceWhisper, PacketTypePing, PacketTypePong, PacketTypeAckLow, PacketTypeInit1:
|
||||
// No dummy fallback path required.
|
||||
default:
|
||||
// Unknown packet type.
|
||||
}
|
||||
h.logger.Debug("packet decryption failed",
|
||||
slog.Uint64("type", uint64(p.Type())),
|
||||
slog.Uint64("id", uint64(p.ID)),
|
||||
slog.Uint64("gen", uint64(gen)),
|
||||
slog.Any("error", decryptErr))
|
||||
|
||||
return plaintext, dummyUsed, decryptErr
|
||||
}
|
||||
|
||||
func (h *PacketHandler) handleCommandWindowAndAck(p *Packet, dummyUsed bool) bool {
|
||||
if p.Type() != PacketTypeCommand && p.Type() != PacketTypeCommandLow {
|
||||
return true
|
||||
}
|
||||
h.mu.Lock()
|
||||
var win *GenerationWindow
|
||||
if p.Type() == PacketTypeCommand {
|
||||
win = h.recvWindowCommand
|
||||
} else {
|
||||
win = h.recvWindowCommandLow
|
||||
}
|
||||
inWindow := win.IsInWindow(int(p.ID))
|
||||
isOld := win.IsOldPacket(int(p.ID))
|
||||
h.mu.Unlock()
|
||||
|
||||
ackType := PacketTypeAck
|
||||
if p.Type() == PacketTypeCommandLow {
|
||||
ackType = PacketTypeAckLow
|
||||
}
|
||||
|
||||
if !inWindow {
|
||||
if isOld {
|
||||
h.logger.Debug("received old packet, sending ack only",
|
||||
slog.Uint64("type", uint64(p.Type())),
|
||||
slog.Uint64("id", uint64(p.ID)))
|
||||
h.sendAckPacket(p.ID, ackType, dummyUsed)
|
||||
} else {
|
||||
h.logger.Warn("packet too far ahead, ignoring",
|
||||
slog.Uint64("type", uint64(p.Type())),
|
||||
slog.Uint64("id", uint64(p.ID)))
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
h.logger.Debug("sending ack for command",
|
||||
slog.Uint64("type", uint64(ackType)),
|
||||
slog.Uint64("id", uint64(p.ID)))
|
||||
h.sendAckPacket(p.ID, ackType, dummyUsed)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *PacketHandler) sendAckPacket(packetID uint16, ackType PacketType, dummyUsed bool) {
|
||||
ackData := getPooledBytes(&smallBufPool, ackDataSize)
|
||||
binary.BigEndian.PutUint16(ackData, packetID)
|
||||
_ = h.sendPacket(byte(ackType), ackData, 0, dummyUsed)
|
||||
putPooledBytes(&smallBufPool, ackData)
|
||||
}
|
||||
|
||||
func (h *PacketHandler) updatePostReceiveState(p *Packet) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if p.Type() == PacketTypeInit1 {
|
||||
h.logger.Debug("received init1 response, cleared init packet check")
|
||||
h.initPacketCheck = nil
|
||||
|
||||
return
|
||||
}
|
||||
if (p.Type() == PacketTypeAck || p.Type() == PacketTypeAckLow) && len(p.Data) >= 2 {
|
||||
ackID := binary.BigEndian.Uint16(p.Data[0:2])
|
||||
targetType := uint32(PacketTypeCommand)
|
||||
if p.Type() == PacketTypeAckLow {
|
||||
targetType = uint32(PacketTypeCommandLow)
|
||||
}
|
||||
h.logger.Debug("received ack from server",
|
||||
slog.Uint64("target_type", uint64(targetType)),
|
||||
slog.Uint64("id", uint64(ackID)))
|
||||
delete(h.ackManager, (targetType<<16)|uint32(ackID))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PacketHandler) fastForwardMissingPackets(
|
||||
pType PacketType, queue map[uint16]*Packet, nextID *uint16,
|
||||
) {
|
||||
for {
|
||||
if _, ok := queue[*nextID]; ok {
|
||||
return
|
||||
}
|
||||
if !hasOldNewerPacket(queue, *nextID) {
|
||||
return
|
||||
}
|
||||
h.logger.Warn("skipping missing packet to unblock queue",
|
||||
slog.Uint64("type", uint64(pType)),
|
||||
slog.Uint64("missing_id", uint64(*nextID)))
|
||||
*nextID++
|
||||
if win := h.getWinForType(pType); win != nil {
|
||||
win.Advance(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasOldNewerPacket(queue map[uint16]*Packet, nextID uint16) bool {
|
||||
for id, pkg := range queue {
|
||||
if (id-nextID) < 32768 && time.Since(pkg.ReceivedAt) > 5*time.Second {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *PacketHandler) logQueueBacklog(pType PacketType, queue map[uint16]*Packet, nextID uint16) {
|
||||
if len(queue) <= 10 {
|
||||
return
|
||||
}
|
||||
h.logger.Debug("packet queue backlog",
|
||||
slog.Uint64("type", uint64(pType)),
|
||||
slog.Uint64("next_id", uint64(nextID)),
|
||||
slog.Int("backlog_size", len(queue)))
|
||||
}
|
||||
|
||||
func (h *PacketHandler) tryDecompressPacket(packet *Packet) {
|
||||
if (packet.Flags() & PacketFlagCompressed) == 0 {
|
||||
return
|
||||
}
|
||||
qlz := NewQlz()
|
||||
decompressed, err := qlz.Decompress(packet.Data)
|
||||
if err != nil {
|
||||
h.logger.Debug("decompression failed",
|
||||
slog.Uint64("id", uint64(packet.ID)),
|
||||
slog.Any("error", err))
|
||||
|
||||
return
|
||||
}
|
||||
h.logger.Debug("decompressed packet successfully",
|
||||
slog.Uint64("id", uint64(packet.ID)),
|
||||
slog.Int("old_len", len(packet.Data)),
|
||||
slog.Int("new_len", len(decompressed)))
|
||||
packet.Data = decompressed
|
||||
packet.TypeFlagged &= ^byte(PacketFlagCompressed)
|
||||
}
|
||||
|
||||
func (h *PacketHandler) tryReassemble(
|
||||
startPacket *Packet, queue map[uint16]*Packet, nextID *uint16, win *GenerationWindow,
|
||||
) (*Packet, bool) {
|
||||
if (startPacket.Flags() & PacketFlagFragmented) == 0 {
|
||||
advanceQueueWindow(queue, nextID, win)
|
||||
|
||||
return startPacket, true
|
||||
}
|
||||
|
||||
fragments, totalSize, ok := collectFragments(queue, *nextID)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
h.logger.Debug("reassembling fragmented packet",
|
||||
slog.Uint64("start_id", uint64(*nextID)),
|
||||
slog.Int("fragments", len(fragments)),
|
||||
slog.Int("total_size", totalSize))
|
||||
|
||||
combined := make([]byte, totalSize)
|
||||
pos := 0
|
||||
for i := range fragments {
|
||||
copy(combined[pos:], fragments[i].Data)
|
||||
pos += len(fragments[i].Data)
|
||||
advanceQueueWindow(queue, nextID, win)
|
||||
}
|
||||
|
||||
startPacket.Data = combined
|
||||
startPacket.TypeFlagged &= ^byte(PacketFlagFragmented)
|
||||
|
||||
return startPacket, true
|
||||
}
|
||||
|
||||
func applyProtocolFlags(pType byte, flags byte) byte {
|
||||
if pType == byte(PacketTypeCommand) || pType == byte(PacketTypeCommandLow) {
|
||||
return flags | byte(PacketFlagNewProtocol)
|
||||
}
|
||||
|
||||
return flags
|
||||
}
|
||||
|
||||
func (h *PacketHandler) nextPacketIdentity(pType byte) (uint16, uint32) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
pID := h.packetCounter[pType]
|
||||
pGen := h.generationCounter[pType]
|
||||
if pType == byte(PacketTypeInit1) {
|
||||
return pID, pGen
|
||||
}
|
||||
|
||||
h.packetCounter[pType]++
|
||||
if h.packetCounter[pType] == 0 {
|
||||
h.generationCounter[pType]++
|
||||
}
|
||||
if pType == byte(PacketTypeCommand) {
|
||||
h.sendWindowCommand.AdvanceToExcluded(int(pID))
|
||||
} else if pType == byte(PacketTypeCommandLow) {
|
||||
h.sendWindowCommandLow.AdvanceToExcluded(int(pID))
|
||||
}
|
||||
|
||||
return pID, pGen
|
||||
}
|
||||
|
||||
func (h *PacketHandler) trackResendPacket(pType byte, p *Packet, rp *resendPacket) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if pType == byte(PacketTypeInit1) {
|
||||
h.initPacketCheck = rp
|
||||
|
||||
return
|
||||
}
|
||||
if pType == byte(PacketTypeCommand) || pType == byte(PacketTypeCommandLow) {
|
||||
key := (uint32(pType) << 16) | uint32(p.ID)
|
||||
h.ackManager[key] = rp
|
||||
}
|
||||
}
|
||||
|
||||
func collectFragments(queue map[uint16]*Packet, startID uint16) ([]*Packet, int, bool) {
|
||||
var fragments []*Packet
|
||||
currID := startID
|
||||
totalSize := 0
|
||||
startSeen := false
|
||||
for {
|
||||
p, ok := queue[currID]
|
||||
if !ok {
|
||||
return nil, 0, false
|
||||
}
|
||||
fragments = append(fragments, p)
|
||||
totalSize += len(p.Data)
|
||||
var complete bool
|
||||
startSeen, complete = updateFragmentState(startSeen, p.Flags())
|
||||
if complete {
|
||||
return fragments, totalSize, true
|
||||
}
|
||||
currID++
|
||||
}
|
||||
}
|
||||
|
||||
func updateFragmentState(startSeen bool, flags PacketFlags) (bool, bool) {
|
||||
if (flags & PacketFlagFragmented) != 0 {
|
||||
if !startSeen {
|
||||
return true, false
|
||||
}
|
||||
|
||||
return true, true
|
||||
}
|
||||
if !startSeen {
|
||||
return true, true
|
||||
}
|
||||
|
||||
return startSeen, false
|
||||
}
|
||||
|
||||
func shiftGeneration(gen uint32, offset int) (uint32, bool) {
|
||||
switch offset {
|
||||
case -1:
|
||||
if gen == 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return gen - 1, true
|
||||
case 1:
|
||||
if gen == ^uint32(0) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return gen + 1, true
|
||||
default:
|
||||
return gen, true
|
||||
}
|
||||
}
|
||||
|
||||
func advanceQueueWindow(queue map[uint16]*Packet, nextID *uint16, win *GenerationWindow) {
|
||||
delete(queue, *nextID)
|
||||
*nextID++
|
||||
if win != nil {
|
||||
win.Advance(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PacketHandler) pingLoop() {
|
||||
ticker := time.NewTicker(PingInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-h.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if h.TsCrypt.CryptoInitComplete {
|
||||
_ = h.SendPacket(byte(PacketTypePing), []byte{}, byte(PacketFlagUnencrypted))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PacketHandler) resendLoop() {
|
||||
ticker := time.NewTicker(resendLoopInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-h.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
h.checkResends()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PacketHandler) ReceivedFinalInitAck() {
|
||||
h.mu.Lock()
|
||||
h.initPacketCheck = nil
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *PacketHandler) checkResends() {
|
||||
h.mu.Lock()
|
||||
now := time.Now()
|
||||
needClose := false
|
||||
|
||||
if now.Sub(h.lastMessageReceived) > PacketTimeout {
|
||||
h.logger.Warn("idle timeout: no packets received", slog.Duration("timeout", PacketTimeout))
|
||||
needClose = true
|
||||
}
|
||||
|
||||
if h.initPacketCheck != nil {
|
||||
h.doResend(h.initPacketCheck, now)
|
||||
}
|
||||
for key, rp := range h.ackManager {
|
||||
if now.Sub(rp.firstSend) > PacketTimeout {
|
||||
delete(h.ackManager, key)
|
||||
needClose = true
|
||||
|
||||
break
|
||||
}
|
||||
h.doResend(rp, now)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if needClose {
|
||||
_ = h.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PacketHandler) doResend(rp *resendPacket, now time.Time) {
|
||||
if now.Sub(rp.lastSend) >= rp.nextInterval {
|
||||
rp.lastSend = now
|
||||
rp.retryCount++
|
||||
rp.nextInterval *= 2
|
||||
if rp.nextInterval > MaxRetryInterval {
|
||||
rp.nextInterval = MaxRetryInterval
|
||||
}
|
||||
|
||||
unencrypted := (rp.packet.Flags()&PacketFlagUnencrypted != 0)
|
||||
dummy := !h.TsCrypt.CryptoInitComplete
|
||||
header := rp.packet.BuildC2SHeader()
|
||||
h.logger.Debug("resending packet",
|
||||
slog.Uint64("type", uint64(rp.packet.Type())),
|
||||
slog.Uint64("id", uint64(rp.packet.ID)),
|
||||
slog.Int("retry_count", rp.retryCount),
|
||||
slog.Duration("next_interval", rp.nextInterval))
|
||||
ciphertext, tag, _ := h.TsCrypt.Encrypt(
|
||||
byte(rp.packet.Type()), rp.packet.ID, rp.packet.GenerationID, header, rp.packet.Data, dummy, unencrypted,
|
||||
)
|
||||
final := make([]byte, tagSize+headerSize+len(ciphertext))
|
||||
copy(final[0:8], tag)
|
||||
copy(final[8:13], header)
|
||||
copy(final[13:], ciphertext)
|
||||
_, err := h.conn.Write(final)
|
||||
if err != nil {
|
||||
h.logger.Warn("resend write failed", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PacketHandler) SendVoicePacket(data []byte, codec byte) error {
|
||||
h.mu.Lock()
|
||||
pID := h.packetCounter[PacketTypeVoice]
|
||||
pGen := h.generationCounter[PacketTypeVoice]
|
||||
h.packetCounter[PacketTypeVoice]++
|
||||
if h.packetCounter[PacketTypeVoice] == 0 {
|
||||
h.generationCounter[PacketTypeVoice]++
|
||||
}
|
||||
clid := h.clientID
|
||||
h.mu.Unlock()
|
||||
|
||||
payloadLen := voiceHeaderSize + len(data)
|
||||
|
||||
voicePayload := getPooledBytes(&voicePayloadPool, payloadLen)
|
||||
binary.BigEndian.PutUint16(voicePayload[0:2], pID)
|
||||
voicePayload[2] = codec
|
||||
copy(voicePayload[voiceHeaderSize:], data)
|
||||
|
||||
p := &Packet{
|
||||
TypeFlagged: byte(PacketTypeVoice) | byte(PacketFlagUnencrypted),
|
||||
ID: pID,
|
||||
GenerationID: pGen,
|
||||
Data: voicePayload,
|
||||
ClientID: clid,
|
||||
}
|
||||
|
||||
header := p.BuildC2SHeader()
|
||||
|
||||
final := getPooledBytes(&bufPool, tagSize+headerSize+payloadLen)
|
||||
|
||||
copy(final[0:8], h.TsCrypt.FakeSignature)
|
||||
copy(final[8:13], header)
|
||||
copy(final[13:], voicePayload)
|
||||
|
||||
_, err := h.conn.Write(final[:tagSize+headerSize+payloadLen])
|
||||
|
||||
putPooledBytes(&bufPool, final)
|
||||
putPooledBytes(&voicePayloadPool, voicePayload)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *PacketHandler) Close() error {
|
||||
if h.closed.Swap(true) {
|
||||
return nil
|
||||
}
|
||||
close(h.stopCh)
|
||||
if h.conn != nil {
|
||||
return h.conn.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getPooledBytes(pool *sync.Pool, size int) []byte {
|
||||
bufPtr, ok := pool.Get().(*[]byte)
|
||||
if !ok || bufPtr == nil {
|
||||
return make([]byte, size)
|
||||
}
|
||||
buf := *bufPtr
|
||||
if cap(buf) < size {
|
||||
return make([]byte, size)
|
||||
}
|
||||
|
||||
return buf[:size]
|
||||
}
|
||||
|
||||
func putPooledBytes(pool *sync.Pool, buf []byte) {
|
||||
pool.Put(&buf)
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/crypto"
|
||||
)
|
||||
|
||||
// packetPipe is one leg of an in-memory datagram pair: one Write is one Read message.
|
||||
type packetPipe struct {
|
||||
recv <-chan []byte
|
||||
send chan<- []byte
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
func (p *packetPipe) Read(b []byte) (int, error) {
|
||||
select {
|
||||
case data, ok := <-p.recv:
|
||||
if !ok {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(b, data)
|
||||
|
||||
return n, nil
|
||||
case <-p.done:
|
||||
return 0, io.EOF
|
||||
}
|
||||
}
|
||||
|
||||
func (p *packetPipe) Write(b []byte) (int, error) {
|
||||
if p.closed.Load() {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
cp := make([]byte, len(b))
|
||||
copy(cp, b)
|
||||
select {
|
||||
case p.send <- cp:
|
||||
return len(b), nil
|
||||
case <-p.done:
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
}
|
||||
|
||||
func (p *packetPipe) Close() error {
|
||||
p.once.Do(func() {
|
||||
p.closed.Store(true)
|
||||
close(p.done)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// newTestPair creates a pair of connected packetPipe endpoints.
|
||||
// clientConn is given to PacketHandler.Start(); serverConn is used by the test
|
||||
// to read what the handler sends and inject packets the handler receives.
|
||||
func newTestPair() (*packetPipe, *packetPipe) {
|
||||
toClient := make(chan []byte, 256)
|
||||
fromClient := make(chan []byte, 256)
|
||||
done := make(chan struct{})
|
||||
|
||||
clientConn := &packetPipe{recv: toClient, send: fromClient, done: done}
|
||||
serverConn := &packetPipe{recv: fromClient, send: toClient, done: done}
|
||||
|
||||
return clientConn, serverConn
|
||||
}
|
||||
|
||||
const testIdentityForHandler = "W2OSGpWxkzBPJjt8iyJFsMnqnwHCnxOlmE9gWFOFnKs=:0"
|
||||
|
||||
// These match the unexported dummy key/nonce in crypto/crypt_ops.go,
|
||||
// used for EAX encrypt/decrypt before CryptoInit completes.
|
||||
var (
|
||||
handlerTestDummyKey = []byte(`c:\windows\syste`)
|
||||
handlerTestDummyNonce = []byte(`m\firewall32.cpl`)
|
||||
)
|
||||
|
||||
func newTestHandler(t *testing.T) (*PacketHandler, *packetPipe) {
|
||||
t.Helper()
|
||||
id, err := crypto.IdentityFromString(testIdentityForHandler)
|
||||
if err != nil {
|
||||
t.Fatalf("IdentityFromString: %v", err)
|
||||
}
|
||||
tc := crypto.NewCrypt(id)
|
||||
h := NewPacketHandler(tc, slog.Default())
|
||||
clientConn, serverConn := newTestPair()
|
||||
startErr := h.Start(clientConn)
|
||||
if startErr != nil {
|
||||
t.Fatalf("Start: %v", startErr)
|
||||
}
|
||||
|
||||
return h, serverConn
|
||||
}
|
||||
|
||||
// readPacket reads the next packet from serverConn with a 2-second timeout.
|
||||
func readPacket(t *testing.T, serverConn *packetPipe) []byte {
|
||||
t.Helper()
|
||||
buf := make([]byte, 4096)
|
||||
done := make(chan []byte, 1)
|
||||
|
||||
go func() {
|
||||
n, err := serverConn.Read(buf)
|
||||
if err != nil {
|
||||
done <- nil
|
||||
|
||||
return
|
||||
}
|
||||
cp := make([]byte, n)
|
||||
copy(cp, buf[:n])
|
||||
done <- cp
|
||||
}()
|
||||
|
||||
select {
|
||||
case data := <-done:
|
||||
return data
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("readPacket: timed out after 2s")
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// buildS2CPacket constructs a raw S2C (server-to-client) packet for injection.
|
||||
// Format: [8 tag][2 ID][1 TypeFlagged][payload].
|
||||
func buildS2CPacket(tag []byte, id uint16, typeFlagged byte, payload []byte) []byte {
|
||||
raw := make([]byte, 8+3+len(payload))
|
||||
copy(raw[0:8], tag)
|
||||
binary.BigEndian.PutUint16(raw[8:10], id)
|
||||
raw[10] = typeFlagged
|
||||
copy(raw[11:], payload)
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
// buildDummyEncryptedS2CCommand encrypts a command payload with the dummy EAX key
|
||||
// and returns the full raw S2C packet bytes.
|
||||
func buildDummyEncryptedS2CCommand(t *testing.T, pktID uint16, typeFlagged byte, payload []byte) []byte {
|
||||
t.Helper()
|
||||
s2cHeader := make([]byte, 3)
|
||||
binary.BigEndian.PutUint16(s2cHeader[0:2], pktID)
|
||||
s2cHeader[2] = typeFlagged
|
||||
|
||||
key := make([]byte, 16)
|
||||
copy(key, handlerTestDummyKey)
|
||||
eax, err := crypto.NewEAX(key)
|
||||
if err != nil {
|
||||
t.Fatalf("NewEAX: %v", err)
|
||||
}
|
||||
ciphertext, mac, err := eax.Encrypt(handlerTestDummyNonce, s2cHeader, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
|
||||
raw := make([]byte, 8+3+len(ciphertext))
|
||||
copy(raw[0:8], mac)
|
||||
copy(raw[8:11], s2cHeader)
|
||||
copy(raw[11:], ciphertext)
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
func TestHandlerStart_SendsInit1Packet(t *testing.T) {
|
||||
h, serverConn := newTestHandler(t)
|
||||
defer func() { _ = h.Close() }()
|
||||
|
||||
pkt := readPacket(t, serverConn)
|
||||
if len(pkt) < 13+21 {
|
||||
t.Fatalf("expected at least %d bytes, got %d", 13+21, len(pkt))
|
||||
}
|
||||
|
||||
// Verify MAC is "TS3INIT1"
|
||||
if string(pkt[0:8]) != "TS3INIT1" {
|
||||
t.Errorf("expected 'TS3INIT1' MAC, got %q", pkt[0:8])
|
||||
}
|
||||
// C2S header[4] lower nibble is the packet type.
|
||||
typeByte := pkt[12] & 0x0F
|
||||
if typeByte != byte(PacketTypeInit1) {
|
||||
t.Errorf("expected PacketTypeInit1 (%d), got %d", PacketTypeInit1, typeByte)
|
||||
}
|
||||
// Payload: [4 version][1 type=0x00][...] = 21 bytes
|
||||
if len(pkt[13:]) != 21 {
|
||||
t.Errorf("expected 21-byte Init1 payload, got %d", len(pkt[13:]))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlerReceive_Init1Packet_CallsOnPacket verifies that a server-sent Init1
|
||||
// packet is delivered to OnPacket. Responding with subsequent Init1 steps is the
|
||||
// Client's responsibility, not the PacketHandler's.
|
||||
func TestHandlerReceive_Init1Packet_CallsOnPacket(t *testing.T) {
|
||||
h, serverConn := newTestHandler(t)
|
||||
defer func() { _ = h.Close() }()
|
||||
|
||||
_ = readPacket(t, serverConn)
|
||||
|
||||
received := make(chan *Packet, 1)
|
||||
h.OnPacket = func(p *Packet) {
|
||||
received <- p
|
||||
}
|
||||
|
||||
step0Data := make([]byte, 21)
|
||||
step0Data[0] = 0x00
|
||||
binary.LittleEndian.PutUint32(step0Data[9:13], 0xCAFEBABE)
|
||||
|
||||
raw := buildS2CPacket(make([]byte, 8), 0, byte(PacketTypeInit1), step0Data)
|
||||
_, writeErr := serverConn.Write(raw)
|
||||
if writeErr != nil {
|
||||
t.Fatalf("Write: %v", writeErr)
|
||||
}
|
||||
|
||||
select {
|
||||
case p := <-received:
|
||||
if p.Type() != PacketTypeInit1 {
|
||||
t.Errorf("expected PacketTypeInit1, got %v", p.Type())
|
||||
}
|
||||
if len(p.Data) != 21 {
|
||||
t.Errorf("expected 21-byte payload, got %d", len(p.Data))
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("OnPacket not called for Init1 packet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReceive_PingFromServer_SendsPong(t *testing.T) {
|
||||
h, serverConn := newTestHandler(t)
|
||||
defer func() { _ = h.Close() }()
|
||||
|
||||
_ = readPacket(t, serverConn)
|
||||
|
||||
const pingID = uint16(42)
|
||||
pingPayload := make([]byte, 2)
|
||||
binary.BigEndian.PutUint16(pingPayload, pingID)
|
||||
|
||||
// TypeFlagged = PacketTypePing(4) | PacketFlagUnencrypted(0x80)
|
||||
// FakeSignature before CryptoInit = all zeros.
|
||||
typeFlagged := byte(PacketTypePing) | byte(PacketFlagUnencrypted)
|
||||
raw := buildS2CPacket(make([]byte, 8), pingID, typeFlagged, pingPayload)
|
||||
_, writeErr2 := serverConn.Write(raw)
|
||||
if writeErr2 != nil {
|
||||
t.Fatalf("Write ping: %v", writeErr2)
|
||||
}
|
||||
|
||||
resp := readPacket(t, serverConn)
|
||||
if len(resp) < 13+2 {
|
||||
t.Fatalf("expected pong, got %d bytes", len(resp))
|
||||
}
|
||||
if resp[12]&0x0F != byte(PacketTypePong) {
|
||||
t.Errorf("expected PacketTypePong (%d), got %d", PacketTypePong, resp[12]&0x0F)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerClose_CallsOnClosed(t *testing.T) {
|
||||
h, serverConn := newTestHandler(t)
|
||||
|
||||
closed := make(chan error, 1)
|
||||
h.OnClosed = func(err error) {
|
||||
closed <- err
|
||||
}
|
||||
|
||||
_ = readPacket(t, serverConn)
|
||||
_ = h.Close()
|
||||
|
||||
select {
|
||||
case <-closed:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("OnClosed not called within timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReceive_CommandPacket_CallsOnPacket(t *testing.T) {
|
||||
h, serverConn := newTestHandler(t)
|
||||
defer func() { _ = h.Close() }()
|
||||
|
||||
_ = readPacket(t, serverConn)
|
||||
|
||||
typeFlagged := byte(PacketTypeCommand) | byte(PacketFlagNewProtocol)
|
||||
raw := buildDummyEncryptedS2CCommand(t, 0, typeFlagged, []byte("hello"))
|
||||
|
||||
received := make(chan *Packet, 1)
|
||||
h.OnPacket = func(p *Packet) {
|
||||
received <- p
|
||||
}
|
||||
|
||||
_, writeErr3 := serverConn.Write(raw)
|
||||
if writeErr3 != nil {
|
||||
t.Fatalf("Write command: %v", writeErr3)
|
||||
}
|
||||
|
||||
select {
|
||||
case p := <-received:
|
||||
if string(p.Data) != "hello" {
|
||||
t.Errorf("expected 'hello', got %q", p.Data)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("OnPacket not called within timeout")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlerReceive_FragmentedCommandPacket_Reassembles verifies that two
|
||||
// fragmented command packets are correctly reassembled before OnPacket is called.
|
||||
//
|
||||
// TeamSpeak fragmentation:
|
||||
// - First fragment: PacketFlagFragmented SET
|
||||
// - Middle fragments: PacketFlagFragmented NOT set
|
||||
// - Last fragment: PacketFlagFragmented SET ← both start and end have the flag
|
||||
func TestHandlerReceive_FragmentedCommandPacket_Reassembles(t *testing.T) {
|
||||
h, serverConn := newTestHandler(t)
|
||||
defer func() { _ = h.Close() }()
|
||||
|
||||
_ = readPacket(t, serverConn)
|
||||
|
||||
// Fragment 1 (start): ID=0, Command | NewProtocol | Fragmented
|
||||
f1Type := byte(PacketTypeCommand) | byte(PacketFlagNewProtocol) | byte(PacketFlagFragmented)
|
||||
// Fragment 2 (end): ID=1, Command | NewProtocol | Fragmented
|
||||
// Both first and last fragments have Fragmented set per TeamSpeak fragmentation rules.
|
||||
f2Type := byte(PacketTypeCommand) | byte(PacketFlagNewProtocol) | byte(PacketFlagFragmented)
|
||||
|
||||
received := make(chan *Packet, 1)
|
||||
h.OnPacket = func(p *Packet) {
|
||||
received <- p
|
||||
}
|
||||
|
||||
_, err1 := serverConn.Write(buildDummyEncryptedS2CCommand(t, 0, f1Type, []byte("hello")))
|
||||
if err1 != nil {
|
||||
t.Fatalf("Write fragment 1: %v", err1)
|
||||
}
|
||||
_, err2 := serverConn.Write(buildDummyEncryptedS2CCommand(t, 1, f2Type, []byte(" world")))
|
||||
if err2 != nil {
|
||||
t.Fatalf("Write fragment 2: %v", err2)
|
||||
}
|
||||
|
||||
select {
|
||||
case p := <-received:
|
||||
if string(p.Data) != "hello world" {
|
||||
t.Errorf("expected 'hello world', got %q", p.Data)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("OnPacket not called for reassembled packet")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlerSendPacket_LargeCommand_SplitsIntoFragments verifies that a
|
||||
// Command payload >487 bytes is fragmented.
|
||||
//
|
||||
// first != last → set PacketFlagFragmented (only on first and last, not middle).
|
||||
func TestHandlerSendPacket_LargeCommand_SplitsIntoFragments(t *testing.T) {
|
||||
h, serverConn := newTestHandler(t)
|
||||
defer func() { _ = h.Close() }()
|
||||
_ = readPacket(t, serverConn)
|
||||
|
||||
// 975 bytes → 3 fragments: 487 + 487 + 1
|
||||
largeData := make([]byte, 975)
|
||||
sendErr := h.SendPacket(byte(PacketTypeCommand), largeData, 0)
|
||||
if sendErr != nil {
|
||||
t.Fatalf("SendPacket error: %v", sendErr)
|
||||
}
|
||||
|
||||
// Collect 3 fragments.
|
||||
pkts := make([][]byte, 0, 3)
|
||||
for range 3 {
|
||||
pkts = append(pkts, readPacket(t, serverConn))
|
||||
}
|
||||
|
||||
// C2S raw layout: [8 tag][2 pktID][2 clientID][1 TypeFlagged][ciphertext]
|
||||
// TypeFlagged byte is at index 12.
|
||||
fragFlag := byte(PacketFlagFragmented)
|
||||
// Fragment 0 (first=true, last=false): Fragmented set
|
||||
if pkts[0][12]&fragFlag == 0 {
|
||||
t.Errorf("fragment 0 should have Fragmented flag, TypeFlagged=0x%02x", pkts[0][12])
|
||||
}
|
||||
// Fragment 1 (first=false, last=false): Fragmented NOT set
|
||||
if pkts[1][12]&fragFlag != 0 {
|
||||
t.Errorf("fragment 1 (middle) should NOT have Fragmented flag, TypeFlagged=0x%02x", pkts[1][12])
|
||||
}
|
||||
// Fragment 2 (first=false, last=true): Fragmented set
|
||||
if pkts[2][12]&fragFlag == 0 {
|
||||
t.Errorf("fragment 2 (last) should have Fragmented flag, TypeFlagged=0x%02x", pkts[2][12])
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlerSendPacket_ExactBoundary_NoSplit verifies that a 487-byte payload
|
||||
// (exactly the max) is sent as a single packet.
|
||||
func TestHandlerSendPacket_ExactBoundary_NoSplit(t *testing.T) {
|
||||
h, serverConn := newTestHandler(t)
|
||||
defer func() { _ = h.Close() }()
|
||||
_ = readPacket(t, serverConn)
|
||||
|
||||
sendErr := h.SendPacket(byte(PacketTypeCommand), make([]byte, 487), 0)
|
||||
if sendErr != nil {
|
||||
t.Fatalf("SendPacket: %v", sendErr)
|
||||
}
|
||||
|
||||
pkt := readPacket(t, serverConn)
|
||||
if pkt[12]&byte(PacketFlagFragmented) != 0 {
|
||||
t.Error("exact-boundary packet should not have Fragmented flag")
|
||||
}
|
||||
|
||||
// Ensure no second fragment arrives.
|
||||
select {
|
||||
case extra := <-func() chan []byte {
|
||||
ch := make(chan []byte, 1)
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
n, readErr := serverConn.Read(buf)
|
||||
if readErr == nil {
|
||||
cp := make([]byte, n)
|
||||
copy(cp, buf[:n])
|
||||
ch <- cp
|
||||
}
|
||||
}()
|
||||
|
||||
return ch
|
||||
}():
|
||||
t.Errorf("unexpected second packet: %d bytes", len(extra))
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerSendVoicePacket_Format(t *testing.T) {
|
||||
h, serverConn := newTestHandler(t)
|
||||
defer func() { _ = h.Close() }()
|
||||
_ = readPacket(t, serverConn)
|
||||
|
||||
voiceData := []byte{0x01, 0x02, 0x03, 0x04}
|
||||
const codec = byte(4) // Opus Voice
|
||||
sendErr := h.SendVoicePacket(voiceData, codec)
|
||||
if sendErr != nil {
|
||||
t.Fatalf("SendVoicePacket: %v", sendErr)
|
||||
}
|
||||
|
||||
pkt := readPacket(t, serverConn)
|
||||
if len(pkt) < 13+3+len(voiceData) {
|
||||
t.Fatalf("packet too short: %d bytes", len(pkt))
|
||||
}
|
||||
|
||||
// Tag bytes 0-7 should be FakeSignature (all zeros for unused crypto state).
|
||||
fakeSig := h.TsCrypt.FakeSignature
|
||||
for i, b := range fakeSig {
|
||||
if pkt[i] != b {
|
||||
t.Errorf("tag[%d]: expected 0x%02x (FakeSignature), got 0x%02x", i, b, pkt[i])
|
||||
}
|
||||
}
|
||||
|
||||
// TypeFlagged byte 12: type = PacketTypeVoice (0), flags = Unencrypted (0x80)
|
||||
if pkt[12]&0x0F != byte(PacketTypeVoice) {
|
||||
t.Errorf("expected PacketTypeVoice (0), got %d", pkt[12]&0x0F)
|
||||
}
|
||||
if pkt[12]&byte(PacketFlagUnencrypted) == 0 {
|
||||
t.Error("voice packet should have Unencrypted flag")
|
||||
}
|
||||
|
||||
// Payload: [2 seqID][1 codec][data]
|
||||
if pkt[13+2] != codec {
|
||||
t.Errorf("expected codec=0x%02x, got 0x%02x", codec, pkt[13+2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerSendVoicePacket_SequenceIncreases(t *testing.T) {
|
||||
h, serverConn := newTestHandler(t)
|
||||
defer func() { _ = h.Close() }()
|
||||
_ = readPacket(t, serverConn)
|
||||
|
||||
for i := range 3 {
|
||||
voiceSendErr := h.SendVoicePacket([]byte{byte(i)}, 4)
|
||||
if voiceSendErr != nil {
|
||||
t.Fatalf("SendVoicePacket[%d]: %v", i, voiceSendErr)
|
||||
}
|
||||
}
|
||||
|
||||
pkt0 := readPacket(t, serverConn)
|
||||
pkt1 := readPacket(t, serverConn)
|
||||
|
||||
seq0 := binary.BigEndian.Uint16(pkt0[13:15])
|
||||
seq1 := binary.BigEndian.Uint16(pkt1[13:15])
|
||||
if seq1 != seq0+1 {
|
||||
t.Errorf("expected seq1 = seq0+1 = %d, got %d", seq0+1, seq1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReceivedFinalInitAck_ClearsInitPacketCheck(t *testing.T) {
|
||||
h, serverConn := newTestHandler(t)
|
||||
defer func() { _ = h.Close() }()
|
||||
_ = readPacket(t, serverConn)
|
||||
|
||||
h.mu.Lock()
|
||||
hasCheck := h.initPacketCheck != nil
|
||||
h.mu.Unlock()
|
||||
|
||||
if !hasCheck {
|
||||
t.Error("expected initPacketCheck to be set after Start()")
|
||||
}
|
||||
|
||||
h.ReceivedFinalInitAck()
|
||||
|
||||
h.mu.Lock()
|
||||
hasCheck = h.initPacketCheck != nil
|
||||
h.mu.Unlock()
|
||||
|
||||
if hasCheck {
|
||||
t.Error("expected initPacketCheck to be nil after ReceivedFinalInitAck()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerGetWinForType(t *testing.T) {
|
||||
h, serverConn := newTestHandler(t)
|
||||
defer func() { _ = h.Close() }()
|
||||
_ = readPacket(t, serverConn)
|
||||
|
||||
if w := h.getWinForType(PacketTypeCommand); w == nil {
|
||||
t.Error("expected non-nil window for PacketTypeCommand")
|
||||
}
|
||||
if w := h.getWinForType(PacketTypeCommandLow); w == nil {
|
||||
t.Error("expected non-nil window for PacketTypeCommandLow")
|
||||
}
|
||||
if w := h.getWinForType(PacketTypePing); w != nil {
|
||||
t.Errorf("expected nil window for PacketTypePing, got %v", w)
|
||||
}
|
||||
if w := h.getWinForType(PacketTypeVoice); w != nil {
|
||||
t.Errorf("expected nil window for PacketTypeVoice, got %v", w)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerCheckResends_InitPacket_ReSent(t *testing.T) {
|
||||
h, serverConn := newTestHandler(t)
|
||||
defer func() { _ = h.Close() }()
|
||||
// Drain the initial Init1 packet.
|
||||
_ = readPacket(t, serverConn)
|
||||
|
||||
// Fast-forward initPacketCheck.lastSend so checkResends triggers a resend.
|
||||
h.mu.Lock()
|
||||
if h.initPacketCheck != nil {
|
||||
h.initPacketCheck.lastSend = time.Now().Add(-time.Second)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
h.checkResends()
|
||||
|
||||
// A resent Init1 packet should now appear on serverConn.
|
||||
resent := readPacket(t, serverConn)
|
||||
if len(resent) < 13 {
|
||||
t.Fatalf("expected resent Init1, got %d bytes", len(resent))
|
||||
}
|
||||
if resent[12]&0x0F != byte(PacketTypeInit1) {
|
||||
t.Errorf("expected PacketTypeInit1, got type %d", resent[12]&0x0F)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerCheckResends_IdleTimeout_ClosesHandler(t *testing.T) {
|
||||
h, serverConn := newTestHandler(t)
|
||||
closed := make(chan error, 1)
|
||||
h.OnClosed = func(err error) { closed <- err }
|
||||
_ = readPacket(t, serverConn)
|
||||
|
||||
// Simulate idle timeout: set lastMessageReceived to a distant past.
|
||||
h.mu.Lock()
|
||||
h.lastMessageReceived = time.Now().Add(-(PacketTimeout + time.Second))
|
||||
h.mu.Unlock()
|
||||
|
||||
h.checkResends()
|
||||
|
||||
select {
|
||||
case <-closed:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("expected handler to close on idle timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerClose_IdempotentNoError(t *testing.T) {
|
||||
h, serverConn := newTestHandler(t)
|
||||
_ = readPacket(t, serverConn)
|
||||
|
||||
err := h.Close()
|
||||
if err != nil {
|
||||
t.Errorf("first Close() returned error: %v", err)
|
||||
}
|
||||
err = h.Close()
|
||||
if err != nil {
|
||||
t.Errorf("second Close() returned error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PacketType byte
|
||||
|
||||
const (
|
||||
PacketTypeVoice PacketType = 0
|
||||
PacketTypeVoiceWhisper PacketType = 1
|
||||
PacketTypeCommand PacketType = 2
|
||||
PacketTypeCommandLow PacketType = 3
|
||||
PacketTypePing PacketType = 4
|
||||
PacketTypePong PacketType = 5
|
||||
PacketTypeAck PacketType = 6
|
||||
PacketTypeAckLow PacketType = 7
|
||||
PacketTypeInit1 PacketType = 8
|
||||
)
|
||||
|
||||
type PacketFlags byte
|
||||
|
||||
const (
|
||||
PacketFlagFragmented PacketFlags = 0x10
|
||||
PacketFlagNewProtocol PacketFlags = 0x20
|
||||
PacketFlagCompressed PacketFlags = 0x40
|
||||
PacketFlagUnencrypted PacketFlags = 0x80
|
||||
)
|
||||
|
||||
type Packet struct {
|
||||
ReceivedAt time.Time
|
||||
Data []byte
|
||||
GenerationID uint32
|
||||
ID uint16
|
||||
ClientID uint16
|
||||
TypeFlagged byte
|
||||
}
|
||||
|
||||
func (p *Packet) Type() PacketType {
|
||||
return PacketType(p.TypeFlagged & 0x0F)
|
||||
}
|
||||
|
||||
func (p *Packet) Flags() PacketFlags {
|
||||
return PacketFlags(p.TypeFlagged & 0xF0)
|
||||
}
|
||||
|
||||
func (p *Packet) IsUnencrypted() bool {
|
||||
return (p.Flags() & PacketFlagUnencrypted) != 0
|
||||
}
|
||||
|
||||
func (p *Packet) BuildC2SHeader() []byte {
|
||||
header := make([]byte, 5)
|
||||
binary.BigEndian.PutUint16(header[0:2], p.ID)
|
||||
binary.BigEndian.PutUint16(header[2:4], p.ClientID)
|
||||
header[4] = p.TypeFlagged
|
||||
|
||||
return header
|
||||
}
|
||||
|
||||
func (p *Packet) ParseS2CHeader(raw []byte) {
|
||||
p.ID = binary.BigEndian.Uint16(raw[0:2])
|
||||
p.TypeFlagged = raw[2]
|
||||
}
|
||||
|
||||
func (p *Packet) ParseC2SHeader(raw []byte) {
|
||||
p.ID = binary.BigEndian.Uint16(raw[0:2])
|
||||
p.ClientID = binary.BigEndian.Uint16(raw[2:4])
|
||||
p.TypeFlagged = raw[4]
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package transport_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/transport"
|
||||
)
|
||||
|
||||
func TestPacketTypeExtraction(t *testing.T) {
|
||||
tests := []struct {
|
||||
typeFlagged byte
|
||||
wantType transport.PacketType
|
||||
}{
|
||||
{0x00, transport.PacketTypeVoice},
|
||||
{0x01, transport.PacketTypeVoiceWhisper},
|
||||
{0x02, transport.PacketTypeCommand},
|
||||
{0x03, transport.PacketTypeCommandLow},
|
||||
{0x04, transport.PacketTypePing},
|
||||
{0x05, transport.PacketTypePong},
|
||||
{0x06, transport.PacketTypeAck},
|
||||
{0x07, transport.PacketTypeAckLow},
|
||||
{0x08, transport.PacketTypeInit1},
|
||||
{0x82, transport.PacketTypeCommand}, // Unencrypted | Command
|
||||
{0xE2, transport.PacketTypeCommand}, // all flags | Command
|
||||
{0x88, transport.PacketTypeInit1}, // Unencrypted | Init1
|
||||
}
|
||||
for _, tt := range tests {
|
||||
p := &transport.Packet{TypeFlagged: tt.typeFlagged}
|
||||
if p.Type() != tt.wantType {
|
||||
t.Errorf("TypeFlagged=0x%02X: Type()=%d, want %d", tt.typeFlagged, p.Type(), tt.wantType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacketFlagsExtraction(t *testing.T) {
|
||||
tests := []struct {
|
||||
typeFlagged byte
|
||||
wantFlags transport.PacketFlags
|
||||
}{
|
||||
{0x10, transport.PacketFlagFragmented},
|
||||
{0x20, transport.PacketFlagNewProtocol},
|
||||
{0x40, transport.PacketFlagCompressed},
|
||||
{0x80, transport.PacketFlagUnencrypted},
|
||||
{
|
||||
0xF0,
|
||||
transport.PacketFlagFragmented | transport.PacketFlagNewProtocol |
|
||||
transport.PacketFlagCompressed | transport.PacketFlagUnencrypted,
|
||||
},
|
||||
{0x02, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
p := &transport.Packet{TypeFlagged: tt.typeFlagged}
|
||||
if p.Flags() != tt.wantFlags {
|
||||
t.Errorf("TypeFlagged=0x%02X: Flags()=0x%02X, want 0x%02X", tt.typeFlagged, p.Flags(), tt.wantFlags)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacketIsUnencrypted(t *testing.T) {
|
||||
tests := []struct {
|
||||
typeFlagged byte
|
||||
want bool
|
||||
}{
|
||||
{byte(transport.PacketFlagUnencrypted) | byte(transport.PacketTypeCommand), true},
|
||||
{byte(transport.PacketTypeCommand), false},
|
||||
{byte(transport.PacketFlagCompressed) | byte(transport.PacketTypeCommand), false},
|
||||
{0xFF, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
p := &transport.Packet{TypeFlagged: tt.typeFlagged}
|
||||
if p.IsUnencrypted() != tt.want {
|
||||
t.Errorf("TypeFlagged=0x%02X: IsUnencrypted()=%v, want %v", tt.typeFlagged, p.IsUnencrypted(), tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildParseC2SHeaderRoundtrip(t *testing.T) {
|
||||
tests := []struct {
|
||||
id uint16
|
||||
clientID uint16
|
||||
typeFlagged byte
|
||||
}{
|
||||
{0x0001, 0x0001, byte(transport.PacketTypeCommand)},
|
||||
{0xFFFF, 0xFFFF, byte(transport.PacketTypeInit1) | byte(transport.PacketFlagUnencrypted)},
|
||||
{0x1234, 0x5678, byte(transport.PacketTypeVoice) | byte(transport.PacketFlagUnencrypted)},
|
||||
{0x0000, 0x0000, 0x00},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
p := &transport.Packet{ID: tt.id, ClientID: tt.clientID, TypeFlagged: tt.typeFlagged}
|
||||
header := p.BuildC2SHeader()
|
||||
if len(header) != 5 {
|
||||
t.Fatalf("C2S header len=%d, want 5", len(header))
|
||||
}
|
||||
p2 := &transport.Packet{}
|
||||
p2.ParseC2SHeader(header)
|
||||
if p2.ID != p.ID {
|
||||
t.Errorf("ID: got %d, want %d", p2.ID, p.ID)
|
||||
}
|
||||
if p2.ClientID != p.ClientID {
|
||||
t.Errorf("ClientID: got %d, want %d", p2.ClientID, p.ClientID)
|
||||
}
|
||||
if p2.TypeFlagged != p.TypeFlagged {
|
||||
t.Errorf("TypeFlagged: got 0x%02X, want 0x%02X", p2.TypeFlagged, p.TypeFlagged)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseS2CHeader(t *testing.T) {
|
||||
tests := []struct {
|
||||
raw []byte
|
||||
wantID uint16
|
||||
wantTypeFl byte
|
||||
}{
|
||||
{[]byte{0x00, 0x01, byte(transport.PacketTypeCommand)}, 1, byte(transport.PacketTypeCommand)},
|
||||
{[]byte{0xFF, 0xFF, byte(transport.PacketTypeInit1)}, 0xFFFF, byte(transport.PacketTypeInit1)},
|
||||
{[]byte{0x12, 0x34, 0x82}, 0x1234, 0x82},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
p := &transport.Packet{}
|
||||
p.ParseS2CHeader(tt.raw)
|
||||
if p.ID != tt.wantID {
|
||||
t.Errorf("S2C ID: got %d, want %d", p.ID, tt.wantID)
|
||||
}
|
||||
if p.TypeFlagged != tt.wantTypeFl {
|
||||
t.Errorf("S2C TypeFlagged: got 0x%02X, want 0x%02X", p.TypeFlagged, tt.wantTypeFl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacketTypeConstants(t *testing.T) {
|
||||
if transport.PacketTypeVoice != 0 {
|
||||
t.Error("PacketTypeVoice should be 0")
|
||||
}
|
||||
if transport.PacketTypeVoiceWhisper != 1 {
|
||||
t.Error("PacketTypeVoiceWhisper should be 1")
|
||||
}
|
||||
if transport.PacketTypeCommand != 2 {
|
||||
t.Error("PacketTypeCommand should be 2")
|
||||
}
|
||||
if transport.PacketTypeCommandLow != 3 {
|
||||
t.Error("PacketTypeCommandLow should be 3")
|
||||
}
|
||||
if transport.PacketTypePing != 4 {
|
||||
t.Error("PacketTypePing should be 4")
|
||||
}
|
||||
if transport.PacketTypePong != 5 {
|
||||
t.Error("PacketTypePong should be 5")
|
||||
}
|
||||
if transport.PacketTypeAck != 6 {
|
||||
t.Error("PacketTypeAck should be 6")
|
||||
}
|
||||
if transport.PacketTypeAckLow != 7 {
|
||||
t.Error("PacketTypeAckLow should be 7")
|
||||
}
|
||||
if transport.PacketTypeInit1 != 8 {
|
||||
t.Error("PacketTypeInit1 should be 8")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacketReceivedAt(t *testing.T) {
|
||||
now := time.Now()
|
||||
p := &transport.Packet{ReceivedAt: now}
|
||||
if !p.ReceivedAt.Equal(now) {
|
||||
t.Error("ReceivedAt mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPacketDataField(t *testing.T) {
|
||||
data := []byte{0x01, 0x02, 0x03}
|
||||
p := &transport.Packet{Data: data}
|
||||
if len(p.Data) != 3 {
|
||||
t.Errorf("Data len=%d, want 3", len(p.Data))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
errQlzDataTooShort = errors.New("data too short")
|
||||
errQlzUnsupportedLevel = errors.New("only QuickLZ level 1 is supported")
|
||||
errQlzDataTooShortForHeader = errors.New("data too short for header")
|
||||
)
|
||||
|
||||
// TableSize is the QuickLZ level-1 hash table size.
|
||||
const TableSize = 4096
|
||||
|
||||
type Qlz struct {
|
||||
hashtable [TableSize]int
|
||||
}
|
||||
|
||||
type qlzState struct {
|
||||
control uint32
|
||||
sourcePos int
|
||||
destPos int
|
||||
nextHashed int
|
||||
}
|
||||
|
||||
func NewQlz() *Qlz {
|
||||
return &Qlz{}
|
||||
}
|
||||
|
||||
func getDecompressedSize(data []byte) int {
|
||||
if (data[0] & 0x02) != 0 {
|
||||
return int(binary.LittleEndian.Uint32(data[5:9]))
|
||||
}
|
||||
|
||||
return int(data[2])
|
||||
}
|
||||
|
||||
func (q *Qlz) Decompress(data []byte) ([]byte, error) {
|
||||
headerLen, decompressedSize, flags, err := parseQlzHeader(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dest := make([]byte, decompressedSize)
|
||||
|
||||
if (flags & 0x01) == 0 {
|
||||
copy(dest, data[headerLen:headerLen+decompressedSize])
|
||||
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
for i := range q.hashtable {
|
||||
q.hashtable[i] = 0
|
||||
}
|
||||
|
||||
state := qlzState{
|
||||
control: 1,
|
||||
sourcePos: headerLen,
|
||||
}
|
||||
|
||||
for q.ensureControl(data, &state) {
|
||||
if (state.control & 1) != 0 {
|
||||
if !q.processReference(data, dest, &state) {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
if q.processLiteral(data, dest, decompressedSize, &state) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
func parseQlzHeader(data []byte) (int, int, byte, error) {
|
||||
if len(data) < 3 {
|
||||
return 0, 0, 0, errQlzDataTooShort
|
||||
}
|
||||
flags := data[0]
|
||||
level := (flags >> 2) & 0x03
|
||||
if level != 1 {
|
||||
return 0, 0, 0, errQlzUnsupportedLevel
|
||||
}
|
||||
headerLen := 3
|
||||
if (flags & 0x02) != 0 {
|
||||
headerLen = 9
|
||||
}
|
||||
if len(data) < headerLen {
|
||||
return 0, 0, 0, errQlzDataTooShortForHeader
|
||||
}
|
||||
|
||||
return headerLen, getDecompressedSize(data), flags, nil
|
||||
}
|
||||
|
||||
func (q *Qlz) ensureControl(data []byte, st *qlzState) bool {
|
||||
if st.control != 1 {
|
||||
return true
|
||||
}
|
||||
if st.sourcePos+4 > len(data) {
|
||||
return false
|
||||
}
|
||||
st.control = binary.LittleEndian.Uint32(data[st.sourcePos : st.sourcePos+4])
|
||||
st.sourcePos += 4
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (q *Qlz) processReference(data, dest []byte, st *qlzState) bool {
|
||||
st.control >>= 1
|
||||
if st.sourcePos+2 > len(data) {
|
||||
return false
|
||||
}
|
||||
b1 := data[st.sourcePos]
|
||||
b2 := data[st.sourcePos+1]
|
||||
st.sourcePos += 2
|
||||
|
||||
hash := int(b1>>4) | (int(b2) << 4)
|
||||
matchlen := int(b1 & 0x0F)
|
||||
if matchlen != 0 {
|
||||
matchlen += 2
|
||||
} else {
|
||||
if st.sourcePos >= len(data) {
|
||||
return false
|
||||
}
|
||||
matchlen = int(data[st.sourcePos])
|
||||
st.sourcePos++
|
||||
}
|
||||
|
||||
offset := q.hashtable[hash]
|
||||
for i := range matchlen {
|
||||
if st.destPos < len(dest) && offset+i < st.destPos {
|
||||
dest[st.destPos] = dest[offset+i]
|
||||
st.destPos++
|
||||
}
|
||||
}
|
||||
|
||||
end := st.destPos + 1 - matchlen
|
||||
q.updateHashtable(dest, &st.nextHashed, end)
|
||||
st.nextHashed = st.destPos
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (q *Qlz) processLiteral(data, dest []byte, decompressedSize int, st *qlzState) bool {
|
||||
if st.destPos >= max(decompressedSize, 10)-10 {
|
||||
for st.destPos < decompressedSize {
|
||||
if st.control == 1 {
|
||||
st.sourcePos += 4
|
||||
if st.sourcePos > len(data) {
|
||||
break
|
||||
}
|
||||
st.control = binary.LittleEndian.Uint32(data[st.sourcePos-4 : st.sourcePos])
|
||||
}
|
||||
if st.sourcePos >= len(data) {
|
||||
break
|
||||
}
|
||||
dest[st.destPos] = data[st.sourcePos]
|
||||
st.destPos++
|
||||
st.sourcePos++
|
||||
st.control >>= 1
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
if st.sourcePos >= len(data) || st.destPos >= len(dest) {
|
||||
return true
|
||||
}
|
||||
dest[st.destPos] = data[st.sourcePos]
|
||||
st.destPos++
|
||||
st.sourcePos++
|
||||
st.control >>= 1
|
||||
end := max(st.destPos-2, 0)
|
||||
q.updateHashtable(dest, &st.nextHashed, end)
|
||||
if st.nextHashed < end {
|
||||
st.nextHashed = end
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (q *Qlz) updateHashtable(dest []byte, nextHashed *int, end int) {
|
||||
for *nextHashed < end {
|
||||
if *nextHashed+3 > len(dest) {
|
||||
break
|
||||
}
|
||||
v := uint32(dest[*nextHashed]) | (uint32(dest[*nextHashed+1]) << 8) | (uint32(dest[*nextHashed+2]) << 16)
|
||||
hash := ((v >> 12) ^ v) & 0xFFF
|
||||
q.hashtable[hash] = *nextHashed
|
||||
*nextHashed++
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package transport_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/honeybbq/teamspeak-go/transport"
|
||||
)
|
||||
|
||||
// buildUncompressedQlz constructs a QuickLZ level-1 frame with no compression.
|
||||
// flags=0x04: level=1 (bits 3:2=01), 3-byte header (bit 1=0), uncompressed (bit 0=0).
|
||||
func buildUncompressedQlz(payload []byte) []byte {
|
||||
if len(payload) > 252 {
|
||||
panic("buildUncompressedQlz: payload too large for single-byte header")
|
||||
}
|
||||
data := make([]byte, 3+len(payload))
|
||||
data[0] = 0x04
|
||||
data[1] = byte(3 + len(payload))
|
||||
data[2] = byte(len(payload))
|
||||
copy(data[3:], payload)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// buildAllLiteralQlz constructs a QuickLZ level-1 compressed frame where all
|
||||
// data is encoded as literals (no back-references). Control word=0 means all
|
||||
// 32 control bits select the literal path.
|
||||
// flags=0x05: level=1, 3-byte header, compressed.
|
||||
func buildAllLiteralQlz(payload []byte) []byte {
|
||||
if len(payload) > 248 {
|
||||
panic("buildAllLiteralQlz: payload too large for single-byte header")
|
||||
}
|
||||
data := make([]byte, 0, 3+4+len(payload))
|
||||
data = append(data, 0x05)
|
||||
data = append(data, byte(7+len(payload)))
|
||||
data = append(data, byte(len(payload)))
|
||||
data = append(data, 0x00, 0x00, 0x00, 0x00) // control word: all literals
|
||||
data = append(data, payload...)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func TestQlzDecompressErrorTooShort(t *testing.T) {
|
||||
q := transport.NewQlz()
|
||||
_, err := q.Decompress([]byte{0x04})
|
||||
if err == nil {
|
||||
t.Error("expected error for too-short data (< 3 bytes)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQlzDecompressErrorWrongLevel(t *testing.T) {
|
||||
q := transport.NewQlz()
|
||||
// flags=0x08: level=(0x08>>2)&0x03=2 (unsupported)
|
||||
_, err := q.Decompress([]byte{0x08, 0x00, 0x04})
|
||||
if err == nil {
|
||||
t.Error("expected error for non-level-1 data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQlzDecompressUncompressed3ByteHeader(t *testing.T) {
|
||||
payload := []byte("ABCD")
|
||||
q := transport.NewQlz()
|
||||
result, err := q.Decompress(buildUncompressedQlz(payload))
|
||||
if err != nil {
|
||||
t.Fatalf("Decompress failed: %v", err)
|
||||
}
|
||||
if !bytes.Equal(result, payload) {
|
||||
t.Errorf("result=%v, want %v", result, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQlzDecompressUncompressed9ByteHeader(t *testing.T) {
|
||||
// flags=0x06: level=1, 9-byte header (bit 1=1), uncompressed (bit 0=0).
|
||||
// Decompressed size is uint32 LE at bytes [5:9].
|
||||
payload := []byte("ABCD")
|
||||
data := make([]byte, 9+len(payload))
|
||||
data[0] = 0x06
|
||||
data[5] = byte(len(payload))
|
||||
copy(data[9:], payload)
|
||||
q := transport.NewQlz()
|
||||
result, err := q.Decompress(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Decompress failed: %v", err)
|
||||
}
|
||||
if !bytes.Equal(result, payload) {
|
||||
t.Errorf("result=%v, want %v", result, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQlzDecompressCompressedAllLiterals(t *testing.T) {
|
||||
// 13 bytes: max(13,10)-10=3 normal-literal iterations,
|
||||
// then the remaining 10 go through the near-end literal path.
|
||||
payload := []byte("Hello, World!")
|
||||
q := transport.NewQlz()
|
||||
result, err := q.Decompress(buildAllLiteralQlz(payload))
|
||||
if err != nil {
|
||||
t.Fatalf("Decompress failed: %v", err)
|
||||
}
|
||||
if !bytes.Equal(result, payload) {
|
||||
t.Errorf("result=%q, want %q", result, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQlzDecompressCompressedShortPayload(t *testing.T) {
|
||||
// Payload shorter than 10 bytes: all iterations use the near-end path.
|
||||
payload := []byte("Hi!")
|
||||
q := transport.NewQlz()
|
||||
result, err := q.Decompress(buildAllLiteralQlz(payload))
|
||||
if err != nil {
|
||||
t.Fatalf("Decompress failed: %v", err)
|
||||
}
|
||||
if !bytes.Equal(result, payload) {
|
||||
t.Errorf("result=%q, want %q", result, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQlzDecompressMultipleCalls(t *testing.T) {
|
||||
// Verify hashtable is reset between calls (no state leak)
|
||||
q := transport.NewQlz()
|
||||
for _, payload := range [][]byte{[]byte("first call data"), []byte("second call data")} {
|
||||
result, err := q.Decompress(buildAllLiteralQlz(payload))
|
||||
if err != nil {
|
||||
t.Fatalf("Decompress failed: %v", err)
|
||||
}
|
||||
if !bytes.Equal(result, payload) {
|
||||
t.Errorf("result=%q, want %q", result, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQlzDecompressEmptyPayload(t *testing.T) {
|
||||
q := transport.NewQlz()
|
||||
result, err := q.Decompress(buildUncompressedQlz([]byte{}))
|
||||
if err != nil {
|
||||
t.Fatalf("Decompress failed: %v", err)
|
||||
}
|
||||
if len(result) != 0 {
|
||||
t.Errorf("expected empty result, got %v", result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package teamspeak
|
||||
|
||||
// TextMessage is an incoming notifytextmessage payload.
|
||||
type TextMessage struct {
|
||||
InvokerName string
|
||||
InvokerUID string
|
||||
Message string
|
||||
InvokerGroups []string
|
||||
TargetMode int
|
||||
TargetID uint64
|
||||
InvokerID uint16
|
||||
}
|
||||
|
||||
// ClientMovedEvent is emitted when a client changes channel (notifyclientmoved).
|
||||
type ClientMovedEvent struct {
|
||||
InvokerName string
|
||||
InvokerUID string
|
||||
TargetChannelID uint64
|
||||
ReasonID int
|
||||
ID uint16
|
||||
InvokerID uint16
|
||||
}
|
||||
|
||||
// PokeEvent is emitted when this client is poked (notifyclientpoke).
|
||||
type PokeEvent struct {
|
||||
InvokerName string
|
||||
InvokerUID string
|
||||
Message string
|
||||
InvokerID uint16
|
||||
}
|
||||
|
||||
// ClientLeftViewEvent is emitted when a client leaves view (notifyclientleftview).
|
||||
type ClientLeftViewEvent struct {
|
||||
ReasonMsg string
|
||||
ReasonID int
|
||||
ID uint16
|
||||
TargetID uint16
|
||||
}
|
||||
|
||||
// kickEvent is an internal event for kick notifications (passed through event queue).
|
||||
type kickEvent struct {
|
||||
reason string
|
||||
}
|
||||
|
||||
// VoiceDataEvent is emitted when a voice packet is received from another client.
|
||||
// Sequence is the sender's uint16 voice-frame packet ID and can be used to order
|
||||
// or detect gaps independently for each client. IsWhisper reports whether the
|
||||
// packet used the whisper packet type rather than ordinary channel voice.
|
||||
type VoiceDataEvent struct {
|
||||
Data []byte
|
||||
ClientID uint16
|
||||
Codec byte
|
||||
Sequence uint16
|
||||
IsWhisper bool
|
||||
}
|
||||
|
||||
// ClientInfo holds fields from clientlist / notifycliententerview.
|
||||
type ClientInfo struct {
|
||||
Nickname string
|
||||
UID string
|
||||
ServerGroups []string
|
||||
ChannelID uint64
|
||||
Type int
|
||||
ID uint16
|
||||
}
|
||||
|
||||
// ChannelInfo is one row from channellist.
|
||||
type ChannelInfo struct {
|
||||
Name string
|
||||
Description string
|
||||
ID uint64
|
||||
ParentID uint64
|
||||
}
|
||||
|
||||
// ChannelInfoDetailed is one row from channellist -topic -flags -voice -limits -icon.
|
||||
// 提供比 ChannelInfo 更丰富的频道属性,用于 UI 展示(密码图标、语音编解码器、人数限制等)。
|
||||
type ChannelInfoDetailed struct {
|
||||
// 基础字段
|
||||
ID uint64
|
||||
ParentID uint64
|
||||
Order uint64
|
||||
Name string
|
||||
Topic string
|
||||
|
||||
// 语音相关(-voice)
|
||||
Codec int // 语音编解码器:0=Speex Narrowband, 1=Speex Wideband, 2=Speex UltraWideband, 3=CELT Mono, 4=Opus Voice, 5=Opus Music
|
||||
CodecQuality int // 编解码质量(0-10)
|
||||
NeededTalkPower int // 发言所需权限等级
|
||||
|
||||
// 限制(-limits)
|
||||
MaxClients int // 频道最大客户端数,-1=无限
|
||||
MaxFamilyClients int // 频道族最大客户端数,-1=无限
|
||||
IsMaxClientsUnlimited bool
|
||||
IsMaxFamilyClientsUnlimited bool
|
||||
IsOrdered bool // 频道是否手动排序
|
||||
|
||||
// 标志(-flags)
|
||||
IsPermanent bool
|
||||
IsSemiPermanent bool
|
||||
IsDefault bool
|
||||
IsPassword bool
|
||||
HasPassword bool // 等价于 IsPassword,保留兼容
|
||||
NeededModifyPower int // 修改频道所需权限等级
|
||||
|
||||
// 图标(-icon)
|
||||
IconID int64
|
||||
}
|
||||
|
||||
// FileUploadInfo represents the information received when an upload is initialized.
|
||||
type FileUploadInfo struct {
|
||||
FileTransferKey string
|
||||
SeekPosition uint64
|
||||
ClientFileTransferID uint16
|
||||
ServerFileTransferID uint16
|
||||
Port uint16
|
||||
}
|
||||
|
||||
// FileDownloadInfo represents the information received when a download is initialized.
|
||||
type FileDownloadInfo struct {
|
||||
FileTransferKey string
|
||||
Size uint64
|
||||
ClientFileTransferID uint16
|
||||
ServerFileTransferID uint16
|
||||
Port uint16
|
||||
}
|
||||
|
||||
// FileTransferStatusInfo represents status notifications for file transfers.
|
||||
type FileTransferStatusInfo struct {
|
||||
Message string
|
||||
Status int
|
||||
ClientFileTransferID uint16
|
||||
}
|
||||
|
||||
// ─── 服务器 / 频道 / 客户端详细信息 ─────────────────────────
|
||||
|
||||
// ServerInfo holds server details from serverinfo.
|
||||
type ServerInfo struct {
|
||||
Name string // virtualserver_name
|
||||
WelcomeMessage string // virtualserver_welcomemessage
|
||||
MaxClients int // virtualserver_maxclients
|
||||
ClientsOnline int // virtualserver_clientsonline
|
||||
ChannelsOnline int // virtualserver_channelsonline
|
||||
Uptime int64 // virtualserver_uptime (秒)
|
||||
Version string // virtualserver_version
|
||||
Platform string // virtualserver_platform
|
||||
Created int64 // virtualserver_created (unix timestamp)
|
||||
IconID int64 // virtualserver_icon_id
|
||||
DefaultServerGroup int // virtualserver_default_server_group
|
||||
DefaultChannelGroup int // virtualserver_default_channel_group
|
||||
}
|
||||
|
||||
// ChannelDetailInfo holds detailed channel properties from channelinfo.
|
||||
// 与 ChannelInfoDetailed 不同:ChannelDetailInfo 来自 channelinfo cid=X(单频道完整信息),
|
||||
// ChannelInfoDetailed 来自 channellist -flags(批量列表的部分属性)。
|
||||
type ChannelDetailInfo struct {
|
||||
ID uint64
|
||||
ParentID uint64
|
||||
Name string
|
||||
Topic string
|
||||
Description string // channel_description (可能很长)
|
||||
Codec int
|
||||
CodecQuality int
|
||||
MaxClients int
|
||||
MaxFamilyClients int
|
||||
NeededTalkPower int
|
||||
IconID int64
|
||||
IsPermanent bool
|
||||
IsSemiPermanent bool
|
||||
IsDefault bool
|
||||
IsPassword bool
|
||||
Order uint64
|
||||
BannerGfxURL string // channel_banner_gfx_url
|
||||
}
|
||||
|
||||
// ClientDetailInfo holds detailed client properties from clientinfo clid=X.
|
||||
type ClientDetailInfo struct {
|
||||
ID uint16 // clid
|
||||
Nickname string
|
||||
UID string // client_unique_identifier
|
||||
ChannelID uint64
|
||||
Type int
|
||||
ServerGroups []string
|
||||
Away bool // client_away
|
||||
AwayMessage string // client_away_message
|
||||
InputMuted bool // client_input_muted
|
||||
OutputMuted bool // client_output_muted
|
||||
Platform string // client_platform
|
||||
Version string // client_version
|
||||
IP string // connection_client_ip
|
||||
Created int64 // client_created (unix timestamp)
|
||||
LastConnected int64 // client_lastconnected
|
||||
TotalConnections int // client_totalconnections
|
||||
Description string // client_description
|
||||
IconID int64 // client_icon_id
|
||||
}
|
||||
|
||||
// DBClient holds one row from clientdblist.
|
||||
type DBClient struct {
|
||||
DBID uint64 // cldbid
|
||||
UID string // client_unique_identifier
|
||||
Nickname string // client_nickname
|
||||
Created int64 // client_created (unix timestamp)
|
||||
LastConnected int64 // client_lastconnected
|
||||
TotalConnections int // client_totalconnections
|
||||
Description string // client_description
|
||||
}
|
||||
|
||||
// ─── 管理(Ban / 频道管理 / 文件列表)───────────────────────
|
||||
|
||||
// BanEntry holds one row from banlist.
|
||||
type BanEntry struct {
|
||||
BanID int64 // banid
|
||||
IP string // ip (可能为空或部分掩码)
|
||||
Name string // name
|
||||
UID string // uid
|
||||
Created int64 // created (unix timestamp)
|
||||
InvokerName string // invokername
|
||||
InvokerUID string // invokeruid
|
||||
Reason string // reason
|
||||
Enforcement bool // enforcements
|
||||
}
|
||||
|
||||
// FileEntry holds one row from ftgetfilelist.
|
||||
type FileEntry struct {
|
||||
Name string // name
|
||||
Size uint64 // size (字节, 目录为 0)
|
||||
DateTime int64 // datetime (unix timestamp)
|
||||
IsFile bool // is_file (false = 目录)
|
||||
}
|
||||
|
||||
// TokenEntry holds one row from tokenlist.
|
||||
type TokenEntry struct {
|
||||
Token string // token
|
||||
TokenType int // token_type (0=服务器组, 1=频道组)
|
||||
TokenID1 int64 // token_id1 (group ID)
|
||||
TokenID2 int64 // token_id2 (channel ID, 仅 token_type=1)
|
||||
Created int64 // token_created
|
||||
Description string // token_description
|
||||
}
|
||||
|
||||
// ComplaintEntry holds one row from complainlist.
|
||||
type ComplaintEntry struct {
|
||||
FromDBID uint64 // fcldbid
|
||||
ToDBID uint64 // tcldbid
|
||||
Message string // message
|
||||
Timestamp int64 // timestamp (unix timestamp)
|
||||
}
|
||||
Reference in New Issue
Block a user