- Fix race condition in client cleanup by serializing operations - Add proper nil checks in SendMessage for server/config - Add semaphore to limit concurrent health check goroutines - Reduce buffer size to RFC-compliant 512 bytes (was 4096) - Add comprehensive input validation (length, null bytes, UTF-8) - Improve SSL error handling with graceful degradation - Replace unsafe conn.Close() with proper cleanup() calls - Prevent goroutine leaks and memory exhaustion attacks - Enhanced logging and error recovery throughout These fixes address the freezing issues and improve overall server stability, security, and RFC compliance.
1233 lines
30 KiB
Go
1233 lines
30 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"log"
|
|
"math/rand"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Handle CAP negotiation and capability enabling
|
|
func (c *Client) handleCap(parts []string) {
|
|
if len(parts) < 2 {
|
|
return
|
|
}
|
|
subcmd := strings.ToUpper(parts[1])
|
|
|
|
serverName := "techircd.local"
|
|
if c.server != nil && c.server.config != nil {
|
|
serverName = c.server.config.Server.Name
|
|
}
|
|
|
|
switch subcmd {
|
|
case "LS":
|
|
// Start CAP negotiation
|
|
c.capNegotiation = true
|
|
|
|
// Advertise capabilities - using proper IRC format
|
|
capabilities := "away-notify chghost invite-notify multi-prefix userhost-in-names server-time message-tags account-tag account-notify"
|
|
c.SendMessage(fmt.Sprintf(":%s CAP * LS :%s", serverName, capabilities))
|
|
|
|
case "REQ":
|
|
if len(parts) < 3 {
|
|
return
|
|
}
|
|
// Join all parts from [2] onwards to handle multi-word capability lists
|
|
requestedCaps := strings.Join(parts[2:], " ")
|
|
if strings.HasPrefix(requestedCaps, ":") {
|
|
requestedCaps = requestedCaps[1:]
|
|
}
|
|
|
|
caps := strings.Fields(requestedCaps)
|
|
ack := []string{}
|
|
for _, cap := range caps {
|
|
// Accept common capabilities
|
|
switch cap {
|
|
case "away-notify", "chghost", "invite-notify", "multi-prefix", "userhost-in-names", "server-time", "message-tags", "account-tag", "account-notify":
|
|
c.SetCapability(cap, true)
|
|
ack = append(ack, cap)
|
|
}
|
|
}
|
|
if len(ack) > 0 {
|
|
c.SendMessage(fmt.Sprintf(":%s CAP %s ACK :%s", serverName, c.Nick(), strings.Join(ack, " ")))
|
|
}
|
|
|
|
case "END":
|
|
c.capNegotiation = false
|
|
// Try to complete registration now that CAP negotiation is done
|
|
c.checkRegistration()
|
|
|
|
case "LIST":
|
|
// List active capabilities for this client
|
|
c.mu.RLock()
|
|
var activeCaps []string
|
|
for cap := range c.capabilities {
|
|
activeCaps = append(activeCaps, cap)
|
|
}
|
|
c.mu.RUnlock()
|
|
|
|
capsList := strings.Join(activeCaps, " ")
|
|
c.SendMessage(fmt.Sprintf(":%s CAP %s LIST :%s", serverName, c.Nick(), capsList))
|
|
}
|
|
}
|
|
|
|
type Client struct {
|
|
conn net.Conn
|
|
nick string
|
|
user string
|
|
realname string
|
|
host string
|
|
server *Server
|
|
channels map[string]*Channel
|
|
modes map[rune]bool
|
|
away string
|
|
oper bool
|
|
operClass string // Operator class name
|
|
ssl bool
|
|
registered bool
|
|
account string // Services account name
|
|
connectTime time.Time // When the client connected
|
|
lastActivity time.Time // Last time client sent a message
|
|
|
|
// Unique client ID for server tracking
|
|
clientID string
|
|
|
|
// Connection stability tracking
|
|
disconnected bool // Flag to track if client is disconnected
|
|
writeErrors int // Count of consecutive write errors
|
|
maxWriteErrors int // Maximum write errors before disconnection
|
|
lastWriteError time.Time // Time of last write error
|
|
|
|
// Flood protection
|
|
lastMessage time.Time
|
|
messageCount int
|
|
|
|
// SASL authentication
|
|
saslMech string
|
|
saslData string
|
|
|
|
// IRCv3 capabilities
|
|
capabilities map[string]bool
|
|
capNegotiation bool // True if client is in CAP negotiation
|
|
|
|
// Server Notice Masks (snomasks) for operators
|
|
snomasks map[rune]bool
|
|
|
|
// Ping timeout tracking
|
|
lastPong time.Time
|
|
waitingForPong bool
|
|
|
|
// SILENCE and MONITOR lists
|
|
silenceList []string
|
|
monitorList []string
|
|
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
func NewClient(conn net.Conn, server *Server) *Client {
|
|
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
|
|
|
|
// Check if connection is SSL
|
|
isSSL := false
|
|
if _, ok := conn.(*tls.Conn); ok {
|
|
isSSL = true
|
|
}
|
|
|
|
// Generate unique client ID
|
|
clientID := fmt.Sprintf("%s_%d_%d", host, time.Now().Unix(), rand.Intn(10000))
|
|
|
|
client := &Client{
|
|
clientID: clientID,
|
|
conn: conn,
|
|
host: host,
|
|
server: server,
|
|
channels: make(map[string]*Channel),
|
|
modes: make(map[rune]bool),
|
|
capabilities: make(map[string]bool),
|
|
capNegotiation: false,
|
|
snomasks: make(map[rune]bool),
|
|
silenceList: make([]string, 0),
|
|
monitorList: make([]string, 0),
|
|
ssl: isSSL,
|
|
connectTime: time.Now(),
|
|
lastActivity: time.Now(),
|
|
lastMessage: time.Now(),
|
|
lastPong: time.Now(),
|
|
waitingForPong: false,
|
|
// Connection stability
|
|
disconnected: false,
|
|
writeErrors: 0,
|
|
maxWriteErrors: 3, // Allow 3 consecutive write errors before marking disconnected
|
|
}
|
|
|
|
// Set SSL user mode if connected via SSL
|
|
if isSSL {
|
|
client.SetMode('z', true)
|
|
}
|
|
|
|
return client
|
|
}
|
|
|
|
func (c *Client) SendMessage(message string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
// Enhanced connection and server health check
|
|
if c.conn == nil {
|
|
log.Printf("SendMessage: connection is nil for client %s", c.Nick())
|
|
return
|
|
}
|
|
|
|
if c.server == nil {
|
|
log.Printf("SendMessage: server is nil for client %s", c.Nick())
|
|
return
|
|
}
|
|
|
|
if c.server.config == nil {
|
|
log.Printf("SendMessage: server config is nil for client %s", c.Nick())
|
|
return
|
|
}
|
|
|
|
// Validate message before sending
|
|
if message == "" {
|
|
return
|
|
}
|
|
|
|
// Prevent sending to disconnected clients
|
|
if c.isDisconnected() {
|
|
return
|
|
}
|
|
|
|
// Debug logging for outgoing messages
|
|
if DebugMode {
|
|
log.Printf(">>> SEND to %s: %s", c.getClientInfoUnsafe(), message)
|
|
}
|
|
|
|
// Set write deadline to prevent hanging with retry mechanism
|
|
writeTimeout := 15 * time.Second
|
|
c.conn.SetWriteDeadline(time.Now().Add(writeTimeout))
|
|
defer func() {
|
|
// Always clear deadline, ignore errors on disconnected connections
|
|
if c.conn != nil {
|
|
c.conn.SetWriteDeadline(time.Time{})
|
|
}
|
|
}()
|
|
|
|
// Attempt to write with error recovery
|
|
_, err := fmt.Fprintf(c.conn, "%s\r\n", message)
|
|
if err != nil {
|
|
// Enhanced error logging with client identification
|
|
clientInfo := c.getClientInfoUnsafe()
|
|
log.Printf("Error sending message to %s: %v (msg: %.50s...)", clientInfo, err, message)
|
|
|
|
// Mark client for disconnection on write errors
|
|
c.markDisconnected()
|
|
|
|
// Don't attempt to send error messages that could cause recursion
|
|
return
|
|
}
|
|
|
|
// Reset write error counter on successful write
|
|
c.resetWriteErrors()
|
|
|
|
// Only log PONG messages in debug mode to reduce log spam
|
|
if strings.HasPrefix(message, "PONG") && c.server != nil && c.server.config != nil {
|
|
if c.server.config.Logging.Level == "debug" {
|
|
log.Printf("Successfully sent to client %s: %s", c.getClientInfoUnsafe(), message)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *Client) SendFrom(source, message string) {
|
|
c.SendMessage(fmt.Sprintf(":%s %s", source, message))
|
|
}
|
|
|
|
func (c *Client) SendNumeric(code int, message string) {
|
|
if c.server == nil || c.server.config == nil {
|
|
return
|
|
}
|
|
c.SendFrom(c.server.config.Server.Name, fmt.Sprintf("%03d %s %s", code, c.Nick(), message))
|
|
}
|
|
|
|
func (c *Client) Nick() string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.nick
|
|
}
|
|
|
|
func (c *Client) SetNick(nick string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.nick = nick
|
|
}
|
|
|
|
func (c *Client) User() string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.user
|
|
}
|
|
|
|
func (c *Client) SetUser(user string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.user = user
|
|
}
|
|
|
|
func (c *Client) Realname() string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.realname
|
|
}
|
|
|
|
func (c *Client) SetRealname(realname string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.realname = realname
|
|
}
|
|
|
|
func (c *Client) Host() string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.host
|
|
}
|
|
|
|
// HostForUser returns the appropriate hostname to show to a requesting user
|
|
// based on privacy settings and the requester's privileges
|
|
func (c *Client) HostForUser(requester *Client) string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
// If host hiding is disabled, always show real host
|
|
if !c.server.config.Privacy.HideHostsFromUsers {
|
|
return c.host
|
|
}
|
|
|
|
// If requester is an operator and bypass is enabled, show real host
|
|
if requester != nil && requester.IsOper() && c.server.config.Privacy.OperBypassHostHide {
|
|
return c.host
|
|
}
|
|
|
|
// If requester is viewing themselves, show real host
|
|
if requester != nil && requester.Nick() == c.Nick() {
|
|
return c.host
|
|
}
|
|
|
|
// Check if user has +x mode set (host masking)
|
|
if c.HasMode('x') {
|
|
// Return masked hostname
|
|
return c.nick + "." + c.server.config.Privacy.MaskedHostSuffix
|
|
}
|
|
|
|
// Default behavior: show masked host when privacy is enabled
|
|
return c.nick + "." + c.server.config.Privacy.MaskedHostSuffix
|
|
}
|
|
|
|
// canSeeWhoisInfo checks if the requester can see specific WHOIS information about the target
|
|
func (c *Client) canSeeWhoisInfo(target *Client, infoType string) bool {
|
|
config := c.server.config.WhoisFeatures
|
|
|
|
var toEveryone, toOpers, toSelf bool
|
|
|
|
switch infoType {
|
|
case "user_modes":
|
|
toEveryone = config.ShowUserModes.ToEveryone
|
|
toOpers = config.ShowUserModes.ToOpers
|
|
toSelf = config.ShowUserModes.ToSelf
|
|
case "ssl_status":
|
|
toEveryone = config.ShowSSLStatus.ToEveryone
|
|
toOpers = config.ShowSSLStatus.ToOpers
|
|
toSelf = config.ShowSSLStatus.ToSelf
|
|
case "idle_time":
|
|
toEveryone = config.ShowIdleTime.ToEveryone
|
|
toOpers = config.ShowIdleTime.ToOpers
|
|
toSelf = config.ShowIdleTime.ToSelf
|
|
case "signon_time":
|
|
toEveryone = config.ShowSignonTime.ToEveryone
|
|
toOpers = config.ShowSignonTime.ToOpers
|
|
toSelf = config.ShowSignonTime.ToSelf
|
|
case "real_host":
|
|
toEveryone = config.ShowRealHost.ToEveryone
|
|
toOpers = config.ShowRealHost.ToOpers
|
|
toSelf = config.ShowRealHost.ToSelf
|
|
case "oper_class":
|
|
toEveryone = config.ShowOperClass.ToEveryone
|
|
toOpers = config.ShowOperClass.ToOpers
|
|
toSelf = config.ShowOperClass.ToSelf
|
|
case "client_info":
|
|
toEveryone = config.ShowClientInfo.ToEveryone
|
|
toOpers = config.ShowClientInfo.ToOpers
|
|
toSelf = config.ShowClientInfo.ToSelf
|
|
case "account_name":
|
|
toEveryone = config.ShowAccountName.ToEveryone
|
|
toOpers = config.ShowAccountName.ToOpers
|
|
toSelf = config.ShowAccountName.ToSelf
|
|
default:
|
|
return false
|
|
}
|
|
|
|
// Check if target is viewing themselves
|
|
if c.Nick() == target.Nick() && toSelf {
|
|
return true
|
|
}
|
|
|
|
// Check if requester is an operator
|
|
if c.IsOper() && toOpers {
|
|
return true
|
|
}
|
|
|
|
// Check if everyone can see this info
|
|
if toEveryone {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// canSeeChannels checks if the requester can see channel information
|
|
func (c *Client) canSeeChannels(target *Client) bool {
|
|
config := c.server.config.WhoisFeatures.ShowChannels
|
|
|
|
// Check if target is viewing themselves
|
|
if c.Nick() == target.Nick() && config.ToSelf {
|
|
return true
|
|
}
|
|
|
|
// Check if requester is an operator
|
|
if c.IsOper() && config.ToOpers {
|
|
return true
|
|
}
|
|
|
|
// Check if everyone can see this info
|
|
if config.ToEveryone {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// UpdateActivity updates the last activity time for the client
|
|
func (c *Client) UpdateActivity() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.lastActivity = time.Now()
|
|
}
|
|
|
|
// SetAccount sets the account name for services integration
|
|
func (c *Client) SetAccount(account string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
oldAccount := c.account
|
|
c.account = account
|
|
// IRCv3 account-notify: send ACCOUNT message if capability enabled and account changes
|
|
if c.HasCapability("account-notify") && oldAccount != account {
|
|
msg := "ACCOUNT "
|
|
if account == "" {
|
|
msg += "*"
|
|
} else {
|
|
msg += account
|
|
}
|
|
c.SendMessage(msg)
|
|
}
|
|
}
|
|
|
|
// Account returns the account name
|
|
func (c *Client) Account() string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.account
|
|
}
|
|
|
|
// ConnectTime returns when the client connected
|
|
func (c *Client) ConnectTime() time.Time {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.connectTime
|
|
}
|
|
|
|
// LastActivity returns the last activity time
|
|
func (c *Client) LastActivity() time.Time {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.lastActivity
|
|
}
|
|
|
|
// SetOperClass sets the operator class for this client
|
|
func (c *Client) SetOperClass(class string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.operClass = class
|
|
}
|
|
|
|
// OperClass returns the operator class
|
|
func (c *Client) OperClass() string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.operClass
|
|
}
|
|
|
|
// HasOperPermission checks if the client has a specific operator permission
|
|
func (c *Client) HasOperPermission(permission string) bool {
|
|
if !c.IsOper() {
|
|
return false
|
|
}
|
|
|
|
// Load oper config and check permissions
|
|
operConfig, err := LoadOperConfig(c.server.config.OperConfig.ConfigFile)
|
|
if err != nil || !c.server.config.OperConfig.Enable {
|
|
// Fallback to legacy system
|
|
return true // Basic oper permissions
|
|
}
|
|
|
|
return operConfig.HasPermission(c.Nick(), permission)
|
|
}
|
|
|
|
// GetOperRank returns the operator rank (higher number = higher authority)
|
|
func (c *Client) GetOperRank() int {
|
|
if !c.IsOper() {
|
|
return 0
|
|
}
|
|
|
|
operConfig, err := LoadOperConfig(c.server.config.OperConfig.ConfigFile)
|
|
if err != nil || !c.server.config.OperConfig.Enable {
|
|
return 1 // Basic rank for legacy
|
|
}
|
|
|
|
return operConfig.GetOperRank(c.Nick())
|
|
}
|
|
|
|
// CanOperateOn checks if this operator can perform actions on another operator
|
|
func (c *Client) CanOperateOn(target *Client) bool {
|
|
if !c.IsOper() {
|
|
return false
|
|
}
|
|
|
|
if !target.IsOper() {
|
|
return true // Opers can operate on regular users
|
|
}
|
|
|
|
operConfig, err := LoadOperConfig(c.server.config.OperConfig.ConfigFile)
|
|
if err != nil || !c.server.config.OperConfig.Enable {
|
|
return true // Legacy behavior
|
|
}
|
|
|
|
return operConfig.CanOperateOn(c.Nick(), target.Nick())
|
|
}
|
|
|
|
// GetOperSymbol returns the symbol for this operator class
|
|
func (c *Client) GetOperSymbol() string {
|
|
if !c.IsOper() {
|
|
return ""
|
|
}
|
|
|
|
operConfig, err := LoadOperConfig(c.server.config.OperConfig.ConfigFile)
|
|
if err != nil || !c.server.config.OperConfig.Enable {
|
|
return "*" // Default symbol
|
|
}
|
|
|
|
class := operConfig.GetOperClass(c.OperClass())
|
|
if class == nil {
|
|
return "*"
|
|
}
|
|
|
|
return class.Symbol
|
|
}
|
|
|
|
func (c *Client) IsRegistered() bool {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.registered
|
|
}
|
|
|
|
func (c *Client) SetRegistered(registered bool) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.registered = registered
|
|
}
|
|
|
|
func (c *Client) IsOper() bool {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.oper
|
|
}
|
|
|
|
func (c *Client) SetOper(oper bool) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.oper = oper
|
|
}
|
|
|
|
func (c *Client) IsSSL() bool {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.ssl
|
|
}
|
|
|
|
func (c *Client) Away() string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.away
|
|
}
|
|
|
|
func (c *Client) SetAway(away string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.away = away
|
|
}
|
|
|
|
func (c *Client) HasMode(mode rune) bool {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.modes[mode]
|
|
}
|
|
|
|
func (c *Client) SetMode(mode rune, set bool) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if set {
|
|
c.modes[mode] = true
|
|
} else {
|
|
delete(c.modes, mode)
|
|
}
|
|
}
|
|
|
|
func (c *Client) GetModes() string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
var modes []rune
|
|
for mode := range c.modes {
|
|
modes = append(modes, mode)
|
|
}
|
|
|
|
if len(modes) == 0 {
|
|
return ""
|
|
}
|
|
|
|
return "+" + string(modes)
|
|
}
|
|
|
|
func (c *Client) HasSnomask(snomask rune) bool {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.snomasks[snomask]
|
|
}
|
|
|
|
func (c *Client) SetSnomask(snomask rune, set bool) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if set {
|
|
c.snomasks[snomask] = true
|
|
} else {
|
|
delete(c.snomasks, snomask)
|
|
}
|
|
}
|
|
|
|
func (c *Client) GetSnomasks() string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
var snomasks []rune
|
|
for snomask := range c.snomasks {
|
|
snomasks = append(snomasks, snomask)
|
|
}
|
|
|
|
if len(snomasks) == 0 {
|
|
return ""
|
|
}
|
|
|
|
return "+" + string(snomasks)
|
|
}
|
|
|
|
// HasGodMode returns true if the client has god mode enabled (user mode +G)
|
|
func (c *Client) HasGodMode() bool {
|
|
return c.HasMode('G') && c.HasOperPermission("god_mode")
|
|
}
|
|
|
|
// HasStealthMode returns true if the client has stealth mode enabled (user mode +S)
|
|
func (c *Client) HasStealthMode() bool {
|
|
return c.HasMode('S') && c.HasOperPermission("stealth_mode")
|
|
}
|
|
|
|
// IsVisibleTo returns true if this client should be visible to the target client
|
|
func (c *Client) IsVisibleTo(target *Client) bool {
|
|
// If client doesn't have stealth mode, always visible
|
|
if !c.HasStealthMode() {
|
|
return true
|
|
}
|
|
|
|
// If target is an operator, stealth users are visible
|
|
if target.IsOper() {
|
|
return true
|
|
}
|
|
|
|
// If target is the stealth user themselves, always visible
|
|
if target == c {
|
|
return true
|
|
}
|
|
|
|
// Otherwise, stealth users are invisible to normal users
|
|
return false
|
|
}
|
|
|
|
// CanBypassChannelRestrictions returns true if the client can bypass channel restrictions
|
|
func (c *Client) CanBypassChannelRestrictions() bool {
|
|
return c.HasGodMode()
|
|
}
|
|
|
|
func (c *Client) AddChannel(channel *Channel) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.channels[strings.ToLower(channel.name)] = channel
|
|
}
|
|
|
|
func (c *Client) RemoveChannel(channelName string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
delete(c.channels, strings.ToLower(channelName))
|
|
}
|
|
|
|
func (c *Client) IsInChannel(channelName string) bool {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
_, exists := c.channels[strings.ToLower(channelName)]
|
|
return exists
|
|
}
|
|
|
|
func (c *Client) GetChannels() map[string]*Channel {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
channels := make(map[string]*Channel)
|
|
for name, channel := range c.channels {
|
|
channels[name] = channel
|
|
}
|
|
return channels
|
|
}
|
|
|
|
func (c *Client) Prefix() string {
|
|
return fmt.Sprintf("%s!%s@%s", c.Nick(), c.User(), c.Host())
|
|
}
|
|
|
|
// sendSnomask sends a server notice to all operators with the specified snomask
|
|
func (c *Client) sendSnomask(snomask rune, message string) {
|
|
if c.server == nil {
|
|
return
|
|
}
|
|
|
|
serverName := "localhost"
|
|
if c.server.config != nil {
|
|
serverName = c.server.config.Server.Name
|
|
}
|
|
|
|
// Send to all operators with this snomask
|
|
c.server.mu.RLock()
|
|
clients := make([]*Client, 0, len(c.server.clients))
|
|
for _, client := range c.server.clients {
|
|
clients = append(clients, client)
|
|
}
|
|
c.server.mu.RUnlock()
|
|
|
|
for _, client := range clients {
|
|
if client.IsOper() && client.HasSnomask(snomask) {
|
|
client.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** %s", serverName, client.Nick(), message))
|
|
}
|
|
}
|
|
}
|
|
|
|
// getServerConfig safely returns the server config, or nil if not available
|
|
func (c *Client) getServerConfig() *Config {
|
|
if c.server == nil {
|
|
return nil
|
|
}
|
|
return c.server.config
|
|
}
|
|
|
|
// getRegistrationTimeout safely gets the registration timeout duration
|
|
func (c *Client) getRegistrationTimeout() time.Duration {
|
|
config := c.getServerConfig()
|
|
if config == nil {
|
|
return 60 * time.Second // default 60 seconds
|
|
}
|
|
return config.RegistrationTimeoutDuration()
|
|
}
|
|
|
|
func (c *Client) CheckFlood() bool {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
// Enhanced nil checks and stability
|
|
if c.server == nil || c.server.config == nil {
|
|
return false
|
|
}
|
|
|
|
// IRC operators are exempt from flood protection
|
|
if c.oper {
|
|
return false
|
|
}
|
|
|
|
// Be very lenient with flood protection for unregistered clients
|
|
// during the initial connection phase (first 60 seconds)
|
|
if !c.registered {
|
|
// Allow up to 100 commands per minute for unregistered clients
|
|
now := time.Now()
|
|
if now.Sub(c.lastMessage) > 60*time.Second {
|
|
c.messageCount = 0
|
|
}
|
|
c.messageCount++
|
|
c.lastMessage = now
|
|
return c.messageCount > 100
|
|
}
|
|
|
|
// For registered clients, use enhanced flood protection
|
|
now := time.Now()
|
|
floodWindow := time.Duration(c.server.config.Limits.FloodSeconds) * time.Second
|
|
|
|
// Ensure minimum flood window to prevent issues
|
|
if floodWindow < 10*time.Second {
|
|
floodWindow = 10 * time.Second
|
|
}
|
|
|
|
if now.Sub(c.lastMessage) > floodWindow {
|
|
c.messageCount = 0
|
|
}
|
|
|
|
c.messageCount++
|
|
c.lastMessage = now
|
|
|
|
// Use higher limits than configured for better user experience
|
|
// and prevent false positives
|
|
configuredLimit := c.server.config.Limits.FloodLines
|
|
if configuredLimit < 5 {
|
|
configuredLimit = 5 // Minimum reasonable limit
|
|
}
|
|
|
|
maxLines := configuredLimit * 3 // Triple the configured limit
|
|
if maxLines > 100 {
|
|
maxLines = 100 // Cap at reasonable maximum
|
|
}
|
|
|
|
exceeded := c.messageCount > maxLines
|
|
|
|
// Log flood attempts for debugging
|
|
if exceeded {
|
|
log.Printf("Flood limit exceeded for %s: %d messages in %v (limit: %d)",
|
|
c.getClientInfoUnsafe(), c.messageCount, floodWindow, maxLines)
|
|
}
|
|
|
|
return exceeded
|
|
}
|
|
|
|
func (c *Client) HasCapability(cap string) bool {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.capabilities[cap]
|
|
}
|
|
|
|
func (c *Client) SetCapability(cap string, enabled bool) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if enabled {
|
|
c.capabilities[cap] = true
|
|
} else {
|
|
delete(c.capabilities, cap)
|
|
}
|
|
}
|
|
|
|
// Connection stability helper methods
|
|
|
|
// isDisconnected checks if the client is marked as disconnected
|
|
func (c *Client) isDisconnected() bool {
|
|
// Don't need to lock here since we're already locked in SendMessage
|
|
return c.disconnected
|
|
}
|
|
|
|
// markDisconnected marks the client as disconnected
|
|
func (c *Client) markDisconnected() {
|
|
// Don't need to lock here since we're already locked in SendMessage
|
|
if !c.disconnected {
|
|
c.disconnected = true
|
|
c.writeErrors++
|
|
c.lastWriteError = time.Now()
|
|
log.Printf("Client %s marked as disconnected after %d write errors", c.getClientInfoUnsafe(), c.writeErrors)
|
|
}
|
|
}
|
|
|
|
// getClientInfo returns identifying information about the client (thread-safe)
|
|
func (c *Client) getClientInfo() string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.getClientInfoUnsafe()
|
|
}
|
|
|
|
// getClientInfoUnsafe returns identifying information about the client (not thread-safe)
|
|
func (c *Client) getClientInfoUnsafe() string {
|
|
nick := c.nick
|
|
if nick == "" {
|
|
nick = "unknown"
|
|
}
|
|
host := c.host
|
|
if host == "" {
|
|
host = "unknown"
|
|
}
|
|
return fmt.Sprintf("%s@%s[%s]", nick, host, c.clientID)
|
|
}
|
|
|
|
// IsConnected checks if the client connection is still valid
|
|
func (c *Client) IsConnected() bool {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.conn != nil && !c.disconnected
|
|
}
|
|
|
|
// resetWriteErrors resets the write error counter (called on successful writes)
|
|
func (c *Client) resetWriteErrors() {
|
|
// Don't need to lock here since we're already locked in SendMessage
|
|
if c.writeErrors > 0 {
|
|
c.writeErrors = 0
|
|
c.lastWriteError = time.Time{}
|
|
}
|
|
}
|
|
|
|
// HealthCheck performs a comprehensive health check on the client
|
|
func (c *Client) HealthCheck() (bool, string) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
// Check if disconnected
|
|
if c.disconnected {
|
|
return false, "client marked as disconnected"
|
|
}
|
|
|
|
// Check connection validity
|
|
if c.conn == nil {
|
|
return false, "connection is nil"
|
|
}
|
|
|
|
// Check for excessive write errors
|
|
if c.writeErrors >= c.maxWriteErrors {
|
|
return false, fmt.Sprintf("too many write errors (%d/%d)", c.writeErrors, c.maxWriteErrors)
|
|
}
|
|
|
|
// Check for stale connections (inactive for too long)
|
|
maxInactiveTime := 30 * time.Minute
|
|
if !c.registered {
|
|
maxInactiveTime = 5 * time.Minute // Shorter timeout for unregistered clients
|
|
}
|
|
|
|
if time.Since(c.lastActivity) > maxInactiveTime {
|
|
return false, fmt.Sprintf("inactive for %v (max: %v)", time.Since(c.lastActivity), maxInactiveTime)
|
|
}
|
|
|
|
// Check for registration timeout
|
|
if !c.registered && time.Since(c.connectTime) > 5*time.Minute {
|
|
return false, fmt.Sprintf("registration timeout: connected %v ago but not registered", time.Since(c.connectTime))
|
|
}
|
|
|
|
return true, "healthy"
|
|
}
|
|
|
|
// ForceDisconnect forcefully disconnects the client with a reason
|
|
func (c *Client) ForceDisconnect(reason string) {
|
|
log.Printf("Force disconnecting client %s: %s", c.getClientInfo(), reason)
|
|
|
|
c.mu.Lock()
|
|
c.disconnected = true
|
|
c.mu.Unlock()
|
|
|
|
// Send error message if possible
|
|
if c.conn != nil {
|
|
c.SendMessage(fmt.Sprintf("ERROR :%s", reason))
|
|
}
|
|
}
|
|
|
|
func (c *Client) Handle() {
|
|
defer func() {
|
|
// Enhanced panic recovery with detailed logging
|
|
if r := recover(); r != nil {
|
|
log.Printf("PANIC in client handler for %s: %v", c.getClientInfo(), r)
|
|
// Log stack trace for debugging
|
|
log.Printf("Stack trace: %v", r)
|
|
}
|
|
|
|
// Enhanced cleanup with error handling
|
|
c.cleanup()
|
|
}()
|
|
|
|
// Validate initial state
|
|
if c.server == nil || c.server.config == nil {
|
|
log.Printf("Client handler: server or config is nil for %s", c.getClientInfo())
|
|
return
|
|
}
|
|
|
|
log.Printf("Starting client handler for %s", c.getClientInfo())
|
|
|
|
scanner := bufio.NewScanner(c.conn)
|
|
|
|
// Set maximum line length per IRC RFC (512 bytes including CRLF)
|
|
const maxLineLength = 512
|
|
scanner.Buffer(make([]byte, maxLineLength), maxLineLength)
|
|
|
|
// Set initial read deadline - be more generous during connection setup
|
|
c.setReadDeadline(10 * time.Minute)
|
|
|
|
// Set registration timeout
|
|
registrationTimer := time.NewTimer(c.getRegistrationTimeout())
|
|
defer registrationTimer.Stop()
|
|
registrationActive := true
|
|
|
|
// Initialize ping state
|
|
c.mu.Lock()
|
|
c.lastPong = time.Now()
|
|
c.lastActivity = time.Now() // Set initial activity time
|
|
c.waitingForPong = false
|
|
c.mu.Unlock()
|
|
|
|
connectionStartTime := time.Now()
|
|
|
|
// Create a context for graceful shutdown
|
|
done := make(chan bool, 1)
|
|
defer close(done)
|
|
|
|
// Main message processing loop with timeout
|
|
for {
|
|
// Check if client is marked as disconnected
|
|
if !c.IsConnected() {
|
|
log.Printf("Client %s marked as disconnected, ending handler", c.getClientInfo())
|
|
return
|
|
}
|
|
|
|
select {
|
|
case <-registrationTimer.C:
|
|
if registrationActive && !c.IsRegistered() {
|
|
log.Printf("Registration timeout for client %s after %v", c.getClientInfo(), time.Since(connectionStartTime))
|
|
c.SendMessage("ERROR :Registration timeout")
|
|
return
|
|
}
|
|
case <-done:
|
|
return
|
|
default:
|
|
// Handle message reading with timeout and enhanced error recovery
|
|
if !c.handleMessageRead(scanner, ®istrationActive, registrationTimer, connectionStartTime) {
|
|
return // Connection closed or error
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Enhanced connection handling methods
|
|
|
|
// cleanup performs thorough cleanup of client resources
|
|
func (c *Client) cleanup() {
|
|
log.Printf("Starting cleanup for client %s", c.getClientInfo())
|
|
|
|
// Mark as disconnected to prevent further operations
|
|
c.mu.Lock()
|
|
c.disconnected = true
|
|
c.mu.Unlock()
|
|
|
|
// Close connection safely with timeout to prevent hanging
|
|
if c.conn != nil {
|
|
// Set a short deadline to force close if needed
|
|
c.conn.SetDeadline(time.Now().Add(5 * time.Second))
|
|
|
|
if err := c.conn.Close(); err != nil {
|
|
log.Printf("Error closing connection for %s: %v", c.getClientInfo(), err)
|
|
}
|
|
|
|
// Clear the connection reference
|
|
c.mu.Lock()
|
|
c.conn = nil
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// Perform cleanup operations sequentially to avoid race conditions
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("Panic during cleanup for %s: %v", c.getClientInfo(), r)
|
|
}
|
|
}()
|
|
|
|
// Get channels snapshot before cleanup
|
|
channels := c.GetChannels()
|
|
var emptyChannels []string
|
|
|
|
// Remove client from all channels first
|
|
for channelName, channel := range channels {
|
|
if channel != nil {
|
|
channel.RemoveClient(c)
|
|
// Track empty channels for later cleanup
|
|
if len(channel.GetClients()) == 0 {
|
|
emptyChannels = append(emptyChannels, channelName)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove from server (must happen after channel cleanup)
|
|
if c.server != nil {
|
|
c.server.RemoveClient(c)
|
|
|
|
// Clean up empty channels after client removal to prevent race conditions
|
|
for _, channelName := range emptyChannels {
|
|
c.server.RemoveChannel(channelName)
|
|
}
|
|
}
|
|
|
|
log.Printf("Cleanup completed for client %s", c.getClientInfo())
|
|
}
|
|
|
|
// setReadDeadline safely sets read deadline on connection
|
|
func (c *Client) setReadDeadline(duration time.Duration) {
|
|
if c.conn != nil && !c.isDisconnected() {
|
|
if err := c.conn.SetReadDeadline(time.Now().Add(duration)); err != nil {
|
|
log.Printf("Error setting read deadline for %s: %v", c.getClientInfo(), err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleMessageRead handles reading and processing a single message
|
|
func (c *Client) handleMessageRead(scanner *bufio.Scanner, registrationActive *bool, registrationTimer *time.Timer, connectionStartTime time.Time) bool {
|
|
// Set read deadline with timeout to prevent hanging
|
|
readTimeout := 30 * time.Second
|
|
if c.IsRegistered() {
|
|
readTimeout = 5 * time.Minute
|
|
}
|
|
|
|
c.setReadDeadline(readTimeout)
|
|
|
|
// Use a channel to make scanning non-blocking with timeout
|
|
scanChan := make(chan bool, 1)
|
|
var line string
|
|
var scanErr error
|
|
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("Panic in scanner goroutine for %s: %v", c.getClientInfo(), r)
|
|
scanChan <- false
|
|
}
|
|
}()
|
|
|
|
if scanner.Scan() {
|
|
line = strings.TrimSpace(scanner.Text())
|
|
scanErr = scanner.Err()
|
|
scanChan <- true
|
|
} else {
|
|
scanErr = scanner.Err()
|
|
scanChan <- false
|
|
}
|
|
}()
|
|
|
|
// Wait for scan result with timeout
|
|
select {
|
|
case scanResult := <-scanChan:
|
|
if !scanResult {
|
|
// Check for scanner error
|
|
if scanErr != nil {
|
|
log.Printf("Scanner error for client %s: %v", c.getClientInfo(), scanErr)
|
|
} else {
|
|
log.Printf("Client %s disconnected cleanly", c.getClientInfo())
|
|
}
|
|
return false
|
|
}
|
|
case <-time.After(readTimeout + 5*time.Second): // Additional buffer
|
|
log.Printf("Read timeout for client %s after %v", c.getClientInfo(), readTimeout)
|
|
c.SendMessage("ERROR :Read timeout")
|
|
return false
|
|
}
|
|
|
|
if line == "" {
|
|
return true // Continue processing
|
|
}
|
|
|
|
// Enhanced flood checking - be more lenient during initial connection
|
|
if c.IsRegistered() && c.CheckFlood() {
|
|
log.Printf("Flood protection triggered for %s", c.getClientInfo())
|
|
c.SendMessage("ERROR :Excess Flood")
|
|
return false
|
|
}
|
|
|
|
// Validate message content to prevent issues
|
|
if len(line) > 4096 {
|
|
log.Printf("Oversized message from %s, truncating", c.getClientInfo())
|
|
line = line[:4096]
|
|
}
|
|
|
|
// Check for binary data that might cause issues
|
|
if !isPrintableASCII(line) {
|
|
log.Printf("Non-printable data from %s, skipping", c.getClientInfo())
|
|
return true
|
|
}
|
|
|
|
// Update activity time for any valid message
|
|
c.mu.Lock()
|
|
c.lastActivity = time.Now()
|
|
c.mu.Unlock()
|
|
|
|
// Stop registration timer once registered
|
|
if *registrationActive && c.IsRegistered() {
|
|
registrationTimer.Stop()
|
|
*registrationActive = false
|
|
log.Printf("Client %s registration completed successfully", c.getClientInfo())
|
|
}
|
|
|
|
// Handle the message with error recovery
|
|
func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("Panic handling message from %s: %v (message: %.100s)", c.getClientInfo(), r, line)
|
|
}
|
|
}()
|
|
|
|
if c.server != nil {
|
|
c.server.HandleMessage(c, line)
|
|
}
|
|
}()
|
|
|
|
return true
|
|
}
|
|
|
|
// isPrintableASCII checks if a string contains only printable ASCII characters
|
|
func isPrintableASCII(s string) bool {
|
|
for _, r := range s {
|
|
if r < 32 || r > 126 {
|
|
// Allow common IRC control characters
|
|
if r != '\r' && r != '\n' && r != '\t' {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// broadcastToChannel sends an IRCv3 message to all users in a channel
|
|
func (c *Client) broadcastToChannel(channel *Channel, msg *IRCMessage) {
|
|
for _, client := range channel.GetClients() {
|
|
if client != c { // Don't send to sender
|
|
// Create a copy of the message for each recipient
|
|
clientMsg := &IRCMessage{
|
|
Tags: make(map[string]string),
|
|
Prefix: msg.Prefix,
|
|
Command: msg.Command,
|
|
Params: msg.Params,
|
|
}
|
|
|
|
// Copy base tags
|
|
for k, v := range msg.Tags {
|
|
clientMsg.Tags[k] = v
|
|
}
|
|
|
|
// Add recipient-specific server-time if they support it
|
|
if client.HasCapability("server-time") && clientMsg.Tags["time"] == "" {
|
|
clientMsg.Tags["time"] = time.Now().UTC().Format(time.RFC3339Nano)
|
|
}
|
|
|
|
client.SendMessage(clientMsg.FormatMessage())
|
|
}
|
|
}
|
|
}
|