Files
go-ts/internal/client/commands.go
2026-01-15 22:06:35 +01:00

418 lines
12 KiB
Go

package client
import (
"encoding/binary"
"fmt"
"log"
"strings"
"go-ts/pkg/protocol"
"github.com/dgryski/go-quicklz"
)
func (c *Client) handleCommand(pkt *protocol.Packet) error {
// Check if Encrypted
// PacketTypeCommand is usually encrypted.
// Flag check? The flag is in the Header (e.g. Unencrypted flag).
// If Unencrypted flag is SET, it's cleartext.
// Spec: "Command ... Encrypted: ✓". So Unencrypted flag is CLEARED.
// Decrypt if necessary
var data []byte
var err error
if pkt.Header.FlagUnencrypted() {
data = pkt.Data
} else {
var key, nonce []byte
decrypted := false
// 1. Try SharedSecret if available
if c.Handshake != nil && c.Handshake.Step >= 6 && len(c.Handshake.SharedIV) > 0 {
// Use SharedSecret-based encryption
crypto := &protocol.CryptoState{
SharedIV: c.Handshake.SharedIV,
SharedMac: c.Handshake.SharedMac,
GenerationID: 0,
}
// Server->Client = false
key, nonce = crypto.GenerateKeyNonce(&pkt.Header, false)
// AAD for Server->Client: PacketID (2) + Type|Flags (1)
meta := make([]byte, 3)
binary.BigEndian.PutUint16(meta[0:2], pkt.Header.PacketID)
meta[2] = pkt.Header.Type // Type includes Flags
data, err = protocol.DecryptEAX(key, nonce, meta, pkt.Data, pkt.Header.MAC[:])
if err == nil {
decrypted = true
} else {
log.Printf("SharedSecret decrypt failed (PID=%d): %v. Trying HandshakeKey...", pkt.Header.PacketID, err)
}
}
// 2. Fallback to HandshakeKey
if !decrypted {
key = protocol.HandshakeKey[:]
nonce = protocol.HandshakeNonce[:]
// AAD matching KeyNonce derivation context?
// HandshakeKey usage usually has same AAD requirements?
meta := make([]byte, 3)
binary.BigEndian.PutUint16(meta[0:2], pkt.Header.PacketID)
meta[2] = pkt.Header.Type // Type includes Flags
data, err = protocol.DecryptEAX(key, nonce, meta, pkt.Data, pkt.Header.MAC[:])
if err != nil {
log.Printf("All decryption attempts failed for PID=%d: %v", pkt.Header.PacketID, err)
return fmt.Errorf("decryption failed: %v", err)
}
}
}
// On first encrypted command set Connected = true (Fallback if ACK missed)
if !c.Connected && pkt.Header.PacketID > 2 {
c.Connected = true
}
// Fragment reassembly logic:
// - First fragment: Fragmented=true, optionally Compressed=true -> start buffer
// - Middle fragments: Fragmented=false, Compressed=false -> append to buffer
// - Last fragment: Fragmented=true -> append and process
isFragmented := pkt.Header.FlagFragmented()
if isFragmented && !c.Fragmenting {
// First fragment - start collecting
c.Fragmenting = true
c.FragmentBuffer = make([]byte, 0, 4096)
c.FragmentBuffer = append(c.FragmentBuffer, data...)
c.FragmentStartPktID = pkt.Header.PacketID
c.FragmentCompressed = pkt.Header.FlagCompressed()
log.Printf("Fragment start (PID=%d, Compressed=%v, Len=%d)", pkt.Header.PacketID, c.FragmentCompressed, len(data))
return nil // Wait for more fragments
} else if c.Fragmenting && !isFragmented {
// Middle fragment - append
c.FragmentBuffer = append(c.FragmentBuffer, data...)
log.Printf("Fragment continue (PID=%d, TotalLen=%d)", pkt.Header.PacketID, len(c.FragmentBuffer))
return nil // Wait for more fragments
} else if c.Fragmenting && isFragmented {
// Last fragment - complete reassembly
c.FragmentBuffer = append(c.FragmentBuffer, data...)
log.Printf("Fragment end (PID=%d, TotalLen=%d)", pkt.Header.PacketID, len(c.FragmentBuffer))
data = c.FragmentBuffer
// Decompress if first fragment was compressed
if c.FragmentCompressed {
decompressed, err := quicklz.Decompress(data)
if err != nil {
log.Printf("QuickLZ decompression of fragmented data failed: %v", err)
// Fallback to raw data
} else {
log.Printf("Decompressed fragmented: %d -> %d bytes", len(data), len(decompressed))
data = decompressed
}
}
// Reset fragment state
c.Fragmenting = false
c.FragmentBuffer = nil
} else {
// Non-fragmented packet - decompress if needed
if pkt.Header.FlagCompressed() {
decompressed, err := quicklz.Decompress(data)
if err != nil {
log.Printf("QuickLZ decompression failed: %v (falling back to raw)", err)
// Fallback to raw data - might not be compressed despite flag
} else {
log.Printf("Decompressed: %d -> %d bytes", len(data), len(decompressed))
data = decompressed
}
}
}
cmdStr := string(data)
// Debug: Log packet flags and raw command preview
log.Printf("Debug Packet: Compressed=%v, Fragmented=%v, RawLen=%d, Preview=%q",
pkt.Header.FlagCompressed(), pkt.Header.FlagFragmented(), len(data),
func() string {
if len(cmdStr) > 100 {
return cmdStr[:100]
}
return cmdStr
}())
// Fix Garbage Headers (TS3 often sends binary garbage before command)
// Scan for first valid lower case [a-z] char (Most commands are lowercase)
validStart := strings.IndexFunc(cmdStr, func(r rune) bool {
return (r >= 'a' && r <= 'z')
})
if validStart > 0 && validStart < 50 {
cmdStr = cmdStr[validStart:]
}
log.Printf("Command: %s", cmdStr)
// Parse Command
cmd, args := protocol.ParseCommand([]byte(cmdStr))
switch cmd {
case "initivexpand2":
err := c.Handshake.ProcessInitivexpand2(args)
if err != nil {
log.Printf("Error processing initivexpand2: %v", err)
}
case "initserver":
// Server sends this after clientinit - contains our clientID
if cid, ok := args["aclid"]; ok {
var id uint64
fmt.Sscanf(cid, "%d", &id)
c.ClientID = uint16(id)
log.Printf("Assigned ClientID: %d", c.ClientID)
}
if name, ok := args["virtualserver_name"]; ok {
c.ServerName = protocol.Unescape(name)
log.Printf("Server Name: %s", c.ServerName)
}
c.emitEvent("connected", map[string]any{
"clientID": c.ClientID,
"serverName": c.ServerName,
})
case "channellist":
// Parse channel info
ch := &Channel{}
if cid, ok := args["cid"]; ok {
fmt.Sscanf(cid, "%d", &ch.ID)
}
if pid, ok := args["cpid"]; ok {
fmt.Sscanf(pid, "%d", &ch.ParentID)
}
if name, ok := args["channel_name"]; ok {
ch.Name = protocol.Unescape(name)
}
if order, ok := args["channel_order"]; ok {
fmt.Sscanf(order, "%d", &ch.Order)
}
c.Channels[ch.ID] = ch
log.Printf("Channel: [%d] NameRaw=%q Order=%d Args=%v", ch.ID, ch.Name, ch.Order, args)
case "channellistfinished":
log.Printf("=== Channel List Complete (%d channels) ===", len(c.Channels))
var channelList []*Channel
var targetChan *Channel
for _, ch := range c.Channels {
log.Printf(" - [%d] %s (parent=%d)", ch.ID, ch.Name, ch.ParentID)
channelList = append(channelList, ch)
if ch.Name == "Test" {
targetChan = ch
}
}
c.emitEvent("channel_list", map[string]any{
"channels": channelList,
})
if targetChan == nil {
if ch, ok := c.Channels[2]; ok {
log.Printf("Name parsing failed. Defaulting to Channel 2 as 'Test'.")
targetChan = ch
}
}
if targetChan != nil {
log.Printf("Found target channel 'Test' (ID=%d). Joining...", targetChan.ID)
if c.ClientID == 0 {
log.Println("ERROR: ClientID is 0. Cannot join channel. 'initserver' missing?")
return nil
}
moveCmd := protocol.NewCommand("clientmove")
moveCmd.AddParam("clid", fmt.Sprintf("%d", c.ClientID))
moveCmd.AddParam("cid", fmt.Sprintf("%d", targetChan.ID))
moveCmd.AddParam("cpw", "")
return c.SendCommand(moveCmd)
}
case "notifycliententerview":
// A client entered the server
nick := ""
clientID := uint16(0)
channelID := uint64(0)
if n, ok := args["client_nickname"]; ok {
nick = protocol.Unescape(n)
}
if cid, ok := args["clid"]; ok {
var id uint64
fmt.Sscanf(cid, "%d", &id)
clientID = uint16(id)
}
if ctid, ok := args["ctid"]; ok {
fmt.Sscanf(ctid, "%d", &channelID)
}
log.Printf("Client entered: %s (ID=%d)", nick, clientID)
c.emitEvent("client_enter", map[string]any{
"clientID": clientID,
"nickname": nick,
"channelID": channelID,
})
case "notifytextmessage":
// targetmode: 1=Private, 2=Channel, 3=Server
msg := ""
invoker := "Unknown"
var invokerID uint16
var targetModeInt int
if m, ok := args["msg"]; ok {
msg = protocol.Unescape(m)
}
if name, ok := args["invokername"]; ok {
invoker = protocol.Unescape(name)
}
if iid, ok := args["invokerid"]; ok {
var id uint64
fmt.Sscanf(iid, "%d", &id)
invokerID = uint16(id)
}
targetMode := "Unknown"
if tm, ok := args["targetmode"]; ok {
switch tm {
case "1":
targetMode = "Private"
targetModeInt = 1
case "2":
targetMode = "Channel"
targetModeInt = 2
case "3":
targetMode = "Server"
targetModeInt = 3
}
}
log.Printf("[Chat][%s] %s: %s", targetMode, invoker, msg)
c.emitEvent("message", map[string]any{
"senderID": invokerID,
"senderName": invoker,
"message": msg,
"targetMode": targetModeInt,
})
case "notifyclientchatcomposing":
// Someone is typing
// We only get clid, need to map to name if possible, or just log clid
clid := "Unknown"
if id, ok := args["clid"]; ok {
clid = id
}
log.Printf("Client %s is typing...", clid)
case "notifyclientmoved":
// Client moved to another channel
var clientID uint16
var channelID uint64
if cid, ok := args["clid"]; ok {
var id uint64
fmt.Sscanf(cid, "%d", &id)
clientID = uint16(id)
}
if ctid, ok := args["ctid"]; ok {
fmt.Sscanf(ctid, "%d", &channelID)
}
log.Printf("Client %d moved to Channel %d", clientID, channelID)
c.emitEvent("client_moved", map[string]any{
"clientID": clientID,
"channelID": channelID,
})
case "notifyclientchannelgroupchanged":
// Client channel group changed
// invokerid=0 invokername=Server cgid=8 cid=1 clid=3 cgi=1
invoker := "Unknown"
if name, ok := args["invokername"]; ok {
invoker = protocol.Unescape(name)
}
log.Printf("Client %s channel group changed to %s in Channel %s by %s",
args["clid"], args["cgid"], args["cid"], invoker)
case "notifyconnectioninforequest":
// Server asking for connection info. We MUST reply to update Ping in UI and avoid timeout.
log.Println("Server requested connection info. sending 'setconnectioninfo'...")
cmd := protocol.NewCommand("setconnectioninfo")
cmd.AddParam("connection_ping", "50")
cmd.AddParam("connection_ping_deviation", "5")
// Detailed stats for each kind as seen in ts3j (KEEPALIVE, SPEECH, CONTROL)
kinds := []string{"keepalive", "speech", "control"}
for _, k := range kinds {
cmd.AddParam("connection_packets_sent_"+k, "500")
cmd.AddParam("connection_packets_received_"+k, "500")
cmd.AddParam("connection_bytes_sent_"+k, "25000")
cmd.AddParam("connection_bytes_received_"+k, "25000")
cmd.AddParam("connection_bandwidth_sent_last_second_"+k, "200")
cmd.AddParam("connection_bandwidth_received_last_second_"+k, "200")
cmd.AddParam("connection_bandwidth_sent_last_minute_"+k, "200")
cmd.AddParam("connection_bandwidth_received_last_minute_"+k, "200")
cmd.AddParam("connection_server2client_packetloss_"+k, "0")
}
cmd.AddParam("connection_server2client_packetloss_total", "0")
return c.SendCommand(cmd)
case "notifyclientleftview":
// Client left the server
var clientID uint16
reason := ""
if cid, ok := args["clid"]; ok {
var id uint64
fmt.Sscanf(cid, "%d", &id)
clientID = uint16(id)
}
if rid, ok := args["reasonid"]; ok {
switch rid {
case "3":
reason = "connection lost"
case "5":
reason = "kicked"
case "6":
reason = "banned"
case "8":
reason = "leaving"
default:
reason = "unknown"
}
}
if rmsg, ok := args["reasonmsg"]; ok {
if rmsg != "" {
reason = protocol.Unescape(rmsg)
}
}
log.Printf("Client %d left: %s", clientID, reason)
c.emitEvent("client_left", map[string]any{
"clientID": clientID,
"reason": reason,
})
case "notifyclientupdated":
// Client updated (e.g. muted/unmuted)
clid := args["clid"]
log.Printf("Client %s updated: %v", clid, args)
case "error":
// Server reported an error
id := args["id"]
msg := protocol.Unescape(args["msg"])
log.Printf("SERVER ERROR: ID=%s MSG=%s", id, msg)
c.emitEvent("error", map[string]any{
"id": id,
"message": msg,
})
case "notifyservergrouplist", "notifychannelgrouplist", "notifyclientneededpermissions":
// Ignore verbose noisy setup commands
default:
log.Printf("Unhandled command: %s Args: %v", cmd, args)
}
return nil
}