1224 lines
30 KiB
Go
1224 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 health check
|
|
if c.conn == nil {
|
|
log.Printf("SendMessage: connection 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 to prevent memory exhaustion
|
|
const maxLineLength = 4096
|
|
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()
|
|
}
|
|
|
|
// Part all channels with error handling in a separate goroutine to prevent blocking
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("Panic during channel cleanup for %s: %v", c.getClientInfo(), r)
|
|
}
|
|
}()
|
|
|
|
channels := c.GetChannels()
|
|
for channelName, channel := range channels {
|
|
if channel != nil {
|
|
channel.RemoveClient(c)
|
|
// Clean up empty channels
|
|
if len(channel.GetClients()) == 0 && c.server != nil {
|
|
c.server.RemoveChannel(channelName)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Remove from server in a separate goroutine to prevent deadlock
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("Panic during server cleanup for %s: %v", c.getClientInfo(), r)
|
|
}
|
|
}()
|
|
|
|
if c.server != nil {
|
|
c.server.RemoveClient(c)
|
|
}
|
|
}()
|
|
|
|
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())
|
|
}
|
|
}
|
|
}
|