首次推送
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)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions EnableDelayedExpansion
|
||||
|
||||
rem Builds vendored libopus for each gomobile Android ABI as static archives.
|
||||
set ROOT=%~dp0..
|
||||
set OPUS_SOURCE=%ROOT%\android\app\src\main\cpp\third_party\opus
|
||||
set OUTPUT=%ROOT%\go\teamspeak\.opus\lib
|
||||
|
||||
if not "%ANDROID_HOME%"=="" (
|
||||
set SDK_ROOT=%ANDROID_HOME%
|
||||
) else if not "%ANDROID_SDK_ROOT%"=="" (
|
||||
set SDK_ROOT=%ANDROID_SDK_ROOT%
|
||||
) else (
|
||||
for /f "tokens=2 delims==" %%A in ('findstr /B "sdk.dir=" "%ROOT%\android\local.properties"') do set SDK_ROOT=%%A
|
||||
set SDK_ROOT=%SDK_ROOT:\=\%
|
||||
)
|
||||
if "%SDK_ROOT%"=="" (
|
||||
echo ANDROID_HOME or ANDROID_SDK_ROOT must point to an Android SDK.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if not "%ANDROID_NDK_HOME%"=="" (
|
||||
set NDK=%ANDROID_NDK_HOME%
|
||||
) else if not "%ANDROID_NDK_ROOT%"=="" (
|
||||
set NDK=%ANDROID_NDK_ROOT%
|
||||
) else (
|
||||
for /f "delims=" %%A in ('dir /B /AD /O-N "%SDK_ROOT%\ndk"') do if not defined NDK set NDK=%SDK_ROOT%\ndk\%%A
|
||||
)
|
||||
if not exist "%NDK%\build\cmake\android.toolchain.cmake" (
|
||||
echo Android NDK not found under "%SDK_ROOT%\ndk".
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set CMAKE=%SDK_ROOT%\cmake\3.22.1\bin\cmake.exe
|
||||
if not exist "%CMAKE%" set CMAKE=cmake
|
||||
for %%A in ("%CMAKE%") do set NINJA=%%~dpAninja.exe
|
||||
if not exist "%NINJA%" set NINJA=ninja
|
||||
|
||||
for %%A in (armeabi-v7a arm64-v8a x86 x86_64) do (
|
||||
set BUILD=%ROOT%\go\teamspeak\.opus\build\%%A
|
||||
set INSTALL=%ROOT%\go\teamspeak\.opus\install\%%A
|
||||
call "%CMAKE%" -S "%OPUS_SOURCE%" -B "!BUILD!" -G Ninja -DCMAKE_MAKE_PROGRAM="%NINJA%" -DCMAKE_TOOLCHAIN_FILE="%NDK%\build\cmake\android.toolchain.cmake" -DANDROID_ABI=%%A -DANDROID_PLATFORM=android-26 -DCMAKE_BUILD_TYPE=Release -DOPUS_BUILD_SHARED_LIBRARY=OFF -DOPUS_BUILD_PROGRAMS=OFF -DOPUS_BUILD_TESTING=OFF -DOPUS_INSTALL_PKG_CONFIG_MODULE=OFF -DOPUS_INSTALL_CMAKE_CONFIG_MODULE=OFF -DCMAKE_INSTALL_PREFIX="!INSTALL!" || exit /b 1
|
||||
call "%CMAKE%" --build "!BUILD!" --config Release || exit /b 1
|
||||
call "%CMAKE%" --install "!BUILD!" --config Release || exit /b 1
|
||||
if not exist "%OUTPUT%\%%A" mkdir "%OUTPUT%\%%A"
|
||||
copy /Y "!INSTALL!\lib\libopus.a" "%OUTPUT%\%%A\libopus.a" >nul || exit /b 1
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Builds the vendored libopus source for every ABI gomobile supports and puts
|
||||
# static archives where voice_decoder_factory_android.go links them. This is a
|
||||
# build-time dependency only; no native library needs to be loaded separately.
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
OPUS_SOURCE="$ROOT/android/app/src/main/cpp/third_party/opus"
|
||||
OUTPUT="$ROOT/go/teamspeak/.opus/lib"
|
||||
SDK_ROOT="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
|
||||
|
||||
if [[ -z "$SDK_ROOT" && -f "$ROOT/android/local.properties" ]]; then
|
||||
SDK_ROOT="$(sed -n 's#^sdk.dir=##p' "$ROOT/android/local.properties" | sed 's#\\\\#/#g')"
|
||||
fi
|
||||
if [[ -z "$SDK_ROOT" ]]; then
|
||||
echo "ANDROID_HOME or ANDROID_SDK_ROOT must point to an Android SDK" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "${ANDROID_NDK_HOME:-}" ]]; then
|
||||
NDK="$ANDROID_NDK_HOME"
|
||||
elif [[ -n "${ANDROID_NDK_ROOT:-}" ]]; then
|
||||
NDK="$ANDROID_NDK_ROOT"
|
||||
else
|
||||
NDK="$(ls -d "$SDK_ROOT"/ndk/* 2>/dev/null | sort -V | tail -n 1 || true)"
|
||||
fi
|
||||
if [[ -z "$NDK" || ! -d "$NDK" ]]; then
|
||||
echo "Android NDK not found under $SDK_ROOT/ndk" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CMAKE="${CMAKE:-$SDK_ROOT/cmake/3.22.1/bin/cmake}"
|
||||
if [[ ! -x "$CMAKE" ]]; then
|
||||
CMAKE="cmake"
|
||||
fi
|
||||
NINJA="$(dirname "$CMAKE")/ninja.exe"
|
||||
if [[ ! -x "$NINJA" ]]; then
|
||||
NINJA="ninja"
|
||||
fi
|
||||
|
||||
for abi in armeabi-v7a arm64-v8a x86 x86_64; do
|
||||
build="$ROOT/go/teamspeak/.opus/build/$abi"
|
||||
install="$ROOT/go/teamspeak/.opus/install/$abi"
|
||||
"$CMAKE" -S "$OPUS_SOURCE" -B "$build" -G Ninja -DCMAKE_MAKE_PROGRAM="$NINJA" \
|
||||
-DCMAKE_TOOLCHAIN_FILE="$NDK/build/cmake/android.toolchain.cmake" \
|
||||
-DANDROID_ABI="$abi" -DANDROID_PLATFORM=android-26 \
|
||||
-DCMAKE_BUILD_TYPE=Release -DOPUS_BUILD_SHARED_LIBRARY=OFF \
|
||||
-DOPUS_BUILD_PROGRAMS=OFF -DOPUS_BUILD_TESTING=OFF \
|
||||
-DOPUS_INSTALL_PKG_CONFIG_MODULE=OFF -DOPUS_INSTALL_CMAKE_CONFIG_MODULE=OFF \
|
||||
-DCMAKE_INSTALL_PREFIX="$install"
|
||||
"$CMAKE" --build "$build" --config Release
|
||||
"$CMAKE" --install "$build" --config Release
|
||||
mkdir -p "$OUTPUT/$abi"
|
||||
cp "$install/lib/libopus.a" "$OUTPUT/$abi/libopus.a"
|
||||
done
|
||||
@@ -0,0 +1,20 @@
|
||||
module tsmobile
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require github.com/honeybbq/teamspeak-go v0.2.0
|
||||
|
||||
require (
|
||||
github.com/oasisprotocol/curve25519-voi v0.0.0-20251114093237-2ab5a27a1729 // indirect
|
||||
github.com/tink-crypto/tink-go/v2 v2.6.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5 // indirect
|
||||
golang.org/x/mod v0.38.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
)
|
||||
|
||||
tool golang.org/x/mobile/cmd/gobind
|
||||
|
||||
replace github.com/honeybbq/teamspeak-go => ./_patches/github.com/honeybbq/teamspeak-go
|
||||
@@ -0,0 +1,24 @@
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
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/mobile v0.0.0-20260709172247-6129f5bee9d5 h1:Mn1OzFmF0ZKX/ZayHz/UdnWHufPp1wlD9lZ5U8LRDFY=
|
||||
golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5/go.mod h1:YX+n47s+53POxN3dx9cIGxG3hGUm/lD64hvrRJFbcSA=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
|
||||
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
|
||||
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
Binary file not shown.
@@ -0,0 +1,148 @@
|
||||
# ninja log v5
|
||||
1 567 8060178312333911 CMakeFiles/opus.dir/src/opus_encoder.c.o 7334019e34b0d0ee
|
||||
1646 2202 8060178324552392 CMakeFiles/opus.dir/silk/inner_prod_aligned.c.o fe717bb5f6a4c88c
|
||||
174 746 8060178310692677 CMakeFiles/opus.dir/src/repacketizer.c.o 2f1d9180fb22e7dc
|
||||
25 594 8060178308974789 CMakeFiles/opus.dir/src/extensions.c.o cdb2630ea562c274
|
||||
542 1110 8060178313594946 CMakeFiles/opus.dir/silk/decoder_set_fs.c.o 939b400a78cbec50
|
||||
48 621 8060178311106444 CMakeFiles/opus.dir/src/opus_multistream_encoder.c.o e82d38a58ae86f53
|
||||
466 1027 8060178312960736 CMakeFiles/opus.dir/silk/decode_pulses.c.o 6ec85f3457da1ba8
|
||||
148 719 8060178310143249 CMakeFiles/opus.dir/src/opus_projection_encoder.c.o 2e07ffa4f5efa99
|
||||
98 669 8060178309301738 CMakeFiles/opus.dir/src/opus_multistream.c.o 188791cb7ff471fc
|
||||
74 645 8060178309724216 CMakeFiles/opus.dir/src/opus_multistream_decoder.c.o c182378fd3b5a89e
|
||||
1392 1941 8060178322065033 CMakeFiles/opus.dir/silk/init_encoder.c.o f05657b2fa99d97d
|
||||
440 1001 8060178312600945 CMakeFiles/opus.dir/silk/decode_frame.c.o 60d3a48a22283c86
|
||||
123 694 8060178310016862 CMakeFiles/opus.dir/src/opus_projection_decoder.c.o 1a03b821c5ba0e02
|
||||
720 1294 8060178315444215 CMakeFiles/opus.dir/silk/gain_quant.c.o 77050b8d0b01fde6
|
||||
199 770 8060178310672776 CMakeFiles/opus.dir/src/mapping_matrix.c.o 54afcf0c20f1b95b
|
||||
974 1542 8060178317862750 CMakeFiles/opus.dir/silk/tables_pulses_per_block.c.o 17ffd3eec46866ae
|
||||
226 796 8060178310340543 CMakeFiles/opus.dir/src/mlp_data.c.o ff793f0c3b8c6a2e
|
||||
283 846 8060178311499340 CMakeFiles/opus.dir/src/mlp.c.o ce1e823d5ad623f4
|
||||
256 823 8060178313012750 CMakeFiles/opus.dir/src/analysis.c.o 898c65bc3cc721f3
|
||||
2971 3891 8060178337950173 CMakeFiles/opus.dir/silk/float/sort_FLP.c.o d6117b9068ca4823
|
||||
310 871 8060178311509583 CMakeFiles/opus.dir/silk/code_signs.c.o 3b84c49138ae4faf
|
||||
1111 1671 8060178319364664 CMakeFiles/opus.dir/silk/HP_variable_cutoff.c.o 7de7a0607884c2e9
|
||||
335 896 8060178311529858 CMakeFiles/opus.dir/silk/init_decoder.c.o ca42965a7b53f3be
|
||||
897 1467 8060178317051651 CMakeFiles/opus.dir/silk/tables_NLSF_CB_NB_MB.c.o 497d874edb02d04f
|
||||
669 1244 8060178315353553 CMakeFiles/opus.dir/silk/encode_pulses.c.o 37f0b0687a46ceb3
|
||||
364 922 8060178312253528 CMakeFiles/opus.dir/silk/CNG.c.o c751320ac875aa37
|
||||
1139 1695 8060178319701143 CMakeFiles/opus.dir/silk/quant_LTP_gains.c.o 7efe4d869d9bcfff
|
||||
1193 1744 8060178320164632 CMakeFiles/opus.dir/silk/process_NLSFs.c.o addaf662d95a0245
|
||||
387 948 8060178312133650 CMakeFiles/opus.dir/silk/decode_parameters.c.o 2e2525b2036b18b3
|
||||
695 1268 8060178315183572 CMakeFiles/opus.dir/silk/encode_indices.c.o debcbaad9c7566c
|
||||
2638 3352 8060178334585586 CMakeFiles/opus.dir/silk/float/find_LTP_FLP.c.o 7f4e674c3afd127b
|
||||
412 974 8060178312990145 CMakeFiles/opus.dir/silk/decode_core.c.o 30f1687094bfe8cd
|
||||
491 1055 8060178313171259 CMakeFiles/opus.dir/silk/decode_indices.c.o 9fe5e13689624ee4
|
||||
1367 1916 8060178321793736 CMakeFiles/opus.dir/silk/control_SNR.c.o 1c37a1f5251122b9
|
||||
518 1083 8060178313382564 CMakeFiles/opus.dir/silk/NLSF_decode.c.o 9f1feabee547c1fc
|
||||
2867 3727 8060178338262006 CMakeFiles/opus.dir/celt/kiss_fft.c.o 86c190a032dfd3c6
|
||||
1843 2403 8060178326712557 CMakeFiles/opus.dir/silk/NLSF2A.c.o 49e4fddb34f5518e
|
||||
1571 2122 8060178323834200 CMakeFiles/opus.dir/silk/decode_pitch.c.o d6cced4c812a2cc8
|
||||
569 1138 8060178315008170 CMakeFiles/opus.dir/silk/enc_API.c.o b4e265eb0aa75cca
|
||||
3686 4265 8060178345064271 CMakeFiles/opus.dir/silk/float/k2a_FLP.c.o 54ab4d6698af5d8
|
||||
594 1164 8060178314761423 CMakeFiles/opus.dir/silk/dec_API.c.o 5e6e344e864f6076
|
||||
621 1193 8060178314332550 CMakeFiles/opus.dir/silk/interpolate.c.o dbebc1284f35a96
|
||||
645 1219 8060178314636500 CMakeFiles/opus.dir/silk/LP_variable_cutoff.c.o 3f1fb9848c24692a
|
||||
3227 4168 8060178341085711 CMakeFiles/opus.dir/silk/float/noise_shape_analysis_FLP.c.o e0468a3580ec5115
|
||||
1268 1818 8060178321102887 CMakeFiles/opus.dir/silk/NLSF_del_dec_quant.c.o 60ebae195f42ee8c
|
||||
746 1318 8060178315530302 CMakeFiles/opus.dir/silk/tables_gain.c.o e4526a0162a4196f
|
||||
770 1342 8060178316735356 CMakeFiles/opus.dir/silk/NSQ.c.o e1458c749667c973
|
||||
797 1367 8060178316243956 CMakeFiles/opus.dir/silk/shell_coder.c.o 1dc86b43ca555195
|
||||
823 1392 8060178317842769 CMakeFiles/opus.dir/silk/NSQ_del_dec.c.o e44d8b8986df957d
|
||||
871 1442 8060178316800353 CMakeFiles/opus.dir/silk/tables_pitch_lag.c.o 6ec29395594f8ec
|
||||
847 1416 8060178317399749 CMakeFiles/opus.dir/silk/PLC.c.o 997988d0eed008c3
|
||||
922 1493 8060178317313574 CMakeFiles/opus.dir/silk/tables_other.c.o 342155a8eb0a826a
|
||||
1083 1646 8060178319162565 CMakeFiles/opus.dir/silk/VQ_WMat_EC.c.o 4b1b179d2178b369
|
||||
948 1517 8060178317621463 CMakeFiles/opus.dir/silk/tables_NLSF_CB_WB.c.o 28d304a459269c5b
|
||||
1493 2041 8060178323023467 CMakeFiles/opus.dir/silk/bwexpander_32.c.o 94b0aa14d830ac9b
|
||||
1002 1571 8060178318154660 CMakeFiles/opus.dir/silk/tables_LTP.c.o 67691bd4135bd253
|
||||
1028 1596 8060178319147518 CMakeFiles/opus.dir/silk/VAD.c.o 532230768284b55c
|
||||
1055 1621 8060178318845528 CMakeFiles/opus.dir/silk/control_audio_bandwidth.c.o d296f28341e31a51
|
||||
1164 1719 8060178320029142 CMakeFiles/opus.dir/silk/NLSF_encode.c.o e9facb34daeae3c5
|
||||
2328 2893 8060178331774876 CMakeFiles/opus.dir/src/opus.c.o 4547269f5e05ff2f
|
||||
1219 1768 8060178320325127 CMakeFiles/opus.dir/silk/NLSF_unpack.c.o 595fc366f11f8ab3
|
||||
1244 1794 8060178320581686 CMakeFiles/opus.dir/silk/NLSF_VQ.c.o 46ff4bf2b48fdbf5
|
||||
1294 1842 8060178321263185 CMakeFiles/opus.dir/silk/stereo_MS_to_LR.c.o ad03d6fcc28416dd
|
||||
1318 1867 8060178321864025 CMakeFiles/opus.dir/silk/stereo_LR_to_MS.c.o c03955267f99752a
|
||||
1343 1891 8060178321563538 CMakeFiles/opus.dir/silk/check_control_input.c.o 847f90c609426a62
|
||||
1597 2150 8060178324031002 CMakeFiles/opus.dir/silk/lin2log.c.o f98af146ccb975de
|
||||
1417 1966 8060178322994279 CMakeFiles/opus.dir/silk/A2NLSF.c.o 3f4d6aa04ccba757
|
||||
1442 1990 8060178322904250 CMakeFiles/opus.dir/silk/control_codec.c.o 55481cfeee1d63d3
|
||||
1468 2016 8060178322884303 CMakeFiles/opus.dir/silk/biquad_alt.c.o a668b0011cd6ba1f
|
||||
1517 2067 8060178323272829 CMakeFiles/opus.dir/silk/bwexpander.c.o c677bd69628976ec
|
||||
1543 2094 8060178323573577 CMakeFiles/opus.dir/silk/ana_filt_bank_1.c.o 1815d195d01e6866
|
||||
1621 2176 8060178324271558 CMakeFiles/opus.dir/silk/log2lin.c.o 1c52e59197ccec08
|
||||
1671 2227 8060178324532350 CMakeFiles/opus.dir/silk/debug.c.o 37b9f76e73b8b29d
|
||||
1768 2328 8060178325914481 CMakeFiles/opus.dir/silk/NLSF_stabilize.c.o 63e27ded427b5b79
|
||||
1695 2253 8060178325214693 CMakeFiles/opus.dir/silk/LPC_analysis_filter.c.o f064181a3667c6ec
|
||||
3602 4263 8060178344263704 CMakeFiles/opus.dir/silk/float/energy_FLP.c.o ef209c71636e68ff
|
||||
1720 2279 8060178325551045 CMakeFiles/opus.dir/silk/LPC_inv_pred_gain.c.o 9a417474d59a6cba
|
||||
1744 2303 8060178325480896 CMakeFiles/opus.dir/silk/table_LSF_cos.c.o ede65ed370ea9536
|
||||
1794 2353 8060178325964294 CMakeFiles/opus.dir/silk/pitch_est_tables.c.o 5cf190ab65b376a3
|
||||
1818 2378 8060178326274774 CMakeFiles/opus.dir/silk/NLSF_VQ_weights_laroia.c.o 326a9230199a3ccd
|
||||
1867 2428 8060178326842636 CMakeFiles/opus.dir/silk/resampler.c.o b1ac1f46bb57b24a
|
||||
1891 2453 8060178327044084 CMakeFiles/opus.dir/silk/resampler_down2_3.c.o ca7458e6a7b9f560
|
||||
1916 2479 8060178327226436 CMakeFiles/opus.dir/silk/resampler_private_AR2.c.o 85aa9e208bb088bd
|
||||
1941 2505 8060178327513537 CMakeFiles/opus.dir/silk/resampler_down2.c.o 33e316e09fe39c60
|
||||
1966 2533 8060178328102697 CMakeFiles/opus.dir/silk/resampler_private_down_FIR.c.o 53d0e8dc077a591f
|
||||
1990 2561 8060178328112732 CMakeFiles/opus.dir/silk/resampler_private_IIR_FIR.c.o b6d8d976a17df165
|
||||
2016 2588 8060178328735362 CMakeFiles/opus.dir/celt/celt.c.o 41096db83f0c5d0
|
||||
2041 2612 8060178331644036 CMakeFiles/opus.dir/celt/bands.c.o 73e5dd7bdf5f5826
|
||||
2067 2637 8060178329011099 CMakeFiles/opus.dir/silk/LPC_fit.c.o 7fe90686de65937d
|
||||
2094 2666 8060178329222074 CMakeFiles/opus.dir/silk/stereo_quant_pred.c.o 3a2df6f2d17f639c
|
||||
2122 2693 8060178329515463 CMakeFiles/opus.dir/silk/stereo_find_predictor.c.o aec71ffdb602a69a
|
||||
2150 2718 8060178329701873 CMakeFiles/opus.dir/silk/sort.c.o d61518d689124c71
|
||||
2480 3088 8060178334003049 CMakeFiles/opus.dir/celt/quant_bands.c.o 7f621ae15555683b
|
||||
2177 2743 8060178329852570 CMakeFiles/opus.dir/silk/sigm_Q15.c.o 9c7372545e8a85e2
|
||||
2202 2767 8060178330210943 CMakeFiles/opus.dir/silk/resampler_private_up2_HQ.c.o 71bb203f012debab
|
||||
2227 2793 8060178330322385 CMakeFiles/opus.dir/silk/resampler_rom.c.o 9031e896fa811e5b
|
||||
2254 2817 8060178330693844 CMakeFiles/opus.dir/silk/sum_sqr_shift.c.o 1c096936e3d02403
|
||||
2279 2842 8060178330904909 CMakeFiles/opus.dir/silk/stereo_encode_pred.c.o 1a5017427812ef45
|
||||
2304 2867 8060178331181310 CMakeFiles/opus.dir/silk/stereo_decode_pred.c.o cef6923bba7b6aaa
|
||||
2353 2919 8060178333245793 CMakeFiles/opus.dir/src/opus_decoder.c.o a229e41358adc695
|
||||
2378 2944 8060178332842048 CMakeFiles/opus.dir/celt/vq.c.o c60125861663133a
|
||||
2403 2971 8060178333092398 CMakeFiles/opus.dir/celt/celt_lpc.c.o 6cfdb595104cf06c
|
||||
4587 4766 8060178354345351 libopus.a 9d24d521f1963680
|
||||
2428 2998 8060178332464150 CMakeFiles/opus.dir/silk/float/apply_sine_window_FLP.c.o 39b59b9c5272c051
|
||||
2842 3686 8060178337334702 CMakeFiles/opus.dir/celt/entenc.c.o ed4e771efa684593
|
||||
2454 3041 8060178333690864 CMakeFiles/opus.dir/celt/rate.c.o 72c5db4ae06922b1
|
||||
3138 4058 8060178339592262 CMakeFiles/opus.dir/celt/arm/arm_celt_map.c.o dc7c66beda86867e
|
||||
2506 3138 8060178333361251 CMakeFiles/opus.dir/silk/float/corrMatrix_FLP.c.o 253b376fbffbeca5
|
||||
2534 3186 8060178334253770 CMakeFiles/opus.dir/silk/float/encode_frame_FLP.c.o 1de3a9842b8b8526
|
||||
2561 3226 8060178333872570 CMakeFiles/opus.dir/silk/float/find_LPC_FLP.c.o d5cdcc9d98c31cb8
|
||||
2588 3270 8060178334143465 CMakeFiles/opus.dir/silk/float/find_pitch_lags_FLP.c.o 9e0f53dad13157b9
|
||||
2612 3311 8060178334384740 CMakeFiles/opus.dir/silk/float/find_pred_coefs_FLP.c.o 73ff8e2fa8f28f52
|
||||
2666 3392 8060178334984888 CMakeFiles/opus.dir/celt/cwrs.c.o 8667488d07fbcdc0
|
||||
2693 3436 8060178335402405 CMakeFiles/opus.dir/celt/entdec.c.o e425ffb08d6e6ae9
|
||||
2719 3477 8060178338292770 CMakeFiles/opus.dir/celt/celt_decoder.c.o 4ffea245154777b7
|
||||
2743 3518 8060178340483732 CMakeFiles/opus.dir/celt/celt_encoder.c.o de1db46d32f64c4d
|
||||
2768 3559 8060178335624101 CMakeFiles/opus.dir/celt/entcode.c.o a790e2ff13d1f908
|
||||
2793 3601 8060178336240475 CMakeFiles/opus.dir/celt/laplace.c.o 655f635d2b2dad68
|
||||
2818 3644 8060178336330975 CMakeFiles/opus.dir/celt/mathops.c.o 53b69f2364eb73b3
|
||||
2893 3768 8060178337395053 CMakeFiles/opus.dir/celt/mdct.c.o 1f4662dfa472c5c6
|
||||
2919 3810 8060178338342717 CMakeFiles/opus.dir/celt/pitch.c.o 976ffd0dc45af6cc
|
||||
2944 3851 8060178337691638 CMakeFiles/opus.dir/celt/modes.c.o deb23cae3ce83043
|
||||
2999 3934 8060178338322686 CMakeFiles/opus.dir/celt/arm/armcpu.c.o b5d7b28c7af163fd
|
||||
3041 3974 8060178338772527 CMakeFiles/opus.dir/silk/float/scale_vector_FLP.c.o d17fa52401551171
|
||||
4169 4587 8060178353062614 CMakeFiles/opus.dir/silk/arm/NSQ_del_dec_neon_intr.c.o 2a226889e013e24c
|
||||
3088 4018 8060178339261426 CMakeFiles/opus.dir/silk/float/schur_FLP.c.o 196fb8d9398104d6
|
||||
3186 4125 8060178340231394 CMakeFiles/opus.dir/silk/float/process_gains_FLP.c.o c3a05b92aca87324
|
||||
3271 4210 8060178340975415 CMakeFiles/opus.dir/silk/float/LTP_analysis_filter_FLP.c.o 54f5bbe8300210b4
|
||||
3312 4253 8060178341310814 CMakeFiles/opus.dir/silk/float/LTP_scale_ctrl_FLP.c.o a3f1f2286b04b61f
|
||||
3353 4255 8060178342111640 CMakeFiles/opus.dir/silk/float/LPC_analysis_filter_FLP.c.o 228ec12f56305937
|
||||
3393 4256 8060178342282739 CMakeFiles/opus.dir/silk/float/residual_energy_FLP.c.o eb545dc225db84c6
|
||||
3436 4257 8060178343071754 CMakeFiles/opus.dir/silk/float/wrappers_FLP.c.o 7264d6a45b2c2485
|
||||
3478 4259 8060178342980964 CMakeFiles/opus.dir/silk/float/regularize_correlations_FLP.c.o 6a3fac96ea6f5b74
|
||||
3519 4260 8060178343474304 CMakeFiles/opus.dir/silk/float/warped_autocorrelation_FLP.c.o dff9d11aa1b45572
|
||||
3560 4261 8060178343781636 CMakeFiles/opus.dir/silk/float/autocorrelation_FLP.c.o fb93b2b08f94fd72
|
||||
3644 4264 8060178345064271 CMakeFiles/opus.dir/silk/float/burg_modified_FLP.c.o ca3c3ea22828fc1f
|
||||
3728 4267 8060178345512470 CMakeFiles/opus.dir/silk/float/inner_product_FLP.c.o f7a5e5ae848a42b
|
||||
3768 4268 8060178345860838 CMakeFiles/opus.dir/silk/float/bwexpander_FLP.c.o 44865536933d97f5
|
||||
3810 4269 8060178346314358 CMakeFiles/opus.dir/silk/float/LPC_inv_pred_gain_FLP.c.o 848f6af980050476
|
||||
3851 4271 8060178348412294 CMakeFiles/opus.dir/silk/float/pitch_analysis_core_FLP.c.o 2778318011eda48e
|
||||
3892 4272 8060178347160595 CMakeFiles/opus.dir/silk/float/scale_copy_vector_FLP.c.o f96f0a4ef249b324
|
||||
3934 4273 8060178348705270 CMakeFiles/opus.dir/celt/arm/pitch_neon_intr.c.o 91b118319dd53062
|
||||
3975 4274 8060178348821282 CMakeFiles/opus.dir/celt/arm/celt_neon_intr.c.o c91c33c147ac876a
|
||||
4019 4275 8060178349073340 CMakeFiles/opus.dir/silk/arm/biquad_alt_neon_intr.c.o 3fafe906977f036c
|
||||
4059 4276 8060178349032837 CMakeFiles/opus.dir/silk/arm/arm_silk_map.c.o 1776d1cc628a2a57
|
||||
4126 4319 8060178350424213 CMakeFiles/opus.dir/silk/arm/LPC_inv_pred_gain_neon_intr.c.o 16ac2cd63ac781d0
|
||||
4211 4366 8060178350894833 CMakeFiles/opus.dir/silk/arm/NSQ_neon.c.o 3e947fb73bffead1
|
||||
@@ -0,0 +1,550 @@
|
||||
# This is the CMakeCache file.
|
||||
# For build in directory: e:/MyProject/ts-mobile-go/go/teamspeak/.opus/build/arm64-v8a
|
||||
# It was generated by CMake: D:/Android/sdk/cmake/3.22.1/bin/cmake.exe
|
||||
# You can edit this file to change values found and used by cmake.
|
||||
# If you do not want to change any of the values, simply exit the editor.
|
||||
# If you do want to change a value, simply edit, save, and exit the editor.
|
||||
# The syntax for the file is as follows:
|
||||
# KEY:TYPE=VALUE
|
||||
# KEY is the name of a variable in the cache.
|
||||
# TYPE is a hint to GUIs for the type of VALUE, DO NOT EDIT TYPE!.
|
||||
# VALUE is the current value for the KEY.
|
||||
|
||||
########################
|
||||
# EXTERNAL cache entries
|
||||
########################
|
||||
|
||||
//No help, variable specified on the command line.
|
||||
ANDROID_ABI:UNINITIALIZED=arm64-v8a
|
||||
|
||||
//No help, variable specified on the command line.
|
||||
ANDROID_PLATFORM:UNINITIALIZED=android-26
|
||||
|
||||
//Path to a program.
|
||||
CMAKE_ADDR2LINE:FILEPATH=D:/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/windows-x86_64/bin/llvm-addr2line.exe
|
||||
|
||||
//Archiver
|
||||
CMAKE_AR:FILEPATH=D:/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/windows-x86_64/bin/llvm-ar.exe
|
||||
|
||||
//Flags used by the compiler during all build types.
|
||||
CMAKE_ASM_FLAGS:STRING=
|
||||
|
||||
//Flags used by the compiler during debug builds.
|
||||
CMAKE_ASM_FLAGS_DEBUG:STRING=
|
||||
|
||||
//Flags used by the compiler during release builds.
|
||||
CMAKE_ASM_FLAGS_RELEASE:STRING=
|
||||
|
||||
//Choose the type of build, options are: None Debug Release RelWithDebInfo
|
||||
// MinSizeRel ...
|
||||
CMAKE_BUILD_TYPE:STRING=Release
|
||||
|
||||
//Flags used by the compiler during all build types.
|
||||
CMAKE_CXX_FLAGS:STRING=
|
||||
|
||||
//Flags used by the compiler during debug builds.
|
||||
CMAKE_CXX_FLAGS_DEBUG:STRING=
|
||||
|
||||
//Flags used by the compiler during release builds.
|
||||
CMAKE_CXX_FLAGS_RELEASE:STRING=
|
||||
|
||||
//LLVM archiver
|
||||
CMAKE_C_COMPILER_AR:FILEPATH=D:/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/windows-x86_64/bin/llvm-ar.exe
|
||||
|
||||
//Generate index for LLVM archive
|
||||
CMAKE_C_COMPILER_RANLIB:FILEPATH=D:/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/windows-x86_64/bin/llvm-ranlib.exe
|
||||
|
||||
//Flags used by the compiler during all build types.
|
||||
CMAKE_C_FLAGS:STRING=
|
||||
|
||||
//Flags used by the compiler during debug builds.
|
||||
CMAKE_C_FLAGS_DEBUG:STRING=
|
||||
|
||||
//Flags used by the C compiler during MINSIZEREL builds.
|
||||
CMAKE_C_FLAGS_MINSIZEREL:STRING=-Os -DNDEBUG
|
||||
|
||||
//Flags used by the compiler during release builds.
|
||||
CMAKE_C_FLAGS_RELEASE:STRING=
|
||||
|
||||
//Flags used by the C compiler during RELWITHDEBINFO builds.
|
||||
CMAKE_C_FLAGS_RELWITHDEBINFO:STRING=-O2 -g -DNDEBUG
|
||||
|
||||
//Libraries linked by default with all C applications.
|
||||
CMAKE_C_STANDARD_LIBRARIES:STRING=-latomic -lm
|
||||
|
||||
//Path to a program.
|
||||
CMAKE_DLLTOOL:FILEPATH=D:/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/windows-x86_64/bin/llvm-dlltool.exe
|
||||
|
||||
//Flags used by the linker.
|
||||
CMAKE_EXE_LINKER_FLAGS:STRING=
|
||||
|
||||
//Flags used by the linker during DEBUG builds.
|
||||
CMAKE_EXE_LINKER_FLAGS_DEBUG:STRING=
|
||||
|
||||
//Flags used by the linker during MINSIZEREL builds.
|
||||
CMAKE_EXE_LINKER_FLAGS_MINSIZEREL:STRING=
|
||||
|
||||
//Flags used by the linker during RELEASE builds.
|
||||
CMAKE_EXE_LINKER_FLAGS_RELEASE:STRING=
|
||||
|
||||
//Flags used by the linker during RELWITHDEBINFO builds.
|
||||
CMAKE_EXE_LINKER_FLAGS_RELWITHDEBINFO:STRING=
|
||||
|
||||
//Enable/Disable output of compile commands during generation.
|
||||
CMAKE_EXPORT_COMPILE_COMMANDS:BOOL=
|
||||
|
||||
//User executables (bin)
|
||||
CMAKE_INSTALL_BINDIR:PATH=bin
|
||||
|
||||
//Read-only architecture-independent data (DATAROOTDIR)
|
||||
CMAKE_INSTALL_DATADIR:PATH=
|
||||
|
||||
//Read-only architecture-independent data root (share)
|
||||
CMAKE_INSTALL_DATAROOTDIR:PATH=share
|
||||
|
||||
//Documentation root (DATAROOTDIR/doc/PROJECT_NAME)
|
||||
CMAKE_INSTALL_DOCDIR:PATH=
|
||||
|
||||
//C header files (include)
|
||||
CMAKE_INSTALL_INCLUDEDIR:PATH=include
|
||||
|
||||
//Info documentation (DATAROOTDIR/info)
|
||||
CMAKE_INSTALL_INFODIR:PATH=
|
||||
|
||||
//Object code libraries (lib)
|
||||
CMAKE_INSTALL_LIBDIR:PATH=lib
|
||||
|
||||
//Program executables (libexec)
|
||||
CMAKE_INSTALL_LIBEXECDIR:PATH=libexec
|
||||
|
||||
//Locale-dependent data (DATAROOTDIR/locale)
|
||||
CMAKE_INSTALL_LOCALEDIR:PATH=
|
||||
|
||||
//Modifiable single-machine data (var)
|
||||
CMAKE_INSTALL_LOCALSTATEDIR:PATH=var
|
||||
|
||||
//Man documentation (DATAROOTDIR/man)
|
||||
CMAKE_INSTALL_MANDIR:PATH=
|
||||
|
||||
//C header files for non-gcc (/usr/include)
|
||||
CMAKE_INSTALL_OLDINCLUDEDIR:PATH=/usr/include
|
||||
|
||||
//Install path prefix, prepended onto install directories.
|
||||
CMAKE_INSTALL_PREFIX:PATH=E:/MyProject/ts-mobile-go/go/teamspeak/.opus/install/arm64-v8a
|
||||
|
||||
//Run-time variable data (LOCALSTATEDIR/run)
|
||||
CMAKE_INSTALL_RUNSTATEDIR:PATH=
|
||||
|
||||
//System admin executables (sbin)
|
||||
CMAKE_INSTALL_SBINDIR:PATH=sbin
|
||||
|
||||
//Modifiable architecture-independent data (com)
|
||||
CMAKE_INSTALL_SHAREDSTATEDIR:PATH=com
|
||||
|
||||
//Read-only single-machine data (etc)
|
||||
CMAKE_INSTALL_SYSCONFDIR:PATH=etc
|
||||
|
||||
//Path to a program.
|
||||
CMAKE_LINKER:FILEPATH=D:/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/windows-x86_64/bin/ld.lld.exe
|
||||
|
||||
//No help, variable specified on the command line.
|
||||
CMAKE_MAKE_PROGRAM:UNINITIALIZED=D:\Android\sdk\cmake\3.22.1\bin\ninja.exe
|
||||
|
||||
//Flags used by the linker during the creation of modules.
|
||||
CMAKE_MODULE_LINKER_FLAGS:STRING=
|
||||
|
||||
//Flags used by the linker during the creation of modules during
|
||||
// DEBUG builds.
|
||||
CMAKE_MODULE_LINKER_FLAGS_DEBUG:STRING=
|
||||
|
||||
//Flags used by the linker during the creation of modules during
|
||||
// MINSIZEREL builds.
|
||||
CMAKE_MODULE_LINKER_FLAGS_MINSIZEREL:STRING=
|
||||
|
||||
//Flags used by the linker during the creation of modules during
|
||||
// RELEASE builds.
|
||||
CMAKE_MODULE_LINKER_FLAGS_RELEASE:STRING=
|
||||
|
||||
//Flags used by the linker during the creation of modules during
|
||||
// RELWITHDEBINFO builds.
|
||||
CMAKE_MODULE_LINKER_FLAGS_RELWITHDEBINFO:STRING=
|
||||
|
||||
//Path to a program.
|
||||
CMAKE_NM:FILEPATH=D:/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/windows-x86_64/bin/llvm-nm.exe
|
||||
|
||||
//Path to a program.
|
||||
CMAKE_OBJCOPY:FILEPATH=D:/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/windows-x86_64/bin/llvm-objcopy.exe
|
||||
|
||||
//Path to a program.
|
||||
CMAKE_OBJDUMP:FILEPATH=D:/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/windows-x86_64/bin/llvm-objdump.exe
|
||||
|
||||
//Value Computed by CMake
|
||||
CMAKE_PROJECT_DESCRIPTION:STATIC=
|
||||
|
||||
//Value Computed by CMake
|
||||
CMAKE_PROJECT_HOMEPAGE_URL:STATIC=
|
||||
|
||||
//Value Computed by CMake
|
||||
CMAKE_PROJECT_NAME:STATIC=Opus
|
||||
|
||||
//Value Computed by CMake
|
||||
CMAKE_PROJECT_VERSION:STATIC=0
|
||||
|
||||
//Value Computed by CMake
|
||||
CMAKE_PROJECT_VERSION_MAJOR:STATIC=0
|
||||
|
||||
//Value Computed by CMake
|
||||
CMAKE_PROJECT_VERSION_MINOR:STATIC=
|
||||
|
||||
//Value Computed by CMake
|
||||
CMAKE_PROJECT_VERSION_PATCH:STATIC=
|
||||
|
||||
//Value Computed by CMake
|
||||
CMAKE_PROJECT_VERSION_TWEAK:STATIC=
|
||||
|
||||
//Ranlib
|
||||
CMAKE_RANLIB:FILEPATH=D:/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/windows-x86_64/bin/llvm-ranlib.exe
|
||||
|
||||
//Path to a program.
|
||||
CMAKE_READELF:FILEPATH=D:/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/windows-x86_64/bin/llvm-readelf.exe
|
||||
|
||||
//Flags used by the linker during the creation of dll's.
|
||||
CMAKE_SHARED_LINKER_FLAGS:STRING=
|
||||
|
||||
//Flags used by the linker during the creation of shared libraries
|
||||
// during DEBUG builds.
|
||||
CMAKE_SHARED_LINKER_FLAGS_DEBUG:STRING=
|
||||
|
||||
//Flags used by the linker during the creation of shared libraries
|
||||
// during MINSIZEREL builds.
|
||||
CMAKE_SHARED_LINKER_FLAGS_MINSIZEREL:STRING=
|
||||
|
||||
//Flags used by the linker during the creation of shared libraries
|
||||
// during RELEASE builds.
|
||||
CMAKE_SHARED_LINKER_FLAGS_RELEASE:STRING=
|
||||
|
||||
//Flags used by the linker during the creation of shared libraries
|
||||
// during RELWITHDEBINFO builds.
|
||||
CMAKE_SHARED_LINKER_FLAGS_RELWITHDEBINFO:STRING=
|
||||
|
||||
//If set, runtime paths are not added when installing shared libraries,
|
||||
// but are added when building.
|
||||
CMAKE_SKIP_INSTALL_RPATH:BOOL=NO
|
||||
|
||||
//If set, runtime paths are not added when using shared libraries.
|
||||
CMAKE_SKIP_RPATH:BOOL=NO
|
||||
|
||||
//Flags used by the linker during the creation of static libraries
|
||||
// during all build types.
|
||||
CMAKE_STATIC_LINKER_FLAGS:STRING=
|
||||
|
||||
//Flags used by the linker during the creation of static libraries
|
||||
// during DEBUG builds.
|
||||
CMAKE_STATIC_LINKER_FLAGS_DEBUG:STRING=
|
||||
|
||||
//Flags used by the linker during the creation of static libraries
|
||||
// during MINSIZEREL builds.
|
||||
CMAKE_STATIC_LINKER_FLAGS_MINSIZEREL:STRING=
|
||||
|
||||
//Flags used by the linker during the creation of static libraries
|
||||
// during RELEASE builds.
|
||||
CMAKE_STATIC_LINKER_FLAGS_RELEASE:STRING=
|
||||
|
||||
//Flags used by the linker during the creation of static libraries
|
||||
// during RELWITHDEBINFO builds.
|
||||
CMAKE_STATIC_LINKER_FLAGS_RELWITHDEBINFO:STRING=
|
||||
|
||||
//Strip
|
||||
CMAKE_STRIP:FILEPATH=D:/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/windows-x86_64/bin/llvm-strip.exe
|
||||
|
||||
//No help, variable specified on the command line.
|
||||
CMAKE_TOOLCHAIN_FILE:UNINITIALIZED=D:\Android\sdk\ndk\27.1.12297006\build\cmake\android.toolchain.cmake
|
||||
|
||||
//If this value is on, makefiles will be generated without the
|
||||
// .SILENT directive, and all commands will be echoed to the console
|
||||
// during the make. This is useful for debugging only. With Visual
|
||||
// Studio IDE projects all commands are done without /nologo.
|
||||
CMAKE_VERBOSE_MAKEFILE:BOOL=FALSE
|
||||
|
||||
//Git command line client
|
||||
GIT_EXECUTABLE:FILEPATH=C:/Program Files/Git/mingw64/bin/git.exe
|
||||
|
||||
//additional software error checking.
|
||||
OPUS_ASSERTIONS:BOOL=OFF
|
||||
|
||||
//build programs.
|
||||
OPUS_BUILD_PROGRAMS:BOOL=OFF
|
||||
|
||||
//build shared library.
|
||||
OPUS_BUILD_SHARED_LIBRARY:BOOL=OFF
|
||||
|
||||
//build tests.
|
||||
OPUS_BUILD_TESTING:BOOL=OFF
|
||||
|
||||
//enable bit-exactness checks between optimized and c implementations.
|
||||
OPUS_CHECK_ASM:BOOL=OFF
|
||||
|
||||
//enable non-Opus modes, e.g. 44.1 kHz & 2^n frames.
|
||||
OPUS_CUSTOM_MODES:BOOL=OFF
|
||||
|
||||
//disable all intrinsics optimizations.
|
||||
OPUS_DISABLE_INTRINSICS:BOOL=OFF
|
||||
|
||||
//Run DNN computations as float for debugging purposes.
|
||||
OPUS_DNN_FLOAT_DEBUG:BOOL=OFF
|
||||
|
||||
//enable DRED.
|
||||
OPUS_DRED:BOOL=OFF
|
||||
|
||||
//compile with the floating point API (for machines with float
|
||||
// library).
|
||||
OPUS_ENABLE_FLOAT_API:BOOL=ON
|
||||
|
||||
//compile as fixed-point (for machines without a fast enough FPU).
|
||||
OPUS_FIXED_POINT:BOOL=OFF
|
||||
|
||||
//enable floating point approximations (Ensure your platform supports
|
||||
// IEEE 754 before enabling).
|
||||
OPUS_FLOAT_APPROX:BOOL=OFF
|
||||
|
||||
//add protection against buffer overflows.
|
||||
OPUS_FORTIFY_SOURCE:BOOL=ON
|
||||
|
||||
//causes the encoder to make random decisions (do not use in production).
|
||||
OPUS_FUZZING:BOOL=OFF
|
||||
|
||||
//run-time checks that are cheap and safe for use in production.
|
||||
OPUS_HARDENING:BOOL=ON
|
||||
|
||||
//install CMake package config module.
|
||||
OPUS_INSTALL_CMAKE_CONFIG_MODULE:BOOL=OFF
|
||||
|
||||
//install pkg-config module.
|
||||
OPUS_INSTALL_PKG_CONFIG_MODULE:BOOL=OFF
|
||||
|
||||
//Does runtime check for neon support
|
||||
OPUS_MAY_HAVE_NEON:BOOL=ON
|
||||
|
||||
//enable OSCE.
|
||||
OPUS_OSCE:BOOL=OFF
|
||||
|
||||
//Assume target CPU has NEON support
|
||||
OPUS_PRESUME_NEON:BOOL=OFF
|
||||
|
||||
//use stack protection.
|
||||
OPUS_STACK_PROTECTOR:BOOL=ON
|
||||
|
||||
//Option to enable NEON
|
||||
OPUS_USE_NEON:BOOL=ON
|
||||
|
||||
//use variable length arrays for stack arrays.
|
||||
OPUS_VAR_ARRAYS:BOOL=ON
|
||||
|
||||
//Value Computed by CMake
|
||||
Opus_BINARY_DIR:STATIC=E:/MyProject/ts-mobile-go/go/teamspeak/.opus/build/arm64-v8a
|
||||
|
||||
//Value Computed by CMake
|
||||
Opus_IS_TOP_LEVEL:STATIC=ON
|
||||
|
||||
//Value Computed by CMake
|
||||
Opus_SOURCE_DIR:STATIC=E:/MyProject/ts-mobile-go/android/app/src/main/cpp/third_party/opus
|
||||
|
||||
|
||||
########################
|
||||
# INTERNAL cache entries
|
||||
########################
|
||||
|
||||
//ADVANCED property for variable: CMAKE_ADDR2LINE
|
||||
CMAKE_ADDR2LINE-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_AR
|
||||
CMAKE_AR-ADVANCED:INTERNAL=1
|
||||
//This is the directory where this CMakeCache.txt was created
|
||||
CMAKE_CACHEFILE_DIR:INTERNAL=e:/MyProject/ts-mobile-go/go/teamspeak/.opus/build/arm64-v8a
|
||||
//Major version of cmake used to create the current loaded cache
|
||||
CMAKE_CACHE_MAJOR_VERSION:INTERNAL=3
|
||||
//Minor version of cmake used to create the current loaded cache
|
||||
CMAKE_CACHE_MINOR_VERSION:INTERNAL=22
|
||||
//Patch version of cmake used to create the current loaded cache
|
||||
CMAKE_CACHE_PATCH_VERSION:INTERNAL=1
|
||||
//Path to CMake executable.
|
||||
CMAKE_COMMAND:INTERNAL=D:/Android/sdk/cmake/3.22.1/bin/cmake.exe
|
||||
//Path to cpack program executable.
|
||||
CMAKE_CPACK_COMMAND:INTERNAL=D:/Android/sdk/cmake/3.22.1/bin/cpack.exe
|
||||
//Path to ctest program executable.
|
||||
CMAKE_CTEST_COMMAND:INTERNAL=D:/Android/sdk/cmake/3.22.1/bin/ctest.exe
|
||||
//ADVANCED property for variable: CMAKE_C_COMPILER_AR
|
||||
CMAKE_C_COMPILER_AR-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_C_COMPILER_RANLIB
|
||||
CMAKE_C_COMPILER_RANLIB-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_C_FLAGS
|
||||
CMAKE_C_FLAGS-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_C_FLAGS_DEBUG
|
||||
CMAKE_C_FLAGS_DEBUG-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_C_FLAGS_MINSIZEREL
|
||||
CMAKE_C_FLAGS_MINSIZEREL-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_C_FLAGS_RELEASE
|
||||
CMAKE_C_FLAGS_RELEASE-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_C_FLAGS_RELWITHDEBINFO
|
||||
CMAKE_C_FLAGS_RELWITHDEBINFO-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_C_STANDARD_LIBRARIES
|
||||
CMAKE_C_STANDARD_LIBRARIES-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_DLLTOOL
|
||||
CMAKE_DLLTOOL-ADVANCED:INTERNAL=1
|
||||
//Executable file format
|
||||
CMAKE_EXECUTABLE_FORMAT:INTERNAL=ELF
|
||||
//ADVANCED property for variable: CMAKE_EXE_LINKER_FLAGS
|
||||
CMAKE_EXE_LINKER_FLAGS-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_EXE_LINKER_FLAGS_DEBUG
|
||||
CMAKE_EXE_LINKER_FLAGS_DEBUG-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_EXE_LINKER_FLAGS_MINSIZEREL
|
||||
CMAKE_EXE_LINKER_FLAGS_MINSIZEREL-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_EXE_LINKER_FLAGS_RELEASE
|
||||
CMAKE_EXE_LINKER_FLAGS_RELEASE-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_EXE_LINKER_FLAGS_RELWITHDEBINFO
|
||||
CMAKE_EXE_LINKER_FLAGS_RELWITHDEBINFO-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_EXPORT_COMPILE_COMMANDS
|
||||
CMAKE_EXPORT_COMPILE_COMMANDS-ADVANCED:INTERNAL=1
|
||||
//Name of external makefile project generator.
|
||||
CMAKE_EXTRA_GENERATOR:INTERNAL=
|
||||
//Name of generator.
|
||||
CMAKE_GENERATOR:INTERNAL=Ninja
|
||||
//Generator instance identifier.
|
||||
CMAKE_GENERATOR_INSTANCE:INTERNAL=
|
||||
//Name of generator platform.
|
||||
CMAKE_GENERATOR_PLATFORM:INTERNAL=
|
||||
//Name of generator toolset.
|
||||
CMAKE_GENERATOR_TOOLSET:INTERNAL=
|
||||
//Source directory with the top level CMakeLists.txt file for this
|
||||
// project
|
||||
CMAKE_HOME_DIRECTORY:INTERNAL=E:/MyProject/ts-mobile-go/android/app/src/main/cpp/third_party/opus
|
||||
//ADVANCED property for variable: CMAKE_INSTALL_BINDIR
|
||||
CMAKE_INSTALL_BINDIR-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_INSTALL_DATADIR
|
||||
CMAKE_INSTALL_DATADIR-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_INSTALL_DATAROOTDIR
|
||||
CMAKE_INSTALL_DATAROOTDIR-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_INSTALL_DOCDIR
|
||||
CMAKE_INSTALL_DOCDIR-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_INSTALL_INCLUDEDIR
|
||||
CMAKE_INSTALL_INCLUDEDIR-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_INSTALL_INFODIR
|
||||
CMAKE_INSTALL_INFODIR-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_INSTALL_LIBDIR
|
||||
CMAKE_INSTALL_LIBDIR-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_INSTALL_LIBEXECDIR
|
||||
CMAKE_INSTALL_LIBEXECDIR-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_INSTALL_LOCALEDIR
|
||||
CMAKE_INSTALL_LOCALEDIR-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_INSTALL_LOCALSTATEDIR
|
||||
CMAKE_INSTALL_LOCALSTATEDIR-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_INSTALL_MANDIR
|
||||
CMAKE_INSTALL_MANDIR-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_INSTALL_OLDINCLUDEDIR
|
||||
CMAKE_INSTALL_OLDINCLUDEDIR-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_INSTALL_RUNSTATEDIR
|
||||
CMAKE_INSTALL_RUNSTATEDIR-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_INSTALL_SBINDIR
|
||||
CMAKE_INSTALL_SBINDIR-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_INSTALL_SHAREDSTATEDIR
|
||||
CMAKE_INSTALL_SHAREDSTATEDIR-ADVANCED:INTERNAL=1
|
||||
//Install .so files without execute permission.
|
||||
CMAKE_INSTALL_SO_NO_EXE:INTERNAL=0
|
||||
//ADVANCED property for variable: CMAKE_INSTALL_SYSCONFDIR
|
||||
CMAKE_INSTALL_SYSCONFDIR-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_LINKER
|
||||
CMAKE_LINKER-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_MODULE_LINKER_FLAGS
|
||||
CMAKE_MODULE_LINKER_FLAGS-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_MODULE_LINKER_FLAGS_DEBUG
|
||||
CMAKE_MODULE_LINKER_FLAGS_DEBUG-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_MODULE_LINKER_FLAGS_MINSIZEREL
|
||||
CMAKE_MODULE_LINKER_FLAGS_MINSIZEREL-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_MODULE_LINKER_FLAGS_RELEASE
|
||||
CMAKE_MODULE_LINKER_FLAGS_RELEASE-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_MODULE_LINKER_FLAGS_RELWITHDEBINFO
|
||||
CMAKE_MODULE_LINKER_FLAGS_RELWITHDEBINFO-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_NM
|
||||
CMAKE_NM-ADVANCED:INTERNAL=1
|
||||
//number of local generators
|
||||
CMAKE_NUMBER_OF_MAKEFILES:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_OBJCOPY
|
||||
CMAKE_OBJCOPY-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_OBJDUMP
|
||||
CMAKE_OBJDUMP-ADVANCED:INTERNAL=1
|
||||
//Platform information initialized
|
||||
CMAKE_PLATFORM_INFO_INITIALIZED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_RANLIB
|
||||
CMAKE_RANLIB-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_READELF
|
||||
CMAKE_READELF-ADVANCED:INTERNAL=1
|
||||
//Path to CMake installation.
|
||||
CMAKE_ROOT:INTERNAL=D:/Android/sdk/cmake/3.22.1/share/cmake-3.22
|
||||
//ADVANCED property for variable: CMAKE_SHARED_LINKER_FLAGS
|
||||
CMAKE_SHARED_LINKER_FLAGS-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_SHARED_LINKER_FLAGS_DEBUG
|
||||
CMAKE_SHARED_LINKER_FLAGS_DEBUG-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_SHARED_LINKER_FLAGS_MINSIZEREL
|
||||
CMAKE_SHARED_LINKER_FLAGS_MINSIZEREL-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_SHARED_LINKER_FLAGS_RELEASE
|
||||
CMAKE_SHARED_LINKER_FLAGS_RELEASE-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_SHARED_LINKER_FLAGS_RELWITHDEBINFO
|
||||
CMAKE_SHARED_LINKER_FLAGS_RELWITHDEBINFO-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_SKIP_INSTALL_RPATH
|
||||
CMAKE_SKIP_INSTALL_RPATH-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_SKIP_RPATH
|
||||
CMAKE_SKIP_RPATH-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_STATIC_LINKER_FLAGS
|
||||
CMAKE_STATIC_LINKER_FLAGS-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_STATIC_LINKER_FLAGS_DEBUG
|
||||
CMAKE_STATIC_LINKER_FLAGS_DEBUG-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_STATIC_LINKER_FLAGS_MINSIZEREL
|
||||
CMAKE_STATIC_LINKER_FLAGS_MINSIZEREL-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_STATIC_LINKER_FLAGS_RELEASE
|
||||
CMAKE_STATIC_LINKER_FLAGS_RELEASE-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_STATIC_LINKER_FLAGS_RELWITHDEBINFO
|
||||
CMAKE_STATIC_LINKER_FLAGS_RELWITHDEBINFO-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_STRIP
|
||||
CMAKE_STRIP-ADVANCED:INTERNAL=1
|
||||
//ADVANCED property for variable: CMAKE_VERBOSE_MAKEFILE
|
||||
CMAKE_VERBOSE_MAKEFILE-ADVANCED:INTERNAL=1
|
||||
//Result of TRY_COMPILE
|
||||
COMPILE_VLA_SUPPORTED:INTERNAL=TRUE
|
||||
//Test FAST_MATH_SUPPORTED
|
||||
FAST_MATH_SUPPORTED:INTERNAL=1
|
||||
//Details about finding Git
|
||||
FIND_PACKAGE_MESSAGE_DETAILS_Git:INTERNAL=[C:/Program Files/Git/mingw64/bin/git.exe][v2.54.0.windows.1()]
|
||||
//ADVANCED property for variable: GIT_EXECUTABLE
|
||||
GIT_EXECUTABLE-ADVANCED:INTERNAL=1
|
||||
//Have include alloca.h
|
||||
HAVE_ALLOCA_H:INTERNAL=1
|
||||
//Have include arm_neon.h
|
||||
HAVE_ARM_NEON_H:INTERNAL=1
|
||||
//Have library m
|
||||
HAVE_LIBM:INTERNAL=1
|
||||
//Have symbol lrint
|
||||
HAVE_LRINT:INTERNAL=1
|
||||
//Have symbol lrintf
|
||||
HAVE_LRINTF:INTERNAL=1
|
||||
//Test HIDDEN_VISIBILITY_SUPPORTED
|
||||
HIDDEN_VISIBILITY_SUPPORTED:INTERNAL=1
|
||||
//Test STACK_PROTECTOR_SUPPORTED
|
||||
STACK_PROTECTOR_SUPPORTED:INTERNAL=1
|
||||
//Have symbol alloca
|
||||
USE_ALLOCA_SUPPORTED:INTERNAL=1
|
||||
//Test W_SUPPORTED
|
||||
W_SUPPORTED:INTERNAL=1
|
||||
//Test Wall_SUPPORTED
|
||||
Wall_SUPPORTED:INTERNAL=1
|
||||
//Test Wcastalign_SUPPORTED
|
||||
Wcastalign_SUPPORTED:INTERNAL=1
|
||||
//Test Wextra_SUPPORTED
|
||||
Wextra_SUPPORTED:INTERNAL=1
|
||||
//Test Wnestedexterns_SUPPORTED
|
||||
Wnestedexterns_SUPPORTED:INTERNAL=1
|
||||
//Test Wshadow_SUPPORTED
|
||||
Wshadow_SUPPORTED:INTERNAL=1
|
||||
//Test Wstrictprototypes_SUPPORTED
|
||||
Wstrictprototypes_SUPPORTED:INTERNAL=1
|
||||
//CMAKE_INSTALL_PREFIX during last run
|
||||
_GNUInstallDirs_LAST_CMAKE_INSTALL_PREFIX:INTERNAL=E:/MyProject/ts-mobile-go/go/teamspeak/.opus/install/arm64-v8a
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
set(CMAKE_C_COMPILER "D:/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/windows-x86_64/bin/clang.exe")
|
||||
set(CMAKE_C_COMPILER_ARG1 "")
|
||||
set(CMAKE_C_COMPILER_ID "Clang")
|
||||
set(CMAKE_C_COMPILER_VERSION "18.0.2")
|
||||
set(CMAKE_C_COMPILER_VERSION_INTERNAL "")
|
||||
set(CMAKE_C_COMPILER_WRAPPER "")
|
||||
set(CMAKE_C_STANDARD_COMPUTED_DEFAULT "17")
|
||||
set(CMAKE_C_EXTENSIONS_COMPUTED_DEFAULT "ON")
|
||||
set(CMAKE_C_COMPILE_FEATURES "c_std_90;c_function_prototypes;c_std_99;c_restrict;c_variadic_macros;c_std_11;c_static_assert;c_std_17;c_std_23")
|
||||
set(CMAKE_C90_COMPILE_FEATURES "c_std_90;c_function_prototypes")
|
||||
set(CMAKE_C99_COMPILE_FEATURES "c_std_99;c_restrict;c_variadic_macros")
|
||||
set(CMAKE_C11_COMPILE_FEATURES "c_std_11;c_static_assert")
|
||||
set(CMAKE_C17_COMPILE_FEATURES "c_std_17")
|
||||
set(CMAKE_C23_COMPILE_FEATURES "c_std_23")
|
||||
|
||||
set(CMAKE_C_PLATFORM_ID "Linux")
|
||||
set(CMAKE_C_SIMULATE_ID "")
|
||||
set(CMAKE_C_COMPILER_FRONTEND_VARIANT "GNU")
|
||||
set(CMAKE_C_SIMULATE_VERSION "")
|
||||
|
||||
|
||||
|
||||
|
||||
set(CMAKE_AR "D:/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/windows-x86_64/bin/llvm-ar.exe")
|
||||
set(CMAKE_C_COMPILER_AR "D:/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/windows-x86_64/bin/llvm-ar.exe")
|
||||
set(CMAKE_RANLIB "D:/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/windows-x86_64/bin/llvm-ranlib.exe")
|
||||
set(CMAKE_C_COMPILER_RANLIB "D:/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/windows-x86_64/bin/llvm-ranlib.exe")
|
||||
set(CMAKE_LINKER "D:/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/windows-x86_64/bin/ld.lld.exe")
|
||||
set(CMAKE_MT "")
|
||||
set(CMAKE_COMPILER_IS_GNUCC )
|
||||
set(CMAKE_C_COMPILER_LOADED 1)
|
||||
set(CMAKE_C_COMPILER_WORKS TRUE)
|
||||
set(CMAKE_C_ABI_COMPILED TRUE)
|
||||
|
||||
set(CMAKE_C_COMPILER_ENV_VAR "CC")
|
||||
|
||||
set(CMAKE_C_COMPILER_ID_RUN 1)
|
||||
set(CMAKE_C_SOURCE_FILE_EXTENSIONS c;m)
|
||||
set(CMAKE_C_IGNORE_EXTENSIONS h;H;o;O;obj;OBJ;def;DEF;rc;RC)
|
||||
set(CMAKE_C_LINKER_PREFERENCE 10)
|
||||
|
||||
# Save compiler ABI information.
|
||||
set(CMAKE_C_SIZEOF_DATA_PTR "8")
|
||||
set(CMAKE_C_COMPILER_ABI "ELF")
|
||||
set(CMAKE_C_BYTE_ORDER "LITTLE_ENDIAN")
|
||||
set(CMAKE_C_LIBRARY_ARCHITECTURE "")
|
||||
|
||||
if(CMAKE_C_SIZEOF_DATA_PTR)
|
||||
set(CMAKE_SIZEOF_VOID_P "${CMAKE_C_SIZEOF_DATA_PTR}")
|
||||
endif()
|
||||
|
||||
if(CMAKE_C_COMPILER_ABI)
|
||||
set(CMAKE_INTERNAL_PLATFORM_ABI "${CMAKE_C_COMPILER_ABI}")
|
||||
endif()
|
||||
|
||||
if(CMAKE_C_LIBRARY_ARCHITECTURE)
|
||||
set(CMAKE_LIBRARY_ARCHITECTURE "")
|
||||
endif()
|
||||
|
||||
set(CMAKE_C_CL_SHOWINCLUDES_PREFIX "")
|
||||
if(CMAKE_C_CL_SHOWINCLUDES_PREFIX)
|
||||
set(CMAKE_CL_SHOWINCLUDES_PREFIX "${CMAKE_C_CL_SHOWINCLUDES_PREFIX}")
|
||||
endif()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
set(CMAKE_C_IMPLICIT_INCLUDE_DIRECTORIES "D:/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/windows-x86_64/lib/clang/18/include;D:/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/windows-x86_64/sysroot/usr/include/aarch64-linux-android;D:/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/windows-x86_64/sysroot/usr/include")
|
||||
set(CMAKE_C_IMPLICIT_LINK_LIBRARIES "-l:libunwind.a;dl;c;-l:libunwind.a;dl")
|
||||
set(CMAKE_C_IMPLICIT_LINK_DIRECTORIES "D:/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/windows-x86_64/lib/clang/18/lib/linux/aarch64;D:/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/windows-x86_64/sysroot/usr/lib/aarch64-linux-android/26;D:/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/windows-x86_64/sysroot/usr/lib/aarch64-linux-android;D:/Android/sdk/ndk/27.1.12297006/toolchains/llvm/prebuilt/windows-x86_64/sysroot/usr/lib")
|
||||
set(CMAKE_C_IMPLICIT_LINK_FRAMEWORK_DIRECTORIES "")
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,15 @@
|
||||
set(CMAKE_HOST_SYSTEM "Windows-10.0.26200")
|
||||
set(CMAKE_HOST_SYSTEM_NAME "Windows")
|
||||
set(CMAKE_HOST_SYSTEM_VERSION "10.0.26200")
|
||||
set(CMAKE_HOST_SYSTEM_PROCESSOR "AMD64")
|
||||
|
||||
include("D:/Android/sdk/ndk/27.1.12297006/build/cmake/android.toolchain.cmake")
|
||||
|
||||
set(CMAKE_SYSTEM "Android-1")
|
||||
set(CMAKE_SYSTEM_NAME "Android")
|
||||
set(CMAKE_SYSTEM_VERSION "1")
|
||||
set(CMAKE_SYSTEM_PROCESSOR "aarch64")
|
||||
|
||||
set(CMAKE_CROSSCOMPILING "TRUE")
|
||||
|
||||
set(CMAKE_SYSTEM_LOADED 1)
|
||||
+803
@@ -0,0 +1,803 @@
|
||||
#ifdef __cplusplus
|
||||
# error "A C++ compiler has been selected for C."
|
||||
#endif
|
||||
|
||||
#if defined(__18CXX)
|
||||
# define ID_VOID_MAIN
|
||||
#endif
|
||||
#if defined(__CLASSIC_C__)
|
||||
/* cv-qualifiers did not exist in K&R C */
|
||||
# define const
|
||||
# define volatile
|
||||
#endif
|
||||
|
||||
#if !defined(__has_include)
|
||||
/* If the compiler does not have __has_include, pretend the answer is
|
||||
always no. */
|
||||
# define __has_include(x) 0
|
||||
#endif
|
||||
|
||||
|
||||
/* Version number components: V=Version, R=Revision, P=Patch
|
||||
Version date components: YYYY=Year, MM=Month, DD=Day */
|
||||
|
||||
#if defined(__INTEL_COMPILER) || defined(__ICC)
|
||||
# define COMPILER_ID "Intel"
|
||||
# if defined(_MSC_VER)
|
||||
# define SIMULATE_ID "MSVC"
|
||||
# endif
|
||||
# if defined(__GNUC__)
|
||||
# define SIMULATE_ID "GNU"
|
||||
# endif
|
||||
/* __INTEL_COMPILER = VRP prior to 2021, and then VVVV for 2021 and later,
|
||||
except that a few beta releases use the old format with V=2021. */
|
||||
# if __INTEL_COMPILER < 2021 || __INTEL_COMPILER == 202110 || __INTEL_COMPILER == 202111
|
||||
# define COMPILER_VERSION_MAJOR DEC(__INTEL_COMPILER/100)
|
||||
# define COMPILER_VERSION_MINOR DEC(__INTEL_COMPILER/10 % 10)
|
||||
# if defined(__INTEL_COMPILER_UPDATE)
|
||||
# define COMPILER_VERSION_PATCH DEC(__INTEL_COMPILER_UPDATE)
|
||||
# else
|
||||
# define COMPILER_VERSION_PATCH DEC(__INTEL_COMPILER % 10)
|
||||
# endif
|
||||
# else
|
||||
# define COMPILER_VERSION_MAJOR DEC(__INTEL_COMPILER)
|
||||
# define COMPILER_VERSION_MINOR DEC(__INTEL_COMPILER_UPDATE)
|
||||
/* The third version component from --version is an update index,
|
||||
but no macro is provided for it. */
|
||||
# define COMPILER_VERSION_PATCH DEC(0)
|
||||
# endif
|
||||
# if defined(__INTEL_COMPILER_BUILD_DATE)
|
||||
/* __INTEL_COMPILER_BUILD_DATE = YYYYMMDD */
|
||||
# define COMPILER_VERSION_TWEAK DEC(__INTEL_COMPILER_BUILD_DATE)
|
||||
# endif
|
||||
# if defined(_MSC_VER)
|
||||
/* _MSC_VER = VVRR */
|
||||
# define SIMULATE_VERSION_MAJOR DEC(_MSC_VER / 100)
|
||||
# define SIMULATE_VERSION_MINOR DEC(_MSC_VER % 100)
|
||||
# endif
|
||||
# if defined(__GNUC__)
|
||||
# define SIMULATE_VERSION_MAJOR DEC(__GNUC__)
|
||||
# elif defined(__GNUG__)
|
||||
# define SIMULATE_VERSION_MAJOR DEC(__GNUG__)
|
||||
# endif
|
||||
# if defined(__GNUC_MINOR__)
|
||||
# define SIMULATE_VERSION_MINOR DEC(__GNUC_MINOR__)
|
||||
# endif
|
||||
# if defined(__GNUC_PATCHLEVEL__)
|
||||
# define SIMULATE_VERSION_PATCH DEC(__GNUC_PATCHLEVEL__)
|
||||
# endif
|
||||
|
||||
#elif (defined(__clang__) && defined(__INTEL_CLANG_COMPILER)) || defined(__INTEL_LLVM_COMPILER)
|
||||
# define COMPILER_ID "IntelLLVM"
|
||||
#if defined(_MSC_VER)
|
||||
# define SIMULATE_ID "MSVC"
|
||||
#endif
|
||||
#if defined(__GNUC__)
|
||||
# define SIMULATE_ID "GNU"
|
||||
#endif
|
||||
/* __INTEL_LLVM_COMPILER = VVVVRP prior to 2021.2.0, VVVVRRPP for 2021.2.0 and
|
||||
* later. Look for 6 digit vs. 8 digit version number to decide encoding.
|
||||
* VVVV is no smaller than the current year when a version is released.
|
||||
*/
|
||||
#if __INTEL_LLVM_COMPILER < 1000000L
|
||||
# define COMPILER_VERSION_MAJOR DEC(__INTEL_LLVM_COMPILER/100)
|
||||
# define COMPILER_VERSION_MINOR DEC(__INTEL_LLVM_COMPILER/10 % 10)
|
||||
# define COMPILER_VERSION_PATCH DEC(__INTEL_LLVM_COMPILER % 10)
|
||||
#else
|
||||
# define COMPILER_VERSION_MAJOR DEC(__INTEL_LLVM_COMPILER/10000)
|
||||
# define COMPILER_VERSION_MINOR DEC(__INTEL_LLVM_COMPILER/100 % 100)
|
||||
# define COMPILER_VERSION_PATCH DEC(__INTEL_LLVM_COMPILER % 100)
|
||||
#endif
|
||||
#if defined(_MSC_VER)
|
||||
/* _MSC_VER = VVRR */
|
||||
# define SIMULATE_VERSION_MAJOR DEC(_MSC_VER / 100)
|
||||
# define SIMULATE_VERSION_MINOR DEC(_MSC_VER % 100)
|
||||
#endif
|
||||
#if defined(__GNUC__)
|
||||
# define SIMULATE_VERSION_MAJOR DEC(__GNUC__)
|
||||
#elif defined(__GNUG__)
|
||||
# define SIMULATE_VERSION_MAJOR DEC(__GNUG__)
|
||||
#endif
|
||||
#if defined(__GNUC_MINOR__)
|
||||
# define SIMULATE_VERSION_MINOR DEC(__GNUC_MINOR__)
|
||||
#endif
|
||||
#if defined(__GNUC_PATCHLEVEL__)
|
||||
# define SIMULATE_VERSION_PATCH DEC(__GNUC_PATCHLEVEL__)
|
||||
#endif
|
||||
|
||||
#elif defined(__PATHCC__)
|
||||
# define COMPILER_ID "PathScale"
|
||||
# define COMPILER_VERSION_MAJOR DEC(__PATHCC__)
|
||||
# define COMPILER_VERSION_MINOR DEC(__PATHCC_MINOR__)
|
||||
# if defined(__PATHCC_PATCHLEVEL__)
|
||||
# define COMPILER_VERSION_PATCH DEC(__PATHCC_PATCHLEVEL__)
|
||||
# endif
|
||||
|
||||
#elif defined(__BORLANDC__) && defined(__CODEGEARC_VERSION__)
|
||||
# define COMPILER_ID "Embarcadero"
|
||||
# define COMPILER_VERSION_MAJOR HEX(__CODEGEARC_VERSION__>>24 & 0x00FF)
|
||||
# define COMPILER_VERSION_MINOR HEX(__CODEGEARC_VERSION__>>16 & 0x00FF)
|
||||
# define COMPILER_VERSION_PATCH DEC(__CODEGEARC_VERSION__ & 0xFFFF)
|
||||
|
||||
#elif defined(__BORLANDC__)
|
||||
# define COMPILER_ID "Borland"
|
||||
/* __BORLANDC__ = 0xVRR */
|
||||
# define COMPILER_VERSION_MAJOR HEX(__BORLANDC__>>8)
|
||||
# define COMPILER_VERSION_MINOR HEX(__BORLANDC__ & 0xFF)
|
||||
|
||||
#elif defined(__WATCOMC__) && __WATCOMC__ < 1200
|
||||
# define COMPILER_ID "Watcom"
|
||||
/* __WATCOMC__ = VVRR */
|
||||
# define COMPILER_VERSION_MAJOR DEC(__WATCOMC__ / 100)
|
||||
# define COMPILER_VERSION_MINOR DEC((__WATCOMC__ / 10) % 10)
|
||||
# if (__WATCOMC__ % 10) > 0
|
||||
# define COMPILER_VERSION_PATCH DEC(__WATCOMC__ % 10)
|
||||
# endif
|
||||
|
||||
#elif defined(__WATCOMC__)
|
||||
# define COMPILER_ID "OpenWatcom"
|
||||
/* __WATCOMC__ = VVRP + 1100 */
|
||||
# define COMPILER_VERSION_MAJOR DEC((__WATCOMC__ - 1100) / 100)
|
||||
# define COMPILER_VERSION_MINOR DEC((__WATCOMC__ / 10) % 10)
|
||||
# if (__WATCOMC__ % 10) > 0
|
||||
# define COMPILER_VERSION_PATCH DEC(__WATCOMC__ % 10)
|
||||
# endif
|
||||
|
||||
#elif defined(__SUNPRO_C)
|
||||
# define COMPILER_ID "SunPro"
|
||||
# if __SUNPRO_C >= 0x5100
|
||||
/* __SUNPRO_C = 0xVRRP */
|
||||
# define COMPILER_VERSION_MAJOR HEX(__SUNPRO_C>>12)
|
||||
# define COMPILER_VERSION_MINOR HEX(__SUNPRO_C>>4 & 0xFF)
|
||||
# define COMPILER_VERSION_PATCH HEX(__SUNPRO_C & 0xF)
|
||||
# else
|
||||
/* __SUNPRO_CC = 0xVRP */
|
||||
# define COMPILER_VERSION_MAJOR HEX(__SUNPRO_C>>8)
|
||||
# define COMPILER_VERSION_MINOR HEX(__SUNPRO_C>>4 & 0xF)
|
||||
# define COMPILER_VERSION_PATCH HEX(__SUNPRO_C & 0xF)
|
||||
# endif
|
||||
|
||||
#elif defined(__HP_cc)
|
||||
# define COMPILER_ID "HP"
|
||||
/* __HP_cc = VVRRPP */
|
||||
# define COMPILER_VERSION_MAJOR DEC(__HP_cc/10000)
|
||||
# define COMPILER_VERSION_MINOR DEC(__HP_cc/100 % 100)
|
||||
# define COMPILER_VERSION_PATCH DEC(__HP_cc % 100)
|
||||
|
||||
#elif defined(__DECC)
|
||||
# define COMPILER_ID "Compaq"
|
||||
/* __DECC_VER = VVRRTPPPP */
|
||||
# define COMPILER_VERSION_MAJOR DEC(__DECC_VER/10000000)
|
||||
# define COMPILER_VERSION_MINOR DEC(__DECC_VER/100000 % 100)
|
||||
# define COMPILER_VERSION_PATCH DEC(__DECC_VER % 10000)
|
||||
|
||||
#elif defined(__IBMC__) && defined(__COMPILER_VER__)
|
||||
# define COMPILER_ID "zOS"
|
||||
/* __IBMC__ = VRP */
|
||||
# define COMPILER_VERSION_MAJOR DEC(__IBMC__/100)
|
||||
# define COMPILER_VERSION_MINOR DEC(__IBMC__/10 % 10)
|
||||
# define COMPILER_VERSION_PATCH DEC(__IBMC__ % 10)
|
||||
|
||||
#elif defined(__ibmxl__) && defined(__clang__)
|
||||
# define COMPILER_ID "XLClang"
|
||||
# define COMPILER_VERSION_MAJOR DEC(__ibmxl_version__)
|
||||
# define COMPILER_VERSION_MINOR DEC(__ibmxl_release__)
|
||||
# define COMPILER_VERSION_PATCH DEC(__ibmxl_modification__)
|
||||
# define COMPILER_VERSION_TWEAK DEC(__ibmxl_ptf_fix_level__)
|
||||
|
||||
|
||||
#elif defined(__IBMC__) && !defined(__COMPILER_VER__) && __IBMC__ >= 800
|
||||
# define COMPILER_ID "XL"
|
||||
/* __IBMC__ = VRP */
|
||||
# define COMPILER_VERSION_MAJOR DEC(__IBMC__/100)
|
||||
# define COMPILER_VERSION_MINOR DEC(__IBMC__/10 % 10)
|
||||
# define COMPILER_VERSION_PATCH DEC(__IBMC__ % 10)
|
||||
|
||||
#elif defined(__IBMC__) && !defined(__COMPILER_VER__) && __IBMC__ < 800
|
||||
# define COMPILER_ID "VisualAge"
|
||||
/* __IBMC__ = VRP */
|
||||
# define COMPILER_VERSION_MAJOR DEC(__IBMC__/100)
|
||||
# define COMPILER_VERSION_MINOR DEC(__IBMC__/10 % 10)
|
||||
# define COMPILER_VERSION_PATCH DEC(__IBMC__ % 10)
|
||||
|
||||
#elif defined(__NVCOMPILER)
|
||||
# define COMPILER_ID "NVHPC"
|
||||
# define COMPILER_VERSION_MAJOR DEC(__NVCOMPILER_MAJOR__)
|
||||
# define COMPILER_VERSION_MINOR DEC(__NVCOMPILER_MINOR__)
|
||||
# if defined(__NVCOMPILER_PATCHLEVEL__)
|
||||
# define COMPILER_VERSION_PATCH DEC(__NVCOMPILER_PATCHLEVEL__)
|
||||
# endif
|
||||
|
||||
#elif defined(__PGI)
|
||||
# define COMPILER_ID "PGI"
|
||||
# define COMPILER_VERSION_MAJOR DEC(__PGIC__)
|
||||
# define COMPILER_VERSION_MINOR DEC(__PGIC_MINOR__)
|
||||
# if defined(__PGIC_PATCHLEVEL__)
|
||||
# define COMPILER_VERSION_PATCH DEC(__PGIC_PATCHLEVEL__)
|
||||
# endif
|
||||
|
||||
#elif defined(_CRAYC)
|
||||
# define COMPILER_ID "Cray"
|
||||
# define COMPILER_VERSION_MAJOR DEC(_RELEASE_MAJOR)
|
||||
# define COMPILER_VERSION_MINOR DEC(_RELEASE_MINOR)
|
||||
|
||||
#elif defined(__TI_COMPILER_VERSION__)
|
||||
# define COMPILER_ID "TI"
|
||||
/* __TI_COMPILER_VERSION__ = VVVRRRPPP */
|
||||
# define COMPILER_VERSION_MAJOR DEC(__TI_COMPILER_VERSION__/1000000)
|
||||
# define COMPILER_VERSION_MINOR DEC(__TI_COMPILER_VERSION__/1000 % 1000)
|
||||
# define COMPILER_VERSION_PATCH DEC(__TI_COMPILER_VERSION__ % 1000)
|
||||
|
||||
#elif defined(__CLANG_FUJITSU)
|
||||
# define COMPILER_ID "FujitsuClang"
|
||||
# define COMPILER_VERSION_MAJOR DEC(__FCC_major__)
|
||||
# define COMPILER_VERSION_MINOR DEC(__FCC_minor__)
|
||||
# define COMPILER_VERSION_PATCH DEC(__FCC_patchlevel__)
|
||||
# define COMPILER_VERSION_INTERNAL_STR __clang_version__
|
||||
|
||||
|
||||
#elif defined(__FUJITSU)
|
||||
# define COMPILER_ID "Fujitsu"
|
||||
# if defined(__FCC_version__)
|
||||
# define COMPILER_VERSION __FCC_version__
|
||||
# elif defined(__FCC_major__)
|
||||
# define COMPILER_VERSION_MAJOR DEC(__FCC_major__)
|
||||
# define COMPILER_VERSION_MINOR DEC(__FCC_minor__)
|
||||
# define COMPILER_VERSION_PATCH DEC(__FCC_patchlevel__)
|
||||
# endif
|
||||
# if defined(__fcc_version)
|
||||
# define COMPILER_VERSION_INTERNAL DEC(__fcc_version)
|
||||
# elif defined(__FCC_VERSION)
|
||||
# define COMPILER_VERSION_INTERNAL DEC(__FCC_VERSION)
|
||||
# endif
|
||||
|
||||
|
||||
#elif defined(__ghs__)
|
||||
# define COMPILER_ID "GHS"
|
||||
/* __GHS_VERSION_NUMBER = VVVVRP */
|
||||
# ifdef __GHS_VERSION_NUMBER
|
||||
# define COMPILER_VERSION_MAJOR DEC(__GHS_VERSION_NUMBER / 100)
|
||||
# define COMPILER_VERSION_MINOR DEC(__GHS_VERSION_NUMBER / 10 % 10)
|
||||
# define COMPILER_VERSION_PATCH DEC(__GHS_VERSION_NUMBER % 10)
|
||||
# endif
|
||||
|
||||
#elif defined(__TINYC__)
|
||||
# define COMPILER_ID "TinyCC"
|
||||
|
||||
#elif defined(__BCC__)
|
||||
# define COMPILER_ID "Bruce"
|
||||
|
||||
#elif defined(__SCO_VERSION__)
|
||||
# define COMPILER_ID "SCO"
|
||||
|
||||
#elif defined(__ARMCC_VERSION) && !defined(__clang__)
|
||||
# define COMPILER_ID "ARMCC"
|
||||
#if __ARMCC_VERSION >= 1000000
|
||||
/* __ARMCC_VERSION = VRRPPPP */
|
||||
# define COMPILER_VERSION_MAJOR DEC(__ARMCC_VERSION/1000000)
|
||||
# define COMPILER_VERSION_MINOR DEC(__ARMCC_VERSION/10000 % 100)
|
||||
# define COMPILER_VERSION_PATCH DEC(__ARMCC_VERSION % 10000)
|
||||
#else
|
||||
/* __ARMCC_VERSION = VRPPPP */
|
||||
# define COMPILER_VERSION_MAJOR DEC(__ARMCC_VERSION/100000)
|
||||
# define COMPILER_VERSION_MINOR DEC(__ARMCC_VERSION/10000 % 10)
|
||||
# define COMPILER_VERSION_PATCH DEC(__ARMCC_VERSION % 10000)
|
||||
#endif
|
||||
|
||||
|
||||
#elif defined(__clang__) && defined(__apple_build_version__)
|
||||
# define COMPILER_ID "AppleClang"
|
||||
# if defined(_MSC_VER)
|
||||
# define SIMULATE_ID "MSVC"
|
||||
# endif
|
||||
# define COMPILER_VERSION_MAJOR DEC(__clang_major__)
|
||||
# define COMPILER_VERSION_MINOR DEC(__clang_minor__)
|
||||
# define COMPILER_VERSION_PATCH DEC(__clang_patchlevel__)
|
||||
# if defined(_MSC_VER)
|
||||
/* _MSC_VER = VVRR */
|
||||
# define SIMULATE_VERSION_MAJOR DEC(_MSC_VER / 100)
|
||||
# define SIMULATE_VERSION_MINOR DEC(_MSC_VER % 100)
|
||||
# endif
|
||||
# define COMPILER_VERSION_TWEAK DEC(__apple_build_version__)
|
||||
|
||||
#elif defined(__clang__) && defined(__ARMCOMPILER_VERSION)
|
||||
# define COMPILER_ID "ARMClang"
|
||||
# define COMPILER_VERSION_MAJOR DEC(__ARMCOMPILER_VERSION/1000000)
|
||||
# define COMPILER_VERSION_MINOR DEC(__ARMCOMPILER_VERSION/10000 % 100)
|
||||
# define COMPILER_VERSION_PATCH DEC(__ARMCOMPILER_VERSION % 10000)
|
||||
# define COMPILER_VERSION_INTERNAL DEC(__ARMCOMPILER_VERSION)
|
||||
|
||||
#elif defined(__clang__)
|
||||
# define COMPILER_ID "Clang"
|
||||
# if defined(_MSC_VER)
|
||||
# define SIMULATE_ID "MSVC"
|
||||
# endif
|
||||
# define COMPILER_VERSION_MAJOR DEC(__clang_major__)
|
||||
# define COMPILER_VERSION_MINOR DEC(__clang_minor__)
|
||||
# define COMPILER_VERSION_PATCH DEC(__clang_patchlevel__)
|
||||
# if defined(_MSC_VER)
|
||||
/* _MSC_VER = VVRR */
|
||||
# define SIMULATE_VERSION_MAJOR DEC(_MSC_VER / 100)
|
||||
# define SIMULATE_VERSION_MINOR DEC(_MSC_VER % 100)
|
||||
# endif
|
||||
|
||||
#elif defined(__GNUC__)
|
||||
# define COMPILER_ID "GNU"
|
||||
# define COMPILER_VERSION_MAJOR DEC(__GNUC__)
|
||||
# if defined(__GNUC_MINOR__)
|
||||
# define COMPILER_VERSION_MINOR DEC(__GNUC_MINOR__)
|
||||
# endif
|
||||
# if defined(__GNUC_PATCHLEVEL__)
|
||||
# define COMPILER_VERSION_PATCH DEC(__GNUC_PATCHLEVEL__)
|
||||
# endif
|
||||
|
||||
#elif defined(_MSC_VER)
|
||||
# define COMPILER_ID "MSVC"
|
||||
/* _MSC_VER = VVRR */
|
||||
# define COMPILER_VERSION_MAJOR DEC(_MSC_VER / 100)
|
||||
# define COMPILER_VERSION_MINOR DEC(_MSC_VER % 100)
|
||||
# if defined(_MSC_FULL_VER)
|
||||
# if _MSC_VER >= 1400
|
||||
/* _MSC_FULL_VER = VVRRPPPPP */
|
||||
# define COMPILER_VERSION_PATCH DEC(_MSC_FULL_VER % 100000)
|
||||
# else
|
||||
/* _MSC_FULL_VER = VVRRPPPP */
|
||||
# define COMPILER_VERSION_PATCH DEC(_MSC_FULL_VER % 10000)
|
||||
# endif
|
||||
# endif
|
||||
# if defined(_MSC_BUILD)
|
||||
# define COMPILER_VERSION_TWEAK DEC(_MSC_BUILD)
|
||||
# endif
|
||||
|
||||
#elif defined(__VISUALDSPVERSION__) || defined(__ADSPBLACKFIN__) || defined(__ADSPTS__) || defined(__ADSP21000__)
|
||||
# define COMPILER_ID "ADSP"
|
||||
#if defined(__VISUALDSPVERSION__)
|
||||
/* __VISUALDSPVERSION__ = 0xVVRRPP00 */
|
||||
# define COMPILER_VERSION_MAJOR HEX(__VISUALDSPVERSION__>>24)
|
||||
# define COMPILER_VERSION_MINOR HEX(__VISUALDSPVERSION__>>16 & 0xFF)
|
||||
# define COMPILER_VERSION_PATCH HEX(__VISUALDSPVERSION__>>8 & 0xFF)
|
||||
#endif
|
||||
|
||||
#elif defined(__IAR_SYSTEMS_ICC__) || defined(__IAR_SYSTEMS_ICC)
|
||||
# define COMPILER_ID "IAR"
|
||||
# if defined(__VER__) && defined(__ICCARM__)
|
||||
# define COMPILER_VERSION_MAJOR DEC((__VER__) / 1000000)
|
||||
# define COMPILER_VERSION_MINOR DEC(((__VER__) / 1000) % 1000)
|
||||
# define COMPILER_VERSION_PATCH DEC((__VER__) % 1000)
|
||||
# define COMPILER_VERSION_INTERNAL DEC(__IAR_SYSTEMS_ICC__)
|
||||
# elif defined(__VER__) && (defined(__ICCAVR__) || defined(__ICCRX__) || defined(__ICCRH850__) || defined(__ICCRL78__) || defined(__ICC430__) || defined(__ICCRISCV__) || defined(__ICCV850__) || defined(__ICC8051__) || defined(__ICCSTM8__))
|
||||
# define COMPILER_VERSION_MAJOR DEC((__VER__) / 100)
|
||||
# define COMPILER_VERSION_MINOR DEC((__VER__) - (((__VER__) / 100)*100))
|
||||
# define COMPILER_VERSION_PATCH DEC(__SUBVERSION__)
|
||||
# define COMPILER_VERSION_INTERNAL DEC(__IAR_SYSTEMS_ICC__)
|
||||
# endif
|
||||
|
||||
#elif defined(__SDCC_VERSION_MAJOR) || defined(SDCC)
|
||||
# define COMPILER_ID "SDCC"
|
||||
# if defined(__SDCC_VERSION_MAJOR)
|
||||
# define COMPILER_VERSION_MAJOR DEC(__SDCC_VERSION_MAJOR)
|
||||
# define COMPILER_VERSION_MINOR DEC(__SDCC_VERSION_MINOR)
|
||||
# define COMPILER_VERSION_PATCH DEC(__SDCC_VERSION_PATCH)
|
||||
# else
|
||||
/* SDCC = VRP */
|
||||
# define COMPILER_VERSION_MAJOR DEC(SDCC/100)
|
||||
# define COMPILER_VERSION_MINOR DEC(SDCC/10 % 10)
|
||||
# define COMPILER_VERSION_PATCH DEC(SDCC % 10)
|
||||
# endif
|
||||
|
||||
|
||||
/* These compilers are either not known or too old to define an
|
||||
identification macro. Try to identify the platform and guess that
|
||||
it is the native compiler. */
|
||||
#elif defined(__hpux) || defined(__hpua)
|
||||
# define COMPILER_ID "HP"
|
||||
|
||||
#else /* unknown compiler */
|
||||
# define COMPILER_ID ""
|
||||
#endif
|
||||
|
||||
/* Construct the string literal in pieces to prevent the source from
|
||||
getting matched. Store it in a pointer rather than an array
|
||||
because some compilers will just produce instructions to fill the
|
||||
array rather than assigning a pointer to a static array. */
|
||||
char const* info_compiler = "INFO" ":" "compiler[" COMPILER_ID "]";
|
||||
#ifdef SIMULATE_ID
|
||||
char const* info_simulate = "INFO" ":" "simulate[" SIMULATE_ID "]";
|
||||
#endif
|
||||
|
||||
#ifdef __QNXNTO__
|
||||
char const* qnxnto = "INFO" ":" "qnxnto[]";
|
||||
#endif
|
||||
|
||||
#if defined(__CRAYXT_COMPUTE_LINUX_TARGET)
|
||||
char const *info_cray = "INFO" ":" "compiler_wrapper[CrayPrgEnv]";
|
||||
#endif
|
||||
|
||||
#define STRINGIFY_HELPER(X) #X
|
||||
#define STRINGIFY(X) STRINGIFY_HELPER(X)
|
||||
|
||||
/* Identify known platforms by name. */
|
||||
#if defined(__linux) || defined(__linux__) || defined(linux)
|
||||
# define PLATFORM_ID "Linux"
|
||||
|
||||
#elif defined(__MSYS__)
|
||||
# define PLATFORM_ID "MSYS"
|
||||
|
||||
#elif defined(__CYGWIN__)
|
||||
# define PLATFORM_ID "Cygwin"
|
||||
|
||||
#elif defined(__MINGW32__)
|
||||
# define PLATFORM_ID "MinGW"
|
||||
|
||||
#elif defined(__APPLE__)
|
||||
# define PLATFORM_ID "Darwin"
|
||||
|
||||
#elif defined(_WIN32) || defined(__WIN32__) || defined(WIN32)
|
||||
# define PLATFORM_ID "Windows"
|
||||
|
||||
#elif defined(__FreeBSD__) || defined(__FreeBSD)
|
||||
# define PLATFORM_ID "FreeBSD"
|
||||
|
||||
#elif defined(__NetBSD__) || defined(__NetBSD)
|
||||
# define PLATFORM_ID "NetBSD"
|
||||
|
||||
#elif defined(__OpenBSD__) || defined(__OPENBSD)
|
||||
# define PLATFORM_ID "OpenBSD"
|
||||
|
||||
#elif defined(__sun) || defined(sun)
|
||||
# define PLATFORM_ID "SunOS"
|
||||
|
||||
#elif defined(_AIX) || defined(__AIX) || defined(__AIX__) || defined(__aix) || defined(__aix__)
|
||||
# define PLATFORM_ID "AIX"
|
||||
|
||||
#elif defined(__hpux) || defined(__hpux__)
|
||||
# define PLATFORM_ID "HP-UX"
|
||||
|
||||
#elif defined(__HAIKU__)
|
||||
# define PLATFORM_ID "Haiku"
|
||||
|
||||
#elif defined(__BeOS) || defined(__BEOS__) || defined(_BEOS)
|
||||
# define PLATFORM_ID "BeOS"
|
||||
|
||||
#elif defined(__QNX__) || defined(__QNXNTO__)
|
||||
# define PLATFORM_ID "QNX"
|
||||
|
||||
#elif defined(__tru64) || defined(_tru64) || defined(__TRU64__)
|
||||
# define PLATFORM_ID "Tru64"
|
||||
|
||||
#elif defined(__riscos) || defined(__riscos__)
|
||||
# define PLATFORM_ID "RISCos"
|
||||
|
||||
#elif defined(__sinix) || defined(__sinix__) || defined(__SINIX__)
|
||||
# define PLATFORM_ID "SINIX"
|
||||
|
||||
#elif defined(__UNIX_SV__)
|
||||
# define PLATFORM_ID "UNIX_SV"
|
||||
|
||||
#elif defined(__bsdos__)
|
||||
# define PLATFORM_ID "BSDOS"
|
||||
|
||||
#elif defined(_MPRAS) || defined(MPRAS)
|
||||
# define PLATFORM_ID "MP-RAS"
|
||||
|
||||
#elif defined(__osf) || defined(__osf__)
|
||||
# define PLATFORM_ID "OSF1"
|
||||
|
||||
#elif defined(_SCO_SV) || defined(SCO_SV) || defined(sco_sv)
|
||||
# define PLATFORM_ID "SCO_SV"
|
||||
|
||||
#elif defined(__ultrix) || defined(__ultrix__) || defined(_ULTRIX)
|
||||
# define PLATFORM_ID "ULTRIX"
|
||||
|
||||
#elif defined(__XENIX__) || defined(_XENIX) || defined(XENIX)
|
||||
# define PLATFORM_ID "Xenix"
|
||||
|
||||
#elif defined(__WATCOMC__)
|
||||
# if defined(__LINUX__)
|
||||
# define PLATFORM_ID "Linux"
|
||||
|
||||
# elif defined(__DOS__)
|
||||
# define PLATFORM_ID "DOS"
|
||||
|
||||
# elif defined(__OS2__)
|
||||
# define PLATFORM_ID "OS2"
|
||||
|
||||
# elif defined(__WINDOWS__)
|
||||
# define PLATFORM_ID "Windows3x"
|
||||
|
||||
# elif defined(__VXWORKS__)
|
||||
# define PLATFORM_ID "VxWorks"
|
||||
|
||||
# else /* unknown platform */
|
||||
# define PLATFORM_ID
|
||||
# endif
|
||||
|
||||
#elif defined(__INTEGRITY)
|
||||
# if defined(INT_178B)
|
||||
# define PLATFORM_ID "Integrity178"
|
||||
|
||||
# else /* regular Integrity */
|
||||
# define PLATFORM_ID "Integrity"
|
||||
# endif
|
||||
|
||||
#else /* unknown platform */
|
||||
# define PLATFORM_ID
|
||||
|
||||
#endif
|
||||
|
||||
/* For windows compilers MSVC and Intel we can determine
|
||||
the architecture of the compiler being used. This is because
|
||||
the compilers do not have flags that can change the architecture,
|
||||
but rather depend on which compiler is being used
|
||||
*/
|
||||
#if defined(_WIN32) && defined(_MSC_VER)
|
||||
# if defined(_M_IA64)
|
||||
# define ARCHITECTURE_ID "IA64"
|
||||
|
||||
# elif defined(_M_ARM64EC)
|
||||
# define ARCHITECTURE_ID "ARM64EC"
|
||||
|
||||
# elif defined(_M_X64) || defined(_M_AMD64)
|
||||
# define ARCHITECTURE_ID "x64"
|
||||
|
||||
# elif defined(_M_IX86)
|
||||
# define ARCHITECTURE_ID "X86"
|
||||
|
||||
# elif defined(_M_ARM64)
|
||||
# define ARCHITECTURE_ID "ARM64"
|
||||
|
||||
# elif defined(_M_ARM)
|
||||
# if _M_ARM == 4
|
||||
# define ARCHITECTURE_ID "ARMV4I"
|
||||
# elif _M_ARM == 5
|
||||
# define ARCHITECTURE_ID "ARMV5I"
|
||||
# else
|
||||
# define ARCHITECTURE_ID "ARMV" STRINGIFY(_M_ARM)
|
||||
# endif
|
||||
|
||||
# elif defined(_M_MIPS)
|
||||
# define ARCHITECTURE_ID "MIPS"
|
||||
|
||||
# elif defined(_M_SH)
|
||||
# define ARCHITECTURE_ID "SHx"
|
||||
|
||||
# else /* unknown architecture */
|
||||
# define ARCHITECTURE_ID ""
|
||||
# endif
|
||||
|
||||
#elif defined(__WATCOMC__)
|
||||
# if defined(_M_I86)
|
||||
# define ARCHITECTURE_ID "I86"
|
||||
|
||||
# elif defined(_M_IX86)
|
||||
# define ARCHITECTURE_ID "X86"
|
||||
|
||||
# else /* unknown architecture */
|
||||
# define ARCHITECTURE_ID ""
|
||||
# endif
|
||||
|
||||
#elif defined(__IAR_SYSTEMS_ICC__) || defined(__IAR_SYSTEMS_ICC)
|
||||
# if defined(__ICCARM__)
|
||||
# define ARCHITECTURE_ID "ARM"
|
||||
|
||||
# elif defined(__ICCRX__)
|
||||
# define ARCHITECTURE_ID "RX"
|
||||
|
||||
# elif defined(__ICCRH850__)
|
||||
# define ARCHITECTURE_ID "RH850"
|
||||
|
||||
# elif defined(__ICCRL78__)
|
||||
# define ARCHITECTURE_ID "RL78"
|
||||
|
||||
# elif defined(__ICCRISCV__)
|
||||
# define ARCHITECTURE_ID "RISCV"
|
||||
|
||||
# elif defined(__ICCAVR__)
|
||||
# define ARCHITECTURE_ID "AVR"
|
||||
|
||||
# elif defined(__ICC430__)
|
||||
# define ARCHITECTURE_ID "MSP430"
|
||||
|
||||
# elif defined(__ICCV850__)
|
||||
# define ARCHITECTURE_ID "V850"
|
||||
|
||||
# elif defined(__ICC8051__)
|
||||
# define ARCHITECTURE_ID "8051"
|
||||
|
||||
# elif defined(__ICCSTM8__)
|
||||
# define ARCHITECTURE_ID "STM8"
|
||||
|
||||
# else /* unknown architecture */
|
||||
# define ARCHITECTURE_ID ""
|
||||
# endif
|
||||
|
||||
#elif defined(__ghs__)
|
||||
# if defined(__PPC64__)
|
||||
# define ARCHITECTURE_ID "PPC64"
|
||||
|
||||
# elif defined(__ppc__)
|
||||
# define ARCHITECTURE_ID "PPC"
|
||||
|
||||
# elif defined(__ARM__)
|
||||
# define ARCHITECTURE_ID "ARM"
|
||||
|
||||
# elif defined(__x86_64__)
|
||||
# define ARCHITECTURE_ID "x64"
|
||||
|
||||
# elif defined(__i386__)
|
||||
# define ARCHITECTURE_ID "X86"
|
||||
|
||||
# else /* unknown architecture */
|
||||
# define ARCHITECTURE_ID ""
|
||||
# endif
|
||||
|
||||
#elif defined(__TI_COMPILER_VERSION__)
|
||||
# if defined(__TI_ARM__)
|
||||
# define ARCHITECTURE_ID "ARM"
|
||||
|
||||
# elif defined(__MSP430__)
|
||||
# define ARCHITECTURE_ID "MSP430"
|
||||
|
||||
# elif defined(__TMS320C28XX__)
|
||||
# define ARCHITECTURE_ID "TMS320C28x"
|
||||
|
||||
# elif defined(__TMS320C6X__) || defined(_TMS320C6X)
|
||||
# define ARCHITECTURE_ID "TMS320C6x"
|
||||
|
||||
# else /* unknown architecture */
|
||||
# define ARCHITECTURE_ID ""
|
||||
# endif
|
||||
|
||||
#else
|
||||
# define ARCHITECTURE_ID
|
||||
#endif
|
||||
|
||||
/* Convert integer to decimal digit literals. */
|
||||
#define DEC(n) \
|
||||
('0' + (((n) / 10000000)%10)), \
|
||||
('0' + (((n) / 1000000)%10)), \
|
||||
('0' + (((n) / 100000)%10)), \
|
||||
('0' + (((n) / 10000)%10)), \
|
||||
('0' + (((n) / 1000)%10)), \
|
||||
('0' + (((n) / 100)%10)), \
|
||||
('0' + (((n) / 10)%10)), \
|
||||
('0' + ((n) % 10))
|
||||
|
||||
/* Convert integer to hex digit literals. */
|
||||
#define HEX(n) \
|
||||
('0' + ((n)>>28 & 0xF)), \
|
||||
('0' + ((n)>>24 & 0xF)), \
|
||||
('0' + ((n)>>20 & 0xF)), \
|
||||
('0' + ((n)>>16 & 0xF)), \
|
||||
('0' + ((n)>>12 & 0xF)), \
|
||||
('0' + ((n)>>8 & 0xF)), \
|
||||
('0' + ((n)>>4 & 0xF)), \
|
||||
('0' + ((n) & 0xF))
|
||||
|
||||
/* Construct a string literal encoding the version number. */
|
||||
#ifdef COMPILER_VERSION
|
||||
char const* info_version = "INFO" ":" "compiler_version[" COMPILER_VERSION "]";
|
||||
|
||||
/* Construct a string literal encoding the version number components. */
|
||||
#elif defined(COMPILER_VERSION_MAJOR)
|
||||
char const info_version[] = {
|
||||
'I', 'N', 'F', 'O', ':',
|
||||
'c','o','m','p','i','l','e','r','_','v','e','r','s','i','o','n','[',
|
||||
COMPILER_VERSION_MAJOR,
|
||||
# ifdef COMPILER_VERSION_MINOR
|
||||
'.', COMPILER_VERSION_MINOR,
|
||||
# ifdef COMPILER_VERSION_PATCH
|
||||
'.', COMPILER_VERSION_PATCH,
|
||||
# ifdef COMPILER_VERSION_TWEAK
|
||||
'.', COMPILER_VERSION_TWEAK,
|
||||
# endif
|
||||
# endif
|
||||
# endif
|
||||
']','\0'};
|
||||
#endif
|
||||
|
||||
/* Construct a string literal encoding the internal version number. */
|
||||
#ifdef COMPILER_VERSION_INTERNAL
|
||||
char const info_version_internal[] = {
|
||||
'I', 'N', 'F', 'O', ':',
|
||||
'c','o','m','p','i','l','e','r','_','v','e','r','s','i','o','n','_',
|
||||
'i','n','t','e','r','n','a','l','[',
|
||||
COMPILER_VERSION_INTERNAL,']','\0'};
|
||||
#elif defined(COMPILER_VERSION_INTERNAL_STR)
|
||||
char const* info_version_internal = "INFO" ":" "compiler_version_internal[" COMPILER_VERSION_INTERNAL_STR "]";
|
||||
#endif
|
||||
|
||||
/* Construct a string literal encoding the version number components. */
|
||||
#ifdef SIMULATE_VERSION_MAJOR
|
||||
char const info_simulate_version[] = {
|
||||
'I', 'N', 'F', 'O', ':',
|
||||
's','i','m','u','l','a','t','e','_','v','e','r','s','i','o','n','[',
|
||||
SIMULATE_VERSION_MAJOR,
|
||||
# ifdef SIMULATE_VERSION_MINOR
|
||||
'.', SIMULATE_VERSION_MINOR,
|
||||
# ifdef SIMULATE_VERSION_PATCH
|
||||
'.', SIMULATE_VERSION_PATCH,
|
||||
# ifdef SIMULATE_VERSION_TWEAK
|
||||
'.', SIMULATE_VERSION_TWEAK,
|
||||
# endif
|
||||
# endif
|
||||
# endif
|
||||
']','\0'};
|
||||
#endif
|
||||
|
||||
/* Construct the string literal in pieces to prevent the source from
|
||||
getting matched. Store it in a pointer rather than an array
|
||||
because some compilers will just produce instructions to fill the
|
||||
array rather than assigning a pointer to a static array. */
|
||||
char const* info_platform = "INFO" ":" "platform[" PLATFORM_ID "]";
|
||||
char const* info_arch = "INFO" ":" "arch[" ARCHITECTURE_ID "]";
|
||||
|
||||
|
||||
|
||||
#if !defined(__STDC__) && !defined(__clang__)
|
||||
# if defined(_MSC_VER) || defined(__ibmxl__) || defined(__IBMC__)
|
||||
# define C_VERSION "90"
|
||||
# else
|
||||
# define C_VERSION
|
||||
# endif
|
||||
#elif __STDC_VERSION__ > 201710L
|
||||
# define C_VERSION "23"
|
||||
#elif __STDC_VERSION__ >= 201710L
|
||||
# define C_VERSION "17"
|
||||
#elif __STDC_VERSION__ >= 201000L
|
||||
# define C_VERSION "11"
|
||||
#elif __STDC_VERSION__ >= 199901L
|
||||
# define C_VERSION "99"
|
||||
#else
|
||||
# define C_VERSION "90"
|
||||
#endif
|
||||
const char* info_language_standard_default =
|
||||
"INFO" ":" "standard_default[" C_VERSION "]";
|
||||
|
||||
const char* info_language_extensions_default = "INFO" ":" "extensions_default["
|
||||
/* !defined(_MSC_VER) to exclude Clang's MSVC compatibility mode. */
|
||||
#if (defined(__clang__) || defined(__GNUC__) || \
|
||||
defined(__TI_COMPILER_VERSION__)) && \
|
||||
!defined(__STRICT_ANSI__) && !defined(_MSC_VER)
|
||||
"ON"
|
||||
#else
|
||||
"OFF"
|
||||
#endif
|
||||
"]";
|
||||
|
||||
/*--------------------------------------------------------------------------*/
|
||||
|
||||
#ifdef ID_VOID_MAIN
|
||||
void main() {}
|
||||
#else
|
||||
# if defined(__CLASSIC_C__)
|
||||
int main(argc, argv) int argc; char *argv[];
|
||||
# else
|
||||
int main(int argc, char* argv[])
|
||||
# endif
|
||||
{
|
||||
int require = 0;
|
||||
require += info_compiler[argc];
|
||||
require += info_platform[argc];
|
||||
require += info_arch[argc];
|
||||
#ifdef COMPILER_VERSION_MAJOR
|
||||
require += info_version[argc];
|
||||
#endif
|
||||
#ifdef COMPILER_VERSION_INTERNAL
|
||||
require += info_version_internal[argc];
|
||||
#endif
|
||||
#ifdef SIMULATE_ID
|
||||
require += info_simulate[argc];
|
||||
#endif
|
||||
#ifdef SIMULATE_VERSION_MAJOR
|
||||
require += info_simulate_version[argc];
|
||||
#endif
|
||||
#if defined(__CRAYXT_COMPUTE_LINUX_TARGET)
|
||||
require += info_cray[argc];
|
||||
#endif
|
||||
require += info_language_standard_default[argc];
|
||||
require += info_language_extensions_default[argc];
|
||||
(void)argv;
|
||||
return require;
|
||||
}
|
||||
#endif
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
E:/MyProject/ts-mobile-go/go/teamspeak/.opus/build/arm64-v8a/CMakeFiles/opus.dir
|
||||
E:/MyProject/ts-mobile-go/go/teamspeak/.opus/build/arm64-v8a/CMakeFiles/edit_cache.dir
|
||||
E:/MyProject/ts-mobile-go/go/teamspeak/.opus/build/arm64-v8a/CMakeFiles/rebuild_cache.dir
|
||||
E:/MyProject/ts-mobile-go/go/teamspeak/.opus/build/arm64-v8a/CMakeFiles/list_install_components.dir
|
||||
E:/MyProject/ts-mobile-go/go/teamspeak/.opus/build/arm64-v8a/CMakeFiles/install.dir
|
||||
E:/MyProject/ts-mobile-go/go/teamspeak/.opus/build/arm64-v8a/CMakeFiles/install/local.dir
|
||||
E:/MyProject/ts-mobile-go/go/teamspeak/.opus/build/arm64-v8a/CMakeFiles/install/strip.dir
|
||||
@@ -0,0 +1 @@
|
||||
# This file is generated by cmake for dependency checking of the CMakeCache.txt file
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user