552 lines
15 KiB
Go
552 lines
15 KiB
Go
package client
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"go-ts/pkg/protocol"
|
|
|
|
"github.com/dgryski/go-quicklz"
|
|
)
|
|
|
|
// sanitizeForLog removes control characters that can corrupt terminal output
|
|
func sanitizeForLog(s string) string {
|
|
var result strings.Builder
|
|
result.Grow(len(s))
|
|
for _, r := range s {
|
|
if r >= 32 && r < 127 {
|
|
// Printable ASCII
|
|
result.WriteRune(r)
|
|
} else if unicode.IsPrint(r) && r < 256 {
|
|
// Printable extended ASCII
|
|
result.WriteRune(r)
|
|
} else if r == '\n' || r == '\r' || r == '\t' {
|
|
// Keep whitespace
|
|
result.WriteRune(r)
|
|
} else {
|
|
// Replace control characters with placeholder
|
|
result.WriteRune('.')
|
|
}
|
|
}
|
|
return result.String()
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Queue-based fragment reassembly (like ts3j)
|
|
// Store packet in queue
|
|
c.CommandQueue[pkt.Header.PacketID] = &protocol.Packet{
|
|
Header: pkt.Header,
|
|
Data: append([]byte{}, data...), // Clone data (already decrypted)
|
|
}
|
|
|
|
// Try to process packets in order
|
|
for {
|
|
nextPkt, ok := c.CommandQueue[c.ExpectedCommandPID]
|
|
if !ok {
|
|
// Missing packet, wait for it
|
|
break
|
|
}
|
|
|
|
isFragmented := nextPkt.Header.FlagFragmented()
|
|
|
|
if isFragmented {
|
|
// Toggle fragment state
|
|
c.FragmentState = !c.FragmentState
|
|
|
|
if c.FragmentState {
|
|
// Starting a new fragment sequence
|
|
// Don't process yet, wait for more
|
|
c.ExpectedCommandPID++
|
|
continue
|
|
} else {
|
|
// Ending fragment sequence - reassemble all
|
|
reassembled, compressed := c.reassembleFragments()
|
|
if reassembled == nil {
|
|
log.Printf("Fragment reassembly failed")
|
|
break
|
|
}
|
|
data = reassembled
|
|
|
|
// Decompress if first packet was compressed
|
|
if compressed {
|
|
decompressed, err := quicklz.Decompress(data)
|
|
if err != nil {
|
|
log.Printf("QuickLZ decompression of fragmented data failed: %v", err)
|
|
} else {
|
|
log.Printf("Decompressed fragmented: %d -> %d bytes", len(data), len(decompressed))
|
|
data = decompressed
|
|
}
|
|
}
|
|
}
|
|
} else if c.FragmentState {
|
|
// Middle fragment - keep collecting
|
|
c.ExpectedCommandPID++
|
|
continue
|
|
} else {
|
|
// Non-fragmented packet - process normally
|
|
data = nextPkt.Data
|
|
|
|
// Decompress if needed
|
|
if nextPkt.Header.FlagCompressed() {
|
|
decompressed, err := quicklz.Decompress(data)
|
|
if err != nil {
|
|
log.Printf("QuickLZ decompression failed: %v (falling back to raw)", err)
|
|
} else {
|
|
log.Printf("Decompressed: %d -> %d bytes", len(data), len(decompressed))
|
|
data = decompressed
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove processed packet from queue
|
|
delete(c.CommandQueue, c.ExpectedCommandPID)
|
|
c.ExpectedCommandPID++
|
|
|
|
// Process the command
|
|
if err := c.processCommand(data, nextPkt); err != nil {
|
|
log.Printf("Error processing command: %v", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// reassembleFragments collects all buffered fragments in order and returns reassembled data
|
|
func (c *Client) reassembleFragments() ([]byte, bool) {
|
|
var result []byte
|
|
compressed := false
|
|
|
|
// Find the start of the fragment sequence (scan backwards from current)
|
|
startPID := c.ExpectedCommandPID
|
|
for {
|
|
prevPID := startPID - 1
|
|
pkt, ok := c.CommandQueue[prevPID]
|
|
if !ok {
|
|
break
|
|
}
|
|
// Check if this is the start (has Fragmented flag)
|
|
if pkt.Header.FlagFragmented() {
|
|
startPID = prevPID
|
|
break
|
|
}
|
|
startPID = prevPID
|
|
}
|
|
|
|
// Now collect from startPID to ExpectedCommandPID (inclusive)
|
|
for pid := startPID; pid <= c.ExpectedCommandPID; pid++ {
|
|
pkt, ok := c.CommandQueue[pid]
|
|
if !ok {
|
|
log.Printf("Missing fragment PID=%d during reassembly", pid)
|
|
return nil, false
|
|
}
|
|
|
|
// First fragment may have compressed flag
|
|
if pid == startPID && pkt.Header.FlagCompressed() {
|
|
compressed = true
|
|
}
|
|
|
|
result = append(result, pkt.Data...)
|
|
delete(c.CommandQueue, pid)
|
|
}
|
|
|
|
log.Printf("Reassembled fragments PID %d-%d, total %d bytes, compressed=%v",
|
|
startPID, c.ExpectedCommandPID, len(result), compressed)
|
|
|
|
return result, compressed
|
|
}
|
|
|
|
// processCommand handles a single fully reassembled command
|
|
func (c *Client) processCommand(data []byte, pkt *protocol.Packet) error {
|
|
cmdStr := string(data)
|
|
|
|
// Debug: Log packet flags and raw command preview (sanitized)
|
|
log.Printf("Debug Packet: Compressed=%v, Fragmented=%v, RawLen=%d, Preview=%q",
|
|
pkt.Header.FlagCompressed(), pkt.Header.FlagFragmented(), len(data),
|
|
func() string {
|
|
preview := cmdStr
|
|
if len(preview) > 100 {
|
|
preview = preview[:100]
|
|
}
|
|
return sanitizeForLog(preview)
|
|
}())
|
|
|
|
// 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", sanitizeForLog(cmdStr))
|
|
|
|
// Parse Commands (possibly multiple piped items)
|
|
commands := protocol.ParseCommands([]byte(cmdStr))
|
|
|
|
for _, command := range commands {
|
|
cmd := command.Name
|
|
args := command.Params
|
|
|
|
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 "clientlist":
|
|
// Parse client info (usually received after connection for all clients)
|
|
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["cid"]; ok {
|
|
fmt.Sscanf(ctid, "%d", &channelID)
|
|
}
|
|
|
|
// Don't emit for ourselves if we already handle it in initserver
|
|
if clientID != c.ClientID && clientID != 0 {
|
|
log.Printf("Existing client: %s (ID=%d) in Channel %d", nick, clientID, channelID)
|
|
c.emitEvent("client_enter", map[string]any{
|
|
"clientID": clientID,
|
|
"nickname": nick,
|
|
"channelID": channelID,
|
|
})
|
|
}
|
|
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")
|
|
|
|
// Use real ping values if available, otherwise default to 50ms
|
|
pingMs := c.PingRTT
|
|
pingDev := c.PingDeviation
|
|
if pingMs == 0 {
|
|
pingMs = 50.0 // Default before first measurement
|
|
pingDev = 5.0
|
|
}
|
|
|
|
cmd.AddParam("connection_ping", fmt.Sprintf("%.4f", pingMs))
|
|
cmd.AddParam("connection_ping_deviation", fmt.Sprintf("%.4f", pingDev))
|
|
|
|
// 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)
|
|
}
|
|
} // End for loop
|
|
|
|
return nil
|
|
}
|