diff --git a/.gitignore b/.gitignore index 54634d2..d37f75b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ +*.exe +*.log ts3j/ +bin/ +vendor/ +.gemini/ diff --git a/client.exe b/client.exe deleted file mode 100644 index 5b8e318..0000000 Binary files a/client.exe and /dev/null differ diff --git a/cmd/tui/main.go b/cmd/tui/main.go index 3ecc2f4..cbf3290 100644 --- a/cmd/tui/main.go +++ b/cmd/tui/main.go @@ -6,6 +6,7 @@ import ( "io" "log" "os" + "time" tea "github.com/charmbracelet/bubbletea" ) @@ -23,7 +24,7 @@ func debugLog(format string, args ...any) { func main() { serverAddr := flag.String("server", "127.0.0.1:9987", "TeamSpeak 3 Server Address") nickname := flag.String("nickname", "TUI-User", "Your nickname") - debug := flag.Bool("debug", false, "Enable debug logging to tui-debug.log") + debug := flag.Bool("debug", true, "Enable debug logging to file (default true)") flag.Parse() // Disable log output completely to prevent TUI corruption @@ -32,10 +33,12 @@ func main() { // Enable debug file logging if requested if *debug { var err error - debugFile, err = os.Create("tui-debug.log") + timestamp := time.Now().Format("20060102-150405") + filename := fmt.Sprintf("tui-%s.log", timestamp) + debugFile, err = os.Create(filename) if err == nil { defer debugFile.Close() - debugLog("TUI Debug started") + debugLog("TUI Debug started at %s", timestamp) } } @@ -45,6 +48,32 @@ func main() { // Create Bubble Tea program p := tea.NewProgram(m, tea.WithAltScreen()) + // Set up log capture + r, w, _ := os.Pipe() + if debugFile != nil { + log.SetOutput(io.MultiWriter(w, debugFile)) + } else { + log.SetOutput(w) + } + // Make sure logs have timestamp removed (TUI adds it if needed, or we keep it) + log.SetFlags(log.Ltime) // Just time + + go func() { + buf := make([]byte, 1024) + for { + n, err := r.Read(buf) + if err != nil { + break + } + if n > 0 { + lines := string(buf[:n]) + // Split by newline and send each line + // Simple split, might need better buffering for partial lines but OK for debug + p.Send(logMsg(lines)) + } + } + }() + // Run if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) diff --git a/cmd/tui/model.go b/cmd/tui/model.go index c5266e7..3dd7135 100644 --- a/cmd/tui/model.go +++ b/cmd/tui/model.go @@ -3,6 +3,7 @@ package main import ( "fmt" "sort" + "strings" "time" "go-ts/pkg/ts3client" @@ -64,6 +65,7 @@ type Model struct { selectedIdx int chatMessages []ChatMessage logMessages []string // Debug logs shown in chat panel + logFullscreen bool // Toggle fullscreen log view inputText string inputActive bool @@ -226,11 +228,18 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case errorMsg: m.lastError = msg.err return m, nil + + case logMsg: + // External log message + m.addLog("%s", string(msg)) + return m, nil } return m, nil } +type logMsg string + func (m *Model) updateChannelList(channels []*ts3client.Channel) { // Sort channels by ID for stable ordering sortedChannels := make([]*ts3client.Channel, len(channels)) @@ -259,6 +268,11 @@ func (m *Model) updateChannelList(channels []*ts3client.Channel) { } } + // Sort users by ID for stable ordering + sort.Slice(node.Users, func(i, j int) bool { + return node.Users[i].ID < node.Users[j].ID + }) + m.channels = append(m.channels, node) } } @@ -288,8 +302,17 @@ func (m *Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleChannelKeys(msg) case FocusInput: return m.handleInputKeys(msg) + case FocusChat: + return m.handleChatKeys(msg) } + return m, nil +} +func (m *Model) handleChatKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "f": + m.logFullscreen = !m.logFullscreen + } return m, nil } @@ -307,7 +330,11 @@ func (m *Model) handleChannelKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Join selected channel if m.selectedIdx < len(m.channels) && m.client != nil { ch := m.channels[m.selectedIdx] - m.client.JoinChannel(ch.ID) + m.addLog("Attempting to join channel: %s (ID=%d)", ch.Name, ch.ID) + err := m.client.JoinChannel(ch.ID) + if err != nil { + m.addLog("Error joining channel: %v", err) + } } } return m, nil @@ -342,6 +369,18 @@ func (m *Model) View() string { return "Loading..." } + // Fullscreen Log Mode + if m.logFullscreen { + style := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("212")). // Active color + Padding(0, 1). + Width(m.width - 2). + Height(m.height - 2) + + return style.Render(m.renderChat()) + } + // Styles headerStyle := lipgloss.NewStyle(). Bold(true). @@ -463,12 +502,30 @@ func (m *Model) renderChat() string { if maxLines < 5 { maxLines = 5 } + start := 0 if len(m.logMessages) > maxLines { start = len(m.logMessages) - maxLines } + + panelWidth := (m.width / 2) - 4 + if m.logFullscreen { + panelWidth = m.width - 6 + } + if panelWidth < 10 { + panelWidth = 10 + } + for _, msg := range m.logMessages[start:] { - lines = append(lines, lipgloss.NewStyle().Faint(true).Render(msg)) + // Truncate if too long to prevent wrapping breaking the layout + displayMsg := msg + if len(displayMsg) > panelWidth { + displayMsg = displayMsg[:panelWidth-3] + "..." + } + // Replace newlines just in case + displayMsg = strings.ReplaceAll(displayMsg, "\n", " ") + + lines = append(lines, lipgloss.NewStyle().Faint(true).Render(displayMsg)) } } diff --git a/example.exe b/example.exe deleted file mode 100644 index 7595fee..0000000 Binary files a/example.exe and /dev/null differ diff --git a/go-ts.exe b/go-ts.exe deleted file mode 100644 index fca8b95..0000000 Binary files a/go-ts.exe and /dev/null differ diff --git a/internal/client/client.go b/internal/client/client.go index c148888..c642a36 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -1,6 +1,8 @@ package client import ( + "encoding/binary" + "fmt" "log" "sync" "time" @@ -29,8 +31,11 @@ type Client struct { ClientID uint16 // Counters - PacketIDCounterC2S uint16 - VoicePacketID uint16 + PacketIDCounterC2S uint16 // Commands (Type 0x02) + VoicePacketID uint16 // Voice (Type 0x00) + PingPacketID uint16 // Type 0x04 + PongPacketID uint16 // Type 0x05 + AckPacketID uint16 // Type 0x06 // State Connected bool @@ -136,25 +141,52 @@ func (c *Client) Connect(address string) error { if !c.Connected { continue // Don't send pings if not connected yet } - ping := protocol.NewPacket(protocol.PacketTypePing, nil) - c.PacketIDCounterC2S++ - ping.Header.PacketID = c.PacketIDCounterC2S - ping.Header.ClientID = c.ClientID - // Must NOT have NewProtocol (0x20) flag for Pings/Pongs - ping.Header.Type = uint8(protocol.PacketTypePing) | protocol.PacketFlagUnencrypted - - // Use SharedMac if available, otherwise zeros (as per ts3j InitPacketTransformation) - if c.Handshake != nil && len(c.Handshake.SharedMac) > 0 { - copy(ping.Header.MAC[:], c.Handshake.SharedMac) - } else { - // Initialize Header.MAC with zeros - for i := 0; i < 8; i++ { - ping.Header.MAC[i] = 0 - } + // Send KeepAlive Ping (Encrypted, No NewProtocol) + if err := c.sendPing(); err != nil { + log.Printf("Error sending Ping: %v", err) } - - log.Printf("Sending KeepAlive Ping (PID=%d)", ping.Header.PacketID) - c.Conn.SendPacket(ping) } } } + +// sendPing sends an encrypted Ping packet WITHOUT the NewProtocol flag +func (c *Client) sendPing() error { + pType := protocol.PacketTypePing + pkt := protocol.NewPacket(pType, nil) + c.PingPacketID++ + pkt.Header.PacketID = c.PingPacketID + pkt.Header.ClientID = c.ClientID + // Note: We do NOT set PacketFlagNewProtocol for Pings + + // Encryption + key := protocol.HandshakeKey[:] + nonce := protocol.HandshakeNonce[:] + + if c.Handshake != nil && c.Handshake.Step >= 6 && len(c.Handshake.SharedIV) > 0 { + crypto := &protocol.CryptoState{ + SharedIV: c.Handshake.SharedIV, + SharedMac: c.Handshake.SharedMac, + GenerationID: 0, + } + keyArr, nonceArr := crypto.GenerateKeyNonce(&pkt.Header, true) // Client->Server=true + key = keyArr + nonce = nonceArr + } + + // Meta for Client->Server: PID(2) + CID(2) + PT(1) = 5 bytes + meta := make([]byte, 5) + binary.BigEndian.PutUint16(meta[0:2], pkt.Header.PacketID) + binary.BigEndian.PutUint16(meta[2:4], pkt.Header.ClientID) + meta[4] = pkt.Header.Type + + encData, mac, err := protocol.EncryptEAX(key, nonce, meta, pkt.Data) + if err != nil { + return fmt.Errorf("encryption failed: %w", err) + } + + pkt.Data = encData + copy(pkt.Header.MAC[:], mac) + + log.Printf("Sending proper Encrypted Ping (PID=%d)", pkt.Header.PacketID) + return c.Conn.SendPacket(pkt) +} diff --git a/internal/client/commands.go b/internal/client/commands.go index de81810..ba7c7b3 100644 --- a/internal/client/commands.go +++ b/internal/client/commands.go @@ -331,6 +331,32 @@ func (c *Client) processCommand(data []byte, pkt *protocol.Packet) error { 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 := "" diff --git a/internal/client/packet.go b/internal/client/packet.go index 442168e..622feb4 100644 --- a/internal/client/packet.go +++ b/internal/client/packet.go @@ -20,8 +20,9 @@ func (c *Client) handlePacket(pkt *protocol.Packet) error { binary.BigEndian.PutUint16(ackData, pkt.Header.PacketID) ack := protocol.NewPacket(protocol.PacketTypeAck, ackData) - // Spec/ts3j: Header PID for ACK matches the packet being acknowledged - ack.Header.PacketID = pkt.Header.PacketID + // Spec/ts3j: Ack has its own counter + c.AckPacketID++ + ack.Header.PacketID = c.AckPacketID ack.Header.ClientID = c.ClientID // ACKs usually don't have NewProtocol flag set in Header byte ack.Header.Type &= ^uint8(protocol.PacketFlagNewProtocol) @@ -56,28 +57,61 @@ func (c *Client) handlePacket(pkt *protocol.Packet) error { case protocol.PacketTypeVoice: c.handleVoice(pkt) case protocol.PacketTypePing: + // Respond with Pong // Respond with Pong pong := protocol.NewPacket(protocol.PacketTypePong, nil) - // Spec/ts3j: Header PID for Pong matches the Ping ID - pong.Header.PacketID = pkt.Header.PacketID + // Spec/ts3j: Pong has its own counter + c.PongPacketID++ + pong.Header.PacketID = c.PongPacketID pong.Header.ClientID = c.ClientID - // Must NOT have NewProtocol (0x20) flag for Pings/Pongs - pong.Header.Type = uint8(protocol.PacketTypePong) | protocol.PacketFlagUnencrypted - // Use SharedMac if available, otherwise zeros - if c.Handshake != nil && len(c.Handshake.SharedMac) > 0 { + // Determine valid keys for encryption + key := protocol.HandshakeKey + nonce := protocol.HandshakeNonce + shouldEncrypt := false + + if c.Handshake != nil && c.Handshake.Step >= 6 && len(c.Handshake.SharedMac) > 0 { + shouldEncrypt = true copy(pong.Header.MAC[:], c.Handshake.SharedMac) + + // Generate EAX keys + if len(c.Handshake.SharedIV) > 0 { + crypto := &protocol.CryptoState{ + SharedIV: c.Handshake.SharedIV, + SharedMac: c.Handshake.SharedMac, + GenerationID: 0, + } + key, nonce = crypto.GenerateKeyNonce(&pong.Header, true) // Client->Server + } } else { + // Pre-handshake or fallback + pong.Header.Type = uint8(protocol.PacketTypePong) | protocol.PacketFlagUnencrypted for i := 0; i < 8; i++ { pong.Header.MAC[i] = 0 } } // The body of the Pong must contain the PID of the Ping it's acknowledging - pong.Data = make([]byte, 2) - binary.BigEndian.PutUint16(pong.Data, pkt.Header.PacketID) + pongData := make([]byte, 2) + binary.BigEndian.PutUint16(pongData, pkt.Header.PacketID) + + if shouldEncrypt { + // Encrypt the Pong data + // Meta for Client->Server: PID(2) + CID(2) + PT(1) = 5 bytes + meta := make([]byte, 5) + binary.BigEndian.PutUint16(meta[0:2], pong.Header.PacketID) + binary.BigEndian.PutUint16(meta[2:4], pong.Header.ClientID) + meta[4] = pong.Header.Type // ensure NewProtocol is NOT set (0x05) + + encData, mac, _ := protocol.EncryptEAX(key, nonce, meta, pongData) + pong.Data = encData + copy(pong.Header.MAC[:], mac) + log.Printf("Sending Encrypted Pong (HeaderPID=%d) for Ping", pong.Header.PacketID) + } else { + pong.Data = pongData + log.Printf("Sending Unencrypted Pong (HeaderPID=%d) for Ping", pong.Header.PacketID) + } - log.Printf("Sending Pong (HeaderPID=%d) for Ping", pong.Header.PacketID) c.Conn.SendPacket(pong) case protocol.PacketTypePong: // Server acknowledged our Ping diff --git a/internal/client/send.go b/internal/client/send.go index 61542eb..af8adaf 100644 --- a/internal/client/send.go +++ b/internal/client/send.go @@ -18,6 +18,7 @@ func (c *Client) SendCommand(cmd *protocol.Command) error { // SendCommandString sends a raw command string with fragmentation. func (c *Client) SendCommandString(cmdStr string) error { + log.Printf("Sending Command: %s", cmdStr) data := []byte(cmdStr) maxPacketSize := 500 maxBody := maxPacketSize - 13 // Header is 13 bytes for C->S (MAC 8, PID 2, TYPE 1, CID 2) diff --git a/pkg/ts3client/client.go b/pkg/ts3client/client.go index ff560e3..9ac80c7 100644 --- a/pkg/ts3client/client.go +++ b/pkg/ts3client/client.go @@ -283,9 +283,23 @@ func (c *Client) handleInternalEvent(eventType string, data map[string]any) { }) case "client_moved": + clientID := getUint16(data, "clientID") + channelID := getUint64(data, "channelID") + + c.clientsMu.Lock() + if client, ok := c.clients[clientID]; ok { + client.ChannelID = channelID + } + c.clientsMu.Unlock() + + // Update selfInfo if it's us + if c.selfInfo != nil && c.selfInfo.ClientID == clientID { + c.selfInfo.ChannelID = channelID + } + c.emit(EventClientMoved, &ClientMovedEvent{ - ClientID: getUint16(data, "clientID"), - ChannelID: getUint64(data, "channelID"), + ClientID: clientID, + ChannelID: channelID, }) case "channel_list": diff --git a/pkg/ts3client/commands.go b/pkg/ts3client/commands.go index 208de13..b2c74b6 100644 --- a/pkg/ts3client/commands.go +++ b/pkg/ts3client/commands.go @@ -81,7 +81,9 @@ func (c *Client) JoinChannelWithPassword(channelID uint64, password string) erro cmd := protocol.NewCommand("clientmove") cmd.AddParam("clid", fmt.Sprintf("%d", c.selfInfo.ClientID)) cmd.AddParam("cid", fmt.Sprintf("%d", channelID)) - cmd.AddParam("cpw", password) + if password != "" { + cmd.AddParam("cpw", password) + } err := c.internal.SendCommand(cmd) if err == nil && c.selfInfo != nil { diff --git a/teamspeak-tui.exe b/teamspeak-tui.exe index 656594e..ab51eac 100644 Binary files a/teamspeak-tui.exe and b/teamspeak-tui.exe differ diff --git a/ts-client.exe b/ts-client.exe deleted file mode 100644 index e861501..0000000 Binary files a/ts-client.exe and /dev/null differ diff --git a/voicebot.exe b/voicebot.exe deleted file mode 100644 index abf0e0d..0000000 Binary files a/voicebot.exe and /dev/null differ