首次推送

This commit is contained in:
sansen
2026-07-20 19:01:03 +08:00
parent ea01b9cf99
commit ef1bf61f9f
4484 changed files with 937163 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
[3/4] Building Android libopus and Go bridge -
@@ -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.
[![CI](https://github.com/honeybbq/teamspeak-go/actions/workflows/ci.yml/badge.svg)](https://github.com/honeybbq/teamspeak-go/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/honeybbq/teamspeak-go/branch/main/graph/badge.svg)](https://codecov.io/gh/honeybbq/teamspeak-go)
[![Go Report Card](https://goreportcard.com/badge/github.com/honeybbq/teamspeak-go)](https://goreportcard.com/report/github.com/honeybbq/teamspeak-go)
[![Go Reference](https://pkg.go.dev/badge/github.com/honeybbq/teamspeak-go.svg)](https://pkg.go.dev/github.com/honeybbq/teamspeak-go)
[![Go Version](https://img.shields.io/github/go-mod/go-version/honeybbq/teamspeak-go)](go.mod)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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)
}
+47
View File
@@ -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
)
+55
View File
@@ -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
+20
View File
@@ -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
+24
View File
@@ -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
@@ -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 "")
@@ -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)
@@ -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
@@ -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

Some files were not shown because too many files have changed in this diff Show More