commit 6772bfd84278b7cd0180e3e97158a185a06636bf Author: ComputerTech312 Date: Sat Sep 27 14:43:52 2025 +0100 Added all of the existing code diff --git a/FREEZING_ISSUE_FIXES.md b/FREEZING_ISSUE_FIXES.md new file mode 100644 index 0000000..15d5b31 --- /dev/null +++ b/FREEZING_ISSUE_FIXES.md @@ -0,0 +1,330 @@ +# TechIRCd Freezing Issue - Fixes Applied + +## Issue Description +TechIRCd was experiencing freezing/hanging issues where the server would become unresponsive after running for a while, preventing new user connections and causing existing users to disconnect. + +## Root Causes Identified + +### 1. Resource Leaks in Client Handling +- **Problem**: Disconnected clients weren't being properly cleaned up, leading to memory leaks and resource exhaustion +- **Symptoms**: Server gradually becoming slower and eventually unresponsive + +### 2. Goroutine Leaks +- **Problem**: Client handler goroutines weren't exiting properly when connections were broken +- **Symptoms**: Increasing number of goroutines over time, eventually exhausting system resources + +### 3. Lock Contention in Ping/Health Checks +- **Problem**: Server-wide ping and health checks were holding locks too long and running synchronously +- **Symptoms**: Server becoming unresponsive during health checks, client operations timing out + +### 4. Inefficient Connection State Management +- **Problem**: Inconsistent tracking of client connection state, leading to operations on dead connections +- **Symptoms**: Hanging writes, blocked goroutines, server freezing + +### 5. Deadlocked Shutdown Process +- **Problem**: Shutdown process could deadlock when trying to notify clients while holding locks +- **Symptoms**: Ctrl+C not working, server becoming completely unresponsive, requiring SIGKILL + +## Fixes Applied + +### 1. Enhanced Client Cleanup (`client.go`) + +#### Before: +```go +func (c *Client) cleanup() { + // Basic cleanup with potential blocking operations + if c.conn != nil { + c.conn.Close() // Could hang + } + // Synchronous channel cleanup + // Synchronous server removal +} +``` + +#### After: +```go +func (c *Client) cleanup() { + // Non-blocking cleanup with timeouts + if c.conn != nil { + c.conn.SetDeadline(time.Now().Add(5 * time.Second)) + c.conn.Close() + } + // Asynchronous channel cleanup in goroutine + // Asynchronous server removal in goroutine +} +``` + +**Benefits:** +- Prevents cleanup operations from blocking other clients +- Uses timeouts to force close hanging connections +- Prevents deadlocks during shutdown + +### 2. Improved Connection Handling + +#### Connection Timeout Management: +```go +func (c *Client) handleMessageRead(...) bool { + // Set read deadline with timeout to prevent hanging + readTimeout := 30 * time.Second + if c.IsRegistered() { + readTimeout = 5 * time.Minute + } + + // Use goroutine with timeout for non-blocking reads + scanChan := make(chan bool, 1) + go func() { + // Scanner in separate goroutine + scanChan <- scanner.Scan() + }() + + select { + case result := <-scanChan: + // Process result + case <-time.After(readTimeout): + // Handle timeout + return false + } +} +``` + +**Benefits:** +- Prevents infinite blocking on read operations +- Detects and handles dead connections quickly +- Provides graceful timeout handling + +### 3. Non-blocking Ping and Health Checks (`server.go`) + +#### Before: +```go +func (s *Server) pingRoutine() { + ticker := time.NewTicker(30 * time.Second) + for { + select { + case <-ticker.C: + s.performPingCheck() // Blocking operation + } + } +} +``` + +#### After: +```go +func (s *Server) pingRoutine() { + ticker := time.NewTicker(60 * time.Second) // Less frequent + for { + select { + case <-ticker.C: + go s.performPingCheck() // Non-blocking + } + } +} +``` + +#### Enhanced Ping Check: +```go +func (s *Server) performPingCheck() { + // Get snapshot of client IDs without holding lock + s.mu.RLock() + clientIDs := make([]string, 0, len(s.clients)) + for clientID := range s.clients { + clientIDs = append(clientIDs, clientID) + } + s.mu.RUnlock() + + // Process clients individually to prevent blocking + for _, clientID := range clientIDs { + // Non-blocking ping sending + go func(id string) { + // Send ping in separate goroutine + }(clientID) + } +} +``` + +**Benefits:** +- Eliminates blocking during server-wide operations +- Reduces lock contention +- Prevents cascade failures + +### 4. Batched Health Checks + +#### Before: +```go +func (s *Server) performHealthCheck() { + // Hold lock for entire operation + s.mu.RLock() + clients := make([]*Client, 0, len(s.clients)) + // ... process all clients synchronously + s.mu.RUnlock() +} +``` + +#### After: +```go +func (s *Server) performHealthCheck() { + // Process clients in batches of 50 + batchSize := 50 + for i := 0; i < len(clientIDs); i += batchSize { + batch := clientIDs[i:end] + for _, clientID := range batch { + // Process each client individually + } + // Small delay between batches + time.Sleep(10 * time.Millisecond) + } +} +``` + +**Benefits:** +- Prevents overwhelming the system during health checks +- Allows other operations to proceed between batches +- Reduces memory usage during large client counts + +### 5. Enhanced Error Recovery + +#### Panic Recovery: +```go +defer func() { + if r := recover(); r != nil { + log.Printf("Panic in client handler for %s: %v", c.getClientInfo(), r) + } + c.cleanup() +}() +``` + +#### Graceful Disconnection: +```go +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() + + if c.conn != nil { + c.SendMessage(fmt.Sprintf("ERROR :%s", reason)) + } +} +``` + +### 5. Robust Shutdown Process (`server.go` & `main.go`) + +#### Before: +```go +func (s *Server) Shutdown() { + // Could deadlock holding locks + s.mu.RLock() + for _, client := range s.clients { + client.SendMessage("ERROR :Server shutting down") + } + s.mu.RUnlock() + close(s.shutdown) +} +``` + +#### After: +```go +func (s *Server) Shutdown() { + // Non-blocking shutdown with timeout protection + + // Close listeners immediately + go func() { /* close listeners */ }() + + // Signal shutdown first + close(s.shutdown) + + // Disconnect clients in batches asynchronously + go func() { + // Process clients in batches of 10 + // Each client disconnection in separate goroutine + // Timeout protection for each operation + }() + + // Force shutdown after reasonable timeout + time.Sleep(2 * time.Second) +} +``` + +#### Signal Handling with Force Option: +```go +// Double Ctrl+C for immediate shutdown +if shutdownInProgress { + log.Println("Forcing immediate shutdown...") + os.Exit(1) +} + +// Timeout protection for graceful shutdown +select { +case <-shutdownComplete: + log.Println("Graceful shutdown completed") +case <-time.After(10 * time.Second): + log.Println("Shutdown timeout, forcing exit...") +} +``` + +**Benefits:** +- Prevents deadlocks during shutdown +- Allows double Ctrl+C for immediate force shutdown +- Timeout protection prevents hanging shutdown +- Asynchronous operations prevent blocking + +## Configuration Optimizations + +### Timing Adjustments: +- **Ping Interval**: Increased from 30s to 60s to reduce overhead +- **Health Check Interval**: Increased from 60s to 5 minutes +- **Read Timeouts**: More conservative timeouts for better stability +- **Registration Timeout**: Better enforcement to prevent hanging registrations + +### Resource Limits: +- **Batch Processing**: Health checks limited to 50 clients per batch +- **Connection Limits**: Better enforcement of max client limits +- **Memory Management**: Proactive cleanup of disconnected clients + +## Expected Results + +1. **Stability**: Server should remain responsive under normal load +2. **Resource Usage**: More predictable memory and goroutine usage +3. **Connection Handling**: Faster detection and cleanup of dead connections +4. **Performance**: Reduced lock contention and blocking operations +5. **Monitoring**: Better logging and health monitoring + +## Monitoring + +The server now provides better logging for: +- Client connection/disconnection events +- Health check results and statistics +- Resource usage patterns +- Error conditions and recovery actions + +## Testing + +I've created test scripts to verify the fixes: + +### 1. Shutdown Test (`test_shutdown.sh`) +- Tests graceful shutdown behavior +- Verifies server responds to SIGTERM +- Confirms shutdown completes within reasonable time + +### 2. Stress Test (`test_stress.sh`) +- Simulates conditions that previously caused freezing +- Creates multiple stable and unstable connections +- Tests rapid connection/disconnection patterns +- Monitors server responsiveness during stress +- Verifies shutdown works after stress conditions + +### Usage: +```bash +# Test shutdown behavior +./test_shutdown.sh + +# Test stability under stress +./test_stress.sh +``` + +## Future Improvements + +1. **Metrics Endpoint**: Add HTTP endpoint for real-time metrics +2. **Connection Pooling**: Implement connection pooling for better resource management +3. **Circuit Breakers**: Add circuit breakers for failing operations +4. **Rate Limiting**: Enhanced rate limiting per IP/user diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7e845c6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 ComputerTech312 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..35aaa57 --- /dev/null +++ b/Makefile @@ -0,0 +1,117 @@ +.PHONY: build run clean test lint fmt help + +# Default target +.DEFAULT_GOAL := help + +# Variables +BINARY_NAME=techircd +CMD_PATH=. +VERSION?=$(shell git describe --tags --always --dirty) +LDFLAGS=-ldflags "-X main.version=$(VERSION)" + +## Build Commands + +# Build the IRC server +build: ## Build the binary + go build $(LDFLAGS) -o $(BINARY_NAME) $(CMD_PATH) + +# Build for different platforms +build-all: ## Build for multiple platforms + GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BINARY_NAME)-linux-amd64 $(CMD_PATH) + GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o $(BINARY_NAME)-windows-amd64.exe $(CMD_PATH) + GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o $(BINARY_NAME)-darwin-amd64 $(CMD_PATH) + GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o $(BINARY_NAME)-darwin-arm64 $(CMD_PATH) + +## Development Commands + +# Run the server +run: build ## Build and run the server + ./$(BINARY_NAME) + +# Run with custom config +run-config: build ## Run with custom config file + ./$(BINARY_NAME) -config configs/config.dev.json + +# Install dependencies +deps: ## Download and install dependencies + go mod download + go mod tidy + +# Format code +fmt: ## Format Go code + go fmt ./... + goimports -w -local github.com/ComputerTech312/TechIRCd . + +# Lint code +lint: ## Run linters + golangci-lint run + +# Fix linting issues +lint-fix: ## Fix auto-fixable linting issues + golangci-lint run --fix + +## Testing Commands + +# Run all tests +test: ## Run all tests + go test -v -race ./... + +# Run tests with coverage +test-coverage: ## Run tests with coverage report + go test -v -race -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + +# Run benchmarks +benchmark: ## Run benchmark tests + go test -bench=. -benchmem ./... + +# Test with the test client +test-client: build ## Test with the simple client + go run tests/test_client.go + +## Release Commands + +# Create a release build +release: clean ## Create optimized release build + CGO_ENABLED=0 go build $(LDFLAGS) -a -installsuffix cgo -o $(BINARY_NAME) $(CMD_PATH) + +# Tag a new version +tag: ## Tag a new version (usage: make tag VERSION=v1.0.1) + git tag -a $(VERSION) -m "Release $(VERSION)" + git push origin $(VERSION) + +## Utility Commands + +# Clean build artifacts +clean: ## Clean build artifacts + rm -f $(BINARY_NAME)* + rm -f coverage.out coverage.html + +# Show git status and recent commits +status: ## Show git status and recent commits + @echo "Git Status:" + @git status --short + @echo "\nRecent Commits:" + @git log --oneline -10 + +# Install development tools +install-tools: ## Install development tools + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + go install golang.org/x/tools/cmd/goimports@latest + +# Generate documentation +docs: ## Generate documentation + go doc -all > docs/API.md + +# Show help +help: ## Show this help message + @echo 'Usage:' + @echo ' make ' + @echo '' + @echo 'Targets:' + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + go fmt ./... + +# Run with race detection +run-race: build + go run -race *.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb10058 --- /dev/null +++ b/README.md @@ -0,0 +1,217 @@ +# TechIRCd + +A modern, high-performance IRC server written in Go with comprehensive RFC compliance and advanced features. + +## Features + +### 🚀 **Core IRC Protocol** +- Full RFC 2812 compliance (Internet Relay Chat: Client Protocol) +- User registration and authentication +- Channel management with comprehensive modes +- Private messaging and notices +- **Ultra-flexible WHOIS system** with granular privacy controls +- WHO and NAMES commands +- Ping/Pong keepalive mechanism + +### 🔍 **Revolutionary WHOIS System** +- **Granular Privacy Controls**: Configure exactly what information is visible to everyone, operators, or only the user themselves +- **User Modes Visibility**: Control who can see user modes (+i, +w, +s, etc.) +- **SSL Status Display**: Show secure connection status +- **Idle Time & Signon Time**: Configurable time information +- **Real Host vs Masked Host**: Smart hostname display based on permissions +- **Channel Privacy**: Hide secret/private channels with fine-grained control +- **Operator Information**: Show operator class and privileges +- **Services Integration**: Account name display for services +- **Client Information**: Show IRC client details +- **Custom Fields**: Add your own WHOIS fields +- See [WHOIS Configuration Guide](docs/WHOIS_CONFIGURATION.md) for details + +### 👑 **Advanced Channel Management** +- **Operator Hierarchy**: Owners (~), Operators (@), Half-ops (%), Voice (+) +- **Channel Modes**: + - `+m` (moderated) - Only voiced users can speak + - `+n` (no external messages) + - `+t` (topic protection) + - `+i` (invite only) + - `+s` (secret channel) + - `+p` (private channel) + - `+k` (channel key/password) + - `+l` (user limit) + - `+b` (ban list) +- **Extended Ban System**: Support for quiet mode (`~q:mask`) and other extended ban types + +### 🔐 **IRC Operator Features** +- Comprehensive operator authentication system +- **Hierarchical Operator Classes** with rank-based permissions and inheritance +- **Completely Customizable Rank Names** (Gaming, Corporate, Fantasy themes, etc.) +- **Server Notice Masks (SNOmasks)**: + - `+c` (connection notices) + - `+k` (kill notices) + - +o (oper notices) + - +x (ban/quiet notices) + - +f (flood notices) + - +n (nick change notices) + - +s (server notices) + - +d (debug notices) +- Operator commands: + - KILL - Disconnect users + - GLOBALNOTICE - Send notices to all users + - OPERWALL - Send messages to all operators + - WALLOPS - Send wallops messages + - REHASH - Reload configuration + - TRACE - Network trace information + - GODMODE - Toggle ultimate channel override powers + - STEALTH - Toggle invisibility to regular users +- Services/admin commands: + - CHGHOST - Change user's hostname + - SVSNICK - Services force nickname change + - SVSMODE - Services force mode change + - SAMODE - Services admin mode change + - SANICK - Services admin nickname change + - SAKICK - Services admin kick + - SAPART - Services admin part + - SAJOIN - Services admin join + - GLINE - Global network ban + - ZLINE - IP address ban + - KLINE - Local server ban + - SHUN - Services shun (silent ignore) + +### 👤 **User Modes** +- `+i` (invisible) - Hide from WHO listings +- `+w` (wallops) - Receive wallops messages +- `+s` (server notices) - Receive server notices (opers only) +- `+o` (operator) - IRC operator status +- `+x` (host masking) - Hide real hostname +- `+B` (bot) - Mark as a bot +- `+z` (SSL) - Connected via SSL/TLS +- `+r` (registered) - Registered with services + +### 🛡️ **Security & Stability** +- Advanced flood protection with operator exemption +- Connection timeout management +- Input validation and sanitization +- Panic recovery and error handling +- Resource monitoring and health checks +- Graceful shutdown capabilities + +### ⚡ **Unique Features (Not Found in Other IRCds)** + +#### 🌟 **God Mode (+G)** +- **Ultimate Channel Override**: Join any channel regardless of bans, limits, keys, or invite-only mode +- **Kick Immunity**: Cannot be kicked by any user +- **Mode Override**: Set any channel mode without operator privileges +- **Complete Bypass**: Ignore all channel restrictions and limitations + +#### 👻 **Stealth Mode (+S)** +- **User Invisibility**: Completely hidden from regular users in WHO, NAMES, and WHOIS +- **Operator Visibility**: Other operators can still see stealth users +- **Covert Monitoring**: Watch channels without being detected +- **Security Operations**: Investigate issues invisibly + +#### 🎨 **Customizable Rank Names** +- **Gaming Themes**: Cadet → Sergeant → Lieutenant → Captain → General +- **Corporate Themes**: Intern → Associate → Manager → Director → CEO +- **Fantasy Themes**: Apprentice → Guardian → Knight → Lord → King +- **Unlimited Creativity**: Create any rank system you can imagine + +#### 🔍 **Revolutionary WHOIS** +- **Granular Privacy**: 15+ configurable information types +- **Three-Tier Permissions**: Everyone/Operators/Self visibility controls +- **Custom Fields**: Add your own WHOIS information +- **Complete Flexibility**: Control every aspect of user information display + +### 📊 **Monitoring & Logging** +- Real-time health monitoring +- Memory usage tracking +- Goroutine count monitoring +- Performance metrics logging +- Configurable log levels and rotation +- Private messaging +- WHO/WHOIS commands +- Configurable server settings +- Extensible architecture +- Ping/Pong keep-alive mechanism +- Graceful shutdown handling + +## Requirements + +- Go 1.21 or higher + +## Building + +```bash +# Install dependencies +go mod tidy + +# Build the server +go run tools/build.go -build + +# Or use the traditional method +go build -o techircd +``` + +## Build Options + +The `tools/build.go` script provides several build options: + +```bash +# Build and run +go run tools/build.go -run + +# Run tests +go run tools/build.go -test + +# Format code +go run tools/build.go -fmt + +# Cross-platform builds +go run tools/build.go -build-all + +# Optimized release build +go run tools/build.go -release + +# Clean build artifacts +go run tools/build.go -clean + +# Show all options +go run tools/build.go -help +``` + +## Usage + +```bash +./techircd +``` + +The server will start on localhost:6667 by default. You can configure the host and port in `config.go`. + +## Configuration + +Edit `config.go` to customize: +- Server host and port +- Server name and description +- Maximum connections +- Ping timeout +- Message of the Day (MOTD) + +## Connecting + +You can connect using any IRC client: +``` +/server localhost 6667 +``` + +Or use the included test client: +```bash +go run test_client.go +``` + +## Architecture + +- `main.go` - Entry point and server initialization +- `server.go` - Main IRC server implementation with concurrent connection handling +- `client.go` - Client connection handling and state management +- `channel.go` - Channel management with thread-safe operations +- `commands.go` - IRC command implementations (NICK, USER, JOIN, PART, etc.) +- `config.go` - Server configuration structure +- `test_client.go` - Simple test client for development diff --git a/STRESS_TEST_README.md b/STRESS_TEST_README.md new file mode 100644 index 0000000..4b70498 --- /dev/null +++ b/STRESS_TEST_README.md @@ -0,0 +1,127 @@ +# TechIRCd Stress Testing Tool + +A comprehensive Python-based stress testing tool for TechIRCd IRC server. + +## Features + +- **Configurable Test Scenarios**: JSON-based configuration +- **Multiple Test Types**: Connection tests, message flooding, command spam +- **Gradual or Mass Operations**: Connect/disconnect clients gradually or all at once +- **Real-time Statistics**: Connection counts, message statistics, error tracking +- **Logging**: Detailed logging to file and console +- **SSL Support**: Test both plain and SSL connections + +## Requirements + +```bash +pip3 install asyncio # Usually included with Python 3.7+ +``` + +## Usage + +### Run All Test Scenarios +```bash +python3 stress_test.py +``` + +### Run Specific Scenario +```bash +python3 stress_test.py --scenario "Basic Connection Test" +``` + +### Use Custom Configuration +```bash +python3 stress_test.py --config my_config.json +``` + +## Configuration + +Edit `stress_config.json` to customize tests: + +### Server Settings +```json +{ + "server": { + "host": "localhost", // IRC server hostname + "port": 6667, // IRC server port + "ssl": false, // Use SSL connection + "ssl_port": 6697, // SSL port + "nick_prefix": "StressBot", // Prefix for bot nicknames + "auto_join_channels": ["#test"] // Channels to auto-join + } +} +``` + +### Test Scenarios + +Each scenario supports: +- `client_count`: Number of concurrent clients +- `duration`: How long to run the test (seconds) +- `connect_gradually`: Connect clients one by one vs all at once +- `connect_delay`: Delay between gradual connections +- `disconnect_gradually`: Disconnect gradually vs all at once +- `activities`: List of activities to perform during test + +### Activity Types + +1. **channel_flood**: Flood channels with messages +2. **private_flood**: Send private messages between clients +3. **join_part_spam**: Rapid JOIN/PART operations +4. **nick_change_spam**: Rapid nickname changes +5. **random_commands**: Send random IRC commands + +## Pre-configured Test Scenarios + +1. **Basic Connection Test** (10 clients, 30s) + - Simple connection and registration test + +2. **Mass Connection Stress** (50 clients, 60s) + - Many simultaneous connections with channel flooding + +3. **Gradual Connection Test** (30 clients, 45s) + - Gradual connection buildup with private messaging + +4. **Heavy Activity Test** (25 clients, 90s) + - Multiple activity types running simultaneously + +5. **Nick Change Spam** (15 clients, 30s) + - Rapid nickname change testing + +6. **Channel Chaos Test** (20 clients, 60s) + - JOIN/PART spam with message flooding + +7. **Maximum Load Test** (100 clients, 120s) + - Push server to maximum capacity + +## Monitoring + +- **Real-time logs**: Monitor progress in console +- **Log file**: Detailed logs saved to `stress_test.log` +- **Statistics**: Connection counts, message statistics, error tracking + +## Example Output + +``` +2025-09-07 12:00:00 - INFO - Starting scenario: Basic Connection Test +2025-09-07 12:00:00 - INFO - Clients: 10, Duration: 30s +2025-09-07 12:00:00 - INFO - Mass connecting 10 clients... +2025-09-07 12:00:01 - INFO - Connected 10/10 clients +2025-09-07 12:00:03 - INFO - Registered 10/10 clients +2025-09-07 12:00:33 - INFO - Stats: 10 connected, 10 registered, 0 messages sent +2025-09-07 12:00:35 - INFO - Scenario Basic Connection Test completed +``` + +## Tips for Testing + +1. **Start Small**: Begin with basic connection test +2. **Monitor Resources**: Watch TechIRCd memory/CPU usage +3. **Check Logs**: Review both stress test and TechIRCd logs +4. **Gradual Increase**: Increase client counts gradually +5. **Test Different Scenarios**: Each scenario tests different aspects + +## Troubleshooting + +- **Connection Refused**: Make sure TechIRCd is running +- **SSL Errors**: Check SSL configuration in both TechIRCd and test config +- **Memory Issues**: Reduce client count or test duration +- **Python Errors**: Ensure Python 3.7+ with asyncio support diff --git a/TechIRCd b/TechIRCd new file mode 100755 index 0000000..d925a98 Binary files /dev/null and b/TechIRCd differ diff --git a/channel.go b/channel.go new file mode 100644 index 0000000..34df687 --- /dev/null +++ b/channel.go @@ -0,0 +1,836 @@ +package main + +import ( + "fmt" + "path/filepath" + "strings" + "sync" + "time" +) + +type Channel struct { + name string + topic string + topicBy string + topicTime time.Time + clients map[string]*Client + operators map[string]*Client + halfops map[string]*Client + voices map[string]*Client + owners map[string]*Client + admins map[string]*Client + modes map[rune]bool + key string + limit int + banList []string + quietList []string // Users who can join but not speak (+q) + exceptList []string // Users exempt from bans (+e) + inviteList []string // Users who can join invite-only channels (+I) + created time.Time + + // Advanced mode settings + floodSettings string // Flood protection settings (e.g., "10:5") + joinThrottle string // Join throttling settings (e.g., "3:10") + + mu sync.RWMutex +} + +func NewChannel(name string) *Channel { + return &Channel{ + name: name, + clients: make(map[string]*Client), + operators: make(map[string]*Client), + halfops: make(map[string]*Client), + voices: make(map[string]*Client), + owners: make(map[string]*Client), + admins: make(map[string]*Client), + modes: make(map[rune]bool), + banList: make([]string, 0), + quietList: make([]string, 0), + exceptList: make([]string, 0), + inviteList: make([]string, 0), + created: time.Now(), + } +} + +func (ch *Channel) Name() string { + ch.mu.RLock() + defer ch.mu.RUnlock() + return ch.name +} + +func (ch *Channel) Topic() string { + ch.mu.RLock() + defer ch.mu.RUnlock() + return ch.topic +} + +func (ch *Channel) TopicBy() string { + ch.mu.RLock() + defer ch.mu.RUnlock() + return ch.topicBy +} + +func (ch *Channel) TopicTime() time.Time { + ch.mu.RLock() + defer ch.mu.RUnlock() + return ch.topicTime +} + +func (ch *Channel) SetTopic(topic, by string) { + ch.mu.Lock() + defer ch.mu.Unlock() + ch.topic = topic + ch.topicBy = by + ch.topicTime = time.Now() +} + +func (ch *Channel) AddClient(client *Client) { + ch.mu.Lock() + defer ch.mu.Unlock() + + nick := strings.ToLower(client.Nick()) + + // Check if client is already in the channel + if _, exists := ch.clients[nick]; exists { + // Client already in channel, don't add again + return + } + + ch.clients[nick] = client + client.AddChannel(ch) + + // First user gets configured founder mode (default: operator) + if len(ch.clients) == 1 { + // Get the configured founder mode from server config + founderMode := "o" // Default fallback + if client.server != nil && client.server.config != nil { + founderMode = client.server.config.Channels.FounderMode + } + + // Apply the appropriate mode based on configuration + switch founderMode { + case "q": + ch.owners[nick] = client + case "a": + ch.admins[nick] = client + case "o": + ch.operators[nick] = client + case "h": + ch.halfops[nick] = client + case "v": + ch.voices[nick] = client + default: + // Invalid config, fallback to operator + ch.operators[nick] = client + } + } +} + +func (ch *Channel) RemoveClient(client *Client) { + ch.mu.Lock() + defer ch.mu.Unlock() + + nick := strings.ToLower(client.Nick()) + delete(ch.clients, nick) + delete(ch.operators, nick) + delete(ch.halfops, nick) + delete(ch.voices, nick) + delete(ch.owners, nick) + delete(ch.admins, nick) + client.RemoveChannel(ch.name) +} + +func (ch *Channel) HasClient(client *Client) bool { + ch.mu.RLock() + defer ch.mu.RUnlock() + _, exists := ch.clients[strings.ToLower(client.Nick())] + return exists +} + +func (ch *Channel) IsOperator(client *Client) bool { + ch.mu.RLock() + defer ch.mu.RUnlock() + _, exists := ch.operators[strings.ToLower(client.Nick())] + return exists +} + +func (ch *Channel) IsVoice(client *Client) bool { + ch.mu.RLock() + defer ch.mu.RUnlock() + _, exists := ch.voices[strings.ToLower(client.Nick())] + return exists +} + +func (ch *Channel) IsHalfop(client *Client) bool { + ch.mu.RLock() + defer ch.mu.RUnlock() + _, exists := ch.halfops[strings.ToLower(client.Nick())] + return exists +} + +func (ch *Channel) IsOwner(client *Client) bool { + ch.mu.RLock() + defer ch.mu.RUnlock() + _, exists := ch.owners[strings.ToLower(client.Nick())] + return exists +} + +func (ch *Channel) IsQuieted(client *Client) bool { + ch.mu.RLock() + defer ch.mu.RUnlock() + return ch.isQuietedUnsafe(client) +} + +func (ch *Channel) isQuietedUnsafe(client *Client) bool { + hostmask := fmt.Sprintf("%s!%s@%s", client.Nick(), client.User(), client.Host()) + + for _, quiet := range ch.quietList { + if ch.matchesBanMask(client, quiet, hostmask) { + return true + } + } + return false +} + +func (ch *Channel) SetOperator(client *Client, isOp bool) { + ch.mu.Lock() + defer ch.mu.Unlock() + + nick := strings.ToLower(client.Nick()) + if isOp { + ch.operators[nick] = client + } else { + delete(ch.operators, nick) + } +} + +func (ch *Channel) SetVoice(client *Client, hasVoice bool) { + ch.mu.Lock() + defer ch.mu.Unlock() + + nick := strings.ToLower(client.Nick()) + if hasVoice { + ch.voices[nick] = client + } else { + delete(ch.voices, nick) + } +} + +func (ch *Channel) SetHalfop(client *Client, isHalfop bool) { + ch.mu.Lock() + defer ch.mu.Unlock() + + nick := strings.ToLower(client.Nick()) + if isHalfop { + ch.halfops[nick] = client + } else { + delete(ch.halfops, nick) + } +} + +func (ch *Channel) SetOwner(client *Client, isOwner bool) { + ch.mu.Lock() + defer ch.mu.Unlock() + + nick := strings.ToLower(client.Nick()) + if isOwner { + ch.owners[nick] = client + } else { + delete(ch.owners, nick) + } +} + +func (ch *Channel) IsAdmin(client *Client) bool { + ch.mu.RLock() + defer ch.mu.RUnlock() + _, exists := ch.admins[strings.ToLower(client.Nick())] + return exists +} + +func (ch *Channel) SetAdmin(client *Client, isAdmin bool) { + ch.mu.Lock() + defer ch.mu.Unlock() + + nick := strings.ToLower(client.Nick()) + if isAdmin { + ch.admins[nick] = client + } else { + delete(ch.admins, nick) + } +} + +func (ch *Channel) GetClients() []*Client { + ch.mu.RLock() + defer ch.mu.RUnlock() + + clients := make([]*Client, 0, len(ch.clients)) + for _, client := range ch.clients { + clients = append(clients, client) + } + return clients +} + +func (ch *Channel) GetClientCount() int { + ch.mu.RLock() + defer ch.mu.RUnlock() + return len(ch.clients) +} + +func (ch *Channel) UserCount() int { + return ch.GetClientCount() +} + +func (ch *Channel) Broadcast(message string, exclude *Client) { + ch.mu.RLock() + defer ch.mu.RUnlock() + + for _, client := range ch.clients { + if exclude != nil && client.Nick() == exclude.Nick() { + continue + } + client.SendMessage(message) + } +} + +func (ch *Channel) BroadcastFrom(source, message string, exclude *Client) { + ch.mu.RLock() + defer ch.mu.RUnlock() + + for _, client := range ch.clients { + if exclude != nil && client.Nick() == exclude.Nick() { + continue + } + client.SendFrom(source, message) + } +} + +func (ch *Channel) HasMode(mode rune) bool { + ch.mu.RLock() + defer ch.mu.RUnlock() + return ch.modes[mode] +} + +func (ch *Channel) SetMode(mode rune, set bool) { + ch.mu.Lock() + defer ch.mu.Unlock() + + if set { + ch.modes[mode] = true + } else { + delete(ch.modes, mode) + } +} + +func (ch *Channel) GetModes() string { + ch.mu.RLock() + defer ch.mu.RUnlock() + + var modes []rune + for mode := range ch.modes { + modes = append(modes, mode) + } + + if len(modes) == 0 { + return "" + } + + return "+" + string(modes) +} + +func (ch *Channel) CanSendMessage(client *Client) bool { + ch.mu.RLock() + defer ch.mu.RUnlock() + + // Check if user is quieted first + if ch.isQuietedUnsafe(client) { + // Only owners, operators, and halfops can speak when quieted + nick := strings.ToLower(client.Nick()) + _, isOwner := ch.owners[nick] + _, isOp := ch.operators[nick] + _, isHalfop := ch.halfops[nick] + + if !isOwner && !isOp && !isHalfop { + return false + } + } + + // If channel is not moderated, anyone in the channel can send + if !ch.modes['m'] { + return true + } + + // In moderated channels, only owners, operators, halfops and voiced users can send messages + nick := strings.ToLower(client.Nick()) + _, isOwner := ch.owners[nick] + _, isOp := ch.operators[nick] + _, isHalfop := ch.halfops[nick] + _, hasVoice := ch.voices[nick] + + return isOwner || isOp || isHalfop || hasVoice +} + +func (ch *Channel) Key() string { + ch.mu.RLock() + defer ch.mu.RUnlock() + return ch.key +} + +func (ch *Channel) SetKey(key string) { + ch.mu.Lock() + defer ch.mu.Unlock() + ch.key = key +} + +func (ch *Channel) Limit() int { + ch.mu.RLock() + defer ch.mu.RUnlock() + return ch.limit +} + +func (ch *Channel) SetLimit(limit int) { + ch.mu.Lock() + defer ch.mu.Unlock() + ch.limit = limit +} + +func (ch *Channel) GetNamesReply() string { + ch.mu.RLock() + defer ch.mu.RUnlock() + + var names []string + for _, client := range ch.clients { + prefix := "" + if ch.IsOwner(client) { + prefix = "~" + } else if ch.IsOperator(client) { + prefix = "@" + } else if ch.IsHalfop(client) { + prefix = "%" + } else if ch.IsVoice(client) { + prefix = "+" + } + names = append(names, prefix+client.Nick()) + } + + return strings.Join(names, " ") +} + +func (ch *Channel) CanSpeak(client *Client) bool { + ch.mu.RLock() + defer ch.mu.RUnlock() + + // If channel is not moderated, anyone can speak + if !ch.modes['m'] { + return true + } + + // Operators and voiced users can always speak + return ch.IsOperator(client) || ch.IsVoice(client) +} + +func (ch *Channel) CanJoin(client *Client, key string) bool { + ch.mu.RLock() + defer ch.mu.RUnlock() + + // Check if invite-only + if ch.modes['i'] { + // Check invite list + for _, mask := range ch.inviteList { + if ch.matchesMask(client.Prefix(), mask) { + return true + } + } + return false + } + + // Check key + if ch.modes['k'] && ch.key != key { + return false + } + + // Check limit + if ch.modes['l'] && len(ch.clients) >= ch.limit { + return false + } + + // Check ban list + for _, mask := range ch.banList { + if ch.matchesMask(client.Prefix(), mask) { + // Check exception list + for _, exceptMask := range ch.exceptList { + if ch.matchesMask(client.Prefix(), exceptMask) { + return true + } + } + return false + } + } + + return true +} + +func (ch *Channel) matchesMask(target, mask string) bool { + // Simple mask matching - should be enhanced for production + return strings.Contains(strings.ToLower(target), strings.ToLower(mask)) +} + +func (ch *Channel) AddBan(mask string) { + ch.mu.Lock() + defer ch.mu.Unlock() + ch.banList = append(ch.banList, mask) +} + +func (ch *Channel) RemoveBan(mask string) { + ch.mu.Lock() + defer ch.mu.Unlock() + + for i, ban := range ch.banList { + if ban == mask { + ch.banList = append(ch.banList[:i], ch.banList[i+1:]...) + break + } + } +} + +func (ch *Channel) GetBans() []string { + ch.mu.RLock() + defer ch.mu.RUnlock() + + bans := make([]string, len(ch.banList)) + copy(bans, ch.banList) + return bans +} + +// Extended ban list management +func (ch *Channel) AddQuiet(mask string) { + ch.mu.Lock() + defer ch.mu.Unlock() + ch.quietList = append(ch.quietList, mask) +} + +func (ch *Channel) RemoveQuiet(mask string) { + ch.mu.Lock() + defer ch.mu.Unlock() + + for i, quiet := range ch.quietList { + if quiet == mask { + ch.quietList = append(ch.quietList[:i], ch.quietList[i+1:]...) + break + } + } +} + +func (ch *Channel) GetQuiets() []string { + ch.mu.RLock() + defer ch.mu.RUnlock() + + quiets := make([]string, len(ch.quietList)) + copy(quiets, ch.quietList) + return quiets +} + +func (ch *Channel) AddExcept(mask string) { + ch.mu.Lock() + defer ch.mu.Unlock() + ch.exceptList = append(ch.exceptList, mask) +} + +func (ch *Channel) RemoveExcept(mask string) { + ch.mu.Lock() + defer ch.mu.Unlock() + + for i, except := range ch.exceptList { + if except == mask { + ch.exceptList = append(ch.exceptList[:i], ch.exceptList[i+1:]...) + break + } + } +} + +func (ch *Channel) GetExcepts() []string { + ch.mu.RLock() + defer ch.mu.RUnlock() + + excepts := make([]string, len(ch.exceptList)) + copy(excepts, ch.exceptList) + return excepts +} + +func (ch *Channel) AddInviteException(mask string) { + ch.mu.Lock() + defer ch.mu.Unlock() + ch.inviteList = append(ch.inviteList, mask) +} + +func (ch *Channel) RemoveInviteException(mask string) { + ch.mu.Lock() + defer ch.mu.Unlock() + + for i, invite := range ch.inviteList { + if invite == mask { + ch.inviteList = append(ch.inviteList[:i], ch.inviteList[i+1:]...) + break + } + } +} + +func (ch *Channel) GetInviteExceptions() []string { + ch.mu.RLock() + defer ch.mu.RUnlock() + + invites := make([]string, len(ch.inviteList)) + copy(invites, ch.inviteList) + return invites +} + +// IsExempt checks if a client is exempt from bans +func (ch *Channel) IsExempt(client *Client) bool { + ch.mu.RLock() + defer ch.mu.RUnlock() + + hostmask := fmt.Sprintf("%s!%s@%s", client.Nick(), client.User(), client.Host()) + + for _, except := range ch.exceptList { + if ch.matchesBanMask(client, except, hostmask) { + return true + } + } + return false +} + +// CanJoinInviteOnly checks if a client can join an invite-only channel +func (ch *Channel) CanJoinInviteOnly(client *Client) bool { + ch.mu.RLock() + defer ch.mu.RUnlock() + + hostmask := fmt.Sprintf("%s!%s@%s", client.Nick(), client.User(), client.Host()) + + for _, invite := range ch.inviteList { + if ch.matchesBanMask(client, invite, hostmask) { + return true + } + } + return false +} + +func (ch *Channel) Created() time.Time { + ch.mu.RLock() + defer ch.mu.RUnlock() + return ch.created +} + +// IsBanned checks if a client matches any ban mask in the channel +func (ch *Channel) IsBanned(client *Client) bool { + ch.mu.RLock() + defer ch.mu.RUnlock() + + // Check exemptions first - if exempt, not banned + if ch.isExemptUnsafe(client) { + return false + } + + hostmask := fmt.Sprintf("%s!%s@%s", client.Nick(), client.User(), client.Host()) + + for _, ban := range ch.banList { + if ch.matchesBanMask(client, ban, hostmask) { + return true + } + } + return false +} + +// isExemptUnsafe checks exemptions without locking (internal use) +func (ch *Channel) isExemptUnsafe(client *Client) bool { + hostmask := fmt.Sprintf("%s!%s@%s", client.Nick(), client.User(), client.Host()) + + for _, except := range ch.exceptList { + if ch.matchesBanMask(client, except, hostmask) { + return true + } + } + return false +} + +// matchesBanMask checks if a client matches a ban mask (supports extended bans) +func (ch *Channel) matchesBanMask(client *Client, banMask, hostmask string) bool { + // Check for extended ban format: ~type:parameter or ~type parameter + if strings.HasPrefix(banMask, "~") { + return ch.matchesExtendedBan(client, banMask) + } + + // Traditional hostmask ban + return matchWildcard(banMask, hostmask) +} + +// matchesExtendedBan handles extended ban types +func (ch *Channel) matchesExtendedBan(client *Client, extban string) bool { + if len(extban) < 2 || extban[0] != '~' { + return false + } + + // Parse ~type:parameter or ~type parameter format + var banType string + var parameter string + + if strings.Contains(extban, ":") { + // Format: ~type:parameter + parts := strings.SplitN(extban[1:], ":", 2) + banType = parts[0] + if len(parts) > 1 { + parameter = parts[1] + } + } else { + // Format: ~type parameter (space separated) + parts := strings.Fields(extban[1:]) + if len(parts) > 0 { + banType = parts[0] + if len(parts) > 1 { + parameter = strings.Join(parts[1:], " ") + } + } + } + + switch banType { + case "a": // Account ban: ~a:accountname or ~a accountname + if parameter == "" { + // ~a with no parameter bans unregistered users + return client.account == "" + } + // ~a:account bans specific account + return client.account == parameter + + case "c": // Channel ban: ~c:#channel - ban users in another channel + if parameter == "" || !strings.HasPrefix(parameter, "#") { + return false + } + targetChannel := client.server.GetChannel(parameter) + return targetChannel != nil && targetChannel.HasClient(client) + + case "j": // Join prevent: ~j:#channel - prevent joining if in another channel + if parameter == "" || !strings.HasPrefix(parameter, "#") { + return false + } + targetChannel := client.server.GetChannel(parameter) + return targetChannel != nil && targetChannel.HasClient(client) + + case "n": // Nick pattern ban: ~n:pattern + if parameter == "" { + return false + } + return matchWildcard(parameter, client.Nick()) + + case "q": // Quiet ban: ~q:mask - this is a special case for quiet functionality + // When used in regular ban list, ~q acts as a quiet + if parameter == "" { + // ~q with no parameter quiets everyone + return true + } + // ~q:mask quiets matching users - check against hostmask pattern + hostmask := fmt.Sprintf("%s!%s@%s", client.Nick(), client.User(), client.Host()) + return matchWildcard(parameter, hostmask) + + case "r": // Real name ban: ~r:pattern + if parameter == "" { + return false + } + return matchWildcard(parameter, client.realname) + + case "s": // Server ban: ~s:servername + if parameter == "" { + return false + } + return matchWildcard(parameter, client.server.config.Server.Name) + + case "o": // Operator ban: ~o (bans all opers) + return client.IsOper() + + case "z": // Non-SSL ban: ~z (bans non-SSL users) + return !client.ssl + + case "Z": // SSL-only ban: ~Z (bans SSL users) + return client.ssl + + case "u": // Username pattern ban: ~u:pattern + if parameter == "" { + return false + } + return matchWildcard(parameter, client.User()) + + case "h": // Hostname pattern ban: ~h:pattern + if parameter == "" { + return false + } + return matchWildcard(parameter, client.Host()) + + case "i": // IP ban: ~i:ip/cidr + if parameter == "" { + return false + } + // Simple IP matching for now (could be enhanced with CIDR) + return matchWildcard(parameter, client.Host()) + + case "R": // Registered only: ~R (bans unregistered users) + return client.account == "" + + case "m": // Mute ban: ~m:mask - similar to quiet + if parameter == "" { + return true + } + hostmask := fmt.Sprintf("%s!%s@%s", client.Nick(), client.User(), client.Host()) + return matchWildcard(parameter, hostmask) + + default: + // Unknown extended ban type + return false + } +} + +// IsInvited checks if a client is on the invite list for the channel +func (ch *Channel) IsInvited(client *Client) bool { + ch.mu.RLock() + defer ch.mu.RUnlock() + + hostmask := fmt.Sprintf("%s!%s@%s", client.Nick(), client.User(), client.Host()) + + for _, invite := range ch.inviteList { + if matchWildcard(invite, hostmask) { + return true + } + } + return false +} + +// matchWildcard checks if a pattern with wildcards (* and ?) matches a string +func matchWildcard(pattern, str string) bool { + matched, _ := filepath.Match(strings.ToLower(pattern), strings.ToLower(str)) + return matched +} + +// SetFloodSettings sets the flood protection settings +func (ch *Channel) SetFloodSettings(settings string) { + ch.mu.Lock() + defer ch.mu.Unlock() + ch.floodSettings = settings +} + +// GetFloodSettings returns the flood protection settings +func (ch *Channel) GetFloodSettings() string { + ch.mu.RLock() + defer ch.mu.RUnlock() + return ch.floodSettings +} + +// SetJoinThrottle sets the join throttling settings +func (ch *Channel) SetJoinThrottle(settings string) { + ch.mu.Lock() + defer ch.mu.Unlock() + ch.joinThrottle = settings +} + +// GetJoinThrottle returns the join throttling settings +func (ch *Channel) GetJoinThrottle() string { + ch.mu.RLock() + defer ch.mu.RUnlock() + return ch.joinThrottle +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..120683c --- /dev/null +++ b/client.go @@ -0,0 +1,1223 @@ +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()) + } + } +} diff --git a/cmd/techircd/main.go b/cmd/techircd/main.go new file mode 100644 index 0000000..1fc4a9f --- /dev/null +++ b/cmd/techircd/main.go @@ -0,0 +1,106 @@ +package main + +import ( + "flag" + "log" + "os" + "os/signal" + "syscall" +) + +// Config represents the server configuration. +// You should replace the fields below with your actual configuration structure. +type Config struct { + Server struct { + Version string + Listen struct { + Host string + Port int + EnableSSL bool + } + SSL struct { + CertFile string + KeyFile string + } + } +} + +// Dummy implementations for missing functions. +// Replace these with your actual implementations. +func ParseConfig(file *os.File) (*Config, error) { + return &Config{}, nil +} +func DefaultConfig() *Config { + return &Config{} +} +func SaveConfig(cfg *Config, path string) error { + return nil +} +func (c *Config) Validate() error { + return nil +} +func (c *Config) SanitizeConfig() {} +func NewServer(cfg *Config) *Server { + return &Server{} +} + +type Server struct{} + +func (s *Server) Shutdown() {} +func (s *Server) Start() error { return nil } + +func LoadConfig(path string) (*Config, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + return ParseConfig(file) +} + +func main() { + // Parse command line flags + configFile := flag.String("config", "config.json", "Path to configuration file") + flag.Parse() + + // Load configuration + config, err := LoadConfig(*configFile) + if err != nil { + log.Printf("Failed to load config from %s, using defaults: %v", *configFile, err) + // Create default config if file doesn't exist + config = DefaultConfig() + if err := SaveConfig(config, *configFile); err != nil { + log.Printf("Failed to save default config: %v", err) + } + } + + // Validate and sanitize configuration + if err := config.Validate(); err != nil { + log.Fatalf("Configuration validation failed: %v", err) + } + config.SanitizeConfig() + log.Println("Configuration validated successfully") + + // Create and start the server + server := NewServer(config) + + // Handle graceful shutdown + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + + go func() { + <-c + log.Println("Shutting down server...") + server.Shutdown() + os.Exit(0) + }() + + // Start the server + log.Printf("Starting TechIRCd %s on %s:%d", config.Server.Version, config.Server.Listen.Host, config.Server.Listen.Port) + if config.Server.Listen.EnableSSL { + log.Printf("SSL enabled with cert: %s, key: %s", config.Server.SSL.CertFile, config.Server.SSL.KeyFile) + } + if err := server.Start(); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} diff --git a/commands.go b/commands.go new file mode 100644 index 0000000..2afb7e6 --- /dev/null +++ b/commands.go @@ -0,0 +1,4098 @@ +package main + +import ( + "fmt" + "log" + "strings" + "time" +) + +// IRCMessage represents a parsed IRC message with IRCv3 support +type IRCMessage struct { + Tags map[string]string + Prefix string + Command string + Params []string +} + +// FormatMessage formats an IRC message with IRCv3 tags +func (m *IRCMessage) FormatMessage() string { + var parts []string + + // Add tags if present + if len(m.Tags) > 0 { + var tagParts []string + for key, value := range m.Tags { + if value == "" { + tagParts = append(tagParts, key) + } else { + tagParts = append(tagParts, fmt.Sprintf("%s=%s", key, value)) + } + } + parts = append(parts, "@"+strings.Join(tagParts, ";")) + } + + // Add prefix if present + if m.Prefix != "" { + parts = append(parts, ":"+m.Prefix) + } + + // Add command + parts = append(parts, m.Command) + + // Add parameters + parts = append(parts, m.Params...) + + return strings.Join(parts, " ") +} + +// AddServerTime adds server-time tag to message +func (c *Client) AddServerTime(msg *IRCMessage) { + if c.HasCapability("server-time") { + if msg.Tags == nil { + msg.Tags = make(map[string]string) + } + msg.Tags["time"] = time.Now().UTC().Format(time.RFC3339Nano) + } +} + +// AddAccountTag adds account tag if user is authenticated +func (c *Client) AddAccountTag(msg *IRCMessage) { + if c.HasCapability("account-tag") && c.Account() != "" { + if msg.Tags == nil { + msg.Tags = make(map[string]string) + } + msg.Tags["account"] = c.Account() + } +} + +// IRC numeric reply codes +const ( + RPL_WELCOME = 001 + RPL_YOURHOST = 002 + RPL_CREATED = 003 + RPL_MYINFO = 004 + RPL_ISUPPORT = 005 + RPL_USERHOST = 302 + RPL_ISON = 303 + RPL_AWAY = 301 + RPL_UNAWAY = 305 + RPL_NOWAWAY = 306 + RPL_WHOISUSER = 311 + RPL_WHOISSERVER = 312 + RPL_WHOISOPERATOR = 313 + RPL_WHOISIDLE = 317 + RPL_ENDOFWHOIS = 318 + RPL_WHOISCHANNELS = 319 + RPL_WHOISHOST = 378 + RPL_LISTSTART = 321 + RPL_LIST = 322 + RPL_LISTEND = 323 + RPL_CHANNELMODEIS = 324 + RPL_NOTOPIC = 331 + RPL_TOPIC = 332 + RPL_TOPICWHOTIME = 333 + RPL_NAMREPLY = 353 + RPL_ENDOFNAMES = 366 + RPL_MOTDSTART = 375 + RPL_MOTD = 372 + RPL_ENDOFMOTD = 376 + RPL_UMODEIS = 221 + RPL_INVITING = 341 + RPL_YOUREOPER = 381 + // New commands + RPL_TIME = 391 + RPL_VERSION = 351 + RPL_ADMINME = 256 + RPL_ADMINLOC1 = 257 + RPL_ADMINLOC2 = 258 + RPL_ADMINEMAIL = 259 + RPL_INFO = 371 + RPL_ENDOFINFO = 374 + RPL_LUSERCLIENT = 251 + RPL_LUSEROP = 252 + RPL_LUSERUNKNOWN = 253 + RPL_LUSERCHANNELS = 254 + RPL_LUSERME = 255 + RPL_STATSCOMMANDS = 212 + RPL_STATSOLINE = 243 + RPL_STATSUPTIME = 242 + RPL_STATSLINKINFO = 211 + RPL_ENDOFSTATS = 219 + // SILENCE command numerics + RPL_SILELIST = 271 + RPL_ENDOFSILELIST = 272 + ERR_SILELISTFULL = 511 + // MONITOR command numerics (IRCv3) + RPL_MONONLINE = 730 + RPL_MONOFFLINE = 731 + RPL_MONLIST = 732 + RPL_ENDOFMONLIST = 733 + ERR_MONLISTFULL = 734 + // Ban list numerics + RPL_BANLIST = 367 + RPL_ENDOFBANLIST = 368 + // SASL authentication numerics + RPL_SASLSUCCESS = 903 + ERR_SASLFAIL = 904 + ERR_SASLNOTSUPP = 908 + ERR_SASLABORTED = 906 + ERR_NOSUCHNICK = 401 + ERR_NOSUCHSERVER = 402 + ERR_NOSUCHCHANNEL = 403 + ERR_CANNOTSENDTOCHAN = 404 + ERR_TOOMANYCHANNELS = 405 + ERR_WASNOSUCHNICK = 406 + ERR_TOOMANYTARGETS = 407 + ERR_NOORIGIN = 409 + ERR_NORECIPIENT = 411 + ERR_NOTEXTTOSEND = 412 + ERR_UNKNOWNCOMMAND = 421 + ERR_NOMOTD = 422 + ERR_NONICKNAMEGIVEN = 431 + ERR_ERRONEUSNICKNAME = 432 + ERR_NICKNAMEINUSE = 433 + ERR_NICKCOLLISION = 436 + ERR_USERNOTINCHANNEL = 441 + ERR_NOTONCHANNEL = 442 + ERR_USERONCHANNEL = 443 + ERR_NOLOGIN = 444 + ERR_SUMMONDISABLED = 445 + ERR_USERSDISABLED = 446 + ERR_NOTREGISTERED = 451 + ERR_NEEDMOREPARAMS = 461 + ERR_ALREADYREGISTRED = 462 + ERR_NOPERMFORHOST = 463 + ERR_PASSWDMISMATCH = 464 + ERR_YOUREBANNEDCREEP = 465 + ERR_YOUWILLBEBANNED = 466 + ERR_KEYSET = 467 + ERR_CHANNELISFULL = 471 + ERR_UNKNOWNMODE = 472 + ERR_INVITEONLYCHAN = 473 + ERR_BANNEDFROMCHAN = 474 + ERR_BADCHANNELKEY = 475 + ERR_BADCHANMASK = 476 + ERR_NOCHANMODES = 477 + ERR_BANLISTFULL = 478 + ERR_MODERATED = 494 // Cannot send to channel (channel is moderated) + ERR_QUIETED = 404 // Cannot send to channel (you are quieted) + ERR_NOPRIVILEGES = 481 + ERR_CHANOPRIVSNEEDED = 482 + ERR_CANTKILLSERVER = 483 + ERR_RESTRICTED = 484 + ERR_UNIQOPPRIVSNEEDED = 485 + ERR_NOOPERHOST = 491 + ERR_UMODEUNKNOWNFLAG = 501 + ERR_USERSDONTMATCH = 502 + RPL_SNOMASK = 8 + RPL_GLOBALNOTICE = 710 + RPL_OPERWALL = 711 + // New command numerics + RPL_ENDOFWHOWAS = 369 + RPL_RULESSTART = 308 + RPL_RULES = 232 + RPL_ENDOFRULES = 309 + RPL_MAP = 006 + RPL_MAPEND = 007 + RPL_KNOCKDLVR = 711 + ERR_KNOCKONCHAN = 713 + ERR_CHANOPEN = 713 + ERR_INVALIDUSERNAME = 468 +) + +// handleNick handles NICK command +func (c *Client) handleNick(parts []string) { + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "NICK :Not enough parameters") + return + } + + newNick := parts[1] + if len(newNick) > 0 && newNick[0] == ':' { + newNick = newNick[1:] + } + + // Validate nickname + if !isValidNickname(newNick) { + c.SendNumeric(ERR_ERRONEUSNICKNAME, newNick+" :Erroneous nickname") + return + } + + // Check if nick is already in use + if existing := c.server.GetClient(newNick); existing != nil && existing != c { + c.SendNumeric(ERR_NICKNAMEINUSE, newNick+" :Nickname is already in use") + return + } + + oldNick := c.Nick() + + // If already registered, notify channels + if c.IsRegistered() && oldNick != "" { + // Create the message with the OLD prefix before changing the nick + oldPrefix := fmt.Sprintf("%s!%s@%s", oldNick, c.User(), c.Host()) + message := fmt.Sprintf(":%s NICK :%s", oldPrefix, newNick) + + // Now change the nick + c.SetNick(newNick) + + // Send to client themselves first (protocol-compliant NICK message) + // The client ALWAYS gets their own NICK message - config only affects others + c.SendMessage(message) + + // Then broadcast to other users in channels based on config + for _, channel := range c.GetChannels() { + // Get all clients in this channel + for _, client := range channel.GetClients() { + if client == c { // Skip self - already handled above + continue + } + + shouldSend := false + if c.server.config.NickChangeNotification.ShowToEveryone { + shouldSend = true + } else if c.server.config.NickChangeNotification.ShowToOpers && client.IsOper() { + shouldSend = true + } + + if shouldSend { + client.SendMessage(message) + } + } + } + + // Send snomask notification for nick change + if c.server != nil && oldNick != newNick { + c.server.sendSnomask('n', fmt.Sprintf("Nick change: %s -> %s (%s@%s)", + oldNick, newNick, c.User(), c.Host())) + } + } else { + // Not registered yet, just change the nick + c.SetNick(newNick) + } + + c.checkRegistration() +} + +// handleUser handles USER command +func (c *Client) handleUser(parts []string) { + if len(parts) < 5 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "USER :Not enough parameters") + return + } + + if c.IsRegistered() { + c.SendNumeric(ERR_ALREADYREGISTRED, ":You may not reregister") + return + } + + c.SetUser(parts[1]) + // parts[2] and parts[3] are ignored (mode and unused) + realname := strings.Join(parts[4:], " ") + if len(realname) > 0 && realname[0] == ':' { + realname = realname[1:] + } + c.SetRealname(realname) + + c.checkRegistration() +} + +// checkRegistration checks if client is ready to be registered +func (c *Client) checkRegistration() { + // Don't complete registration if CAP negotiation is still in progress + if c.capNegotiation { + return + } + + if !c.IsRegistered() && c.Nick() != "" && c.User() != "" { + c.SetRegistered(true) + c.sendWelcome() + } +} + +// sendWelcome sends welcome messages to newly registered client +func (c *Client) sendWelcome() { + if c.server == nil { + return + } + if c.server.config == nil { + return + } + + c.SendNumeric(RPL_WELCOME, fmt.Sprintf("Welcome to %s, %s", c.server.config.Server.Network, c.Prefix())) + c.SendNumeric(RPL_YOURHOST, fmt.Sprintf("Your host is %s, running version %s", c.server.config.Server.Name, c.server.config.Server.Version)) + c.SendNumeric(RPL_CREATED, "This server was created recently") + c.SendNumeric(RPL_MYINFO, fmt.Sprintf("%s %s iowsBGSx beIklmnpstqaohv", c.server.config.Server.Name, c.server.config.Server.Version)) + + // Send ISUPPORT (005) messages to inform client about server capabilities + c.SendNumeric(RPL_ISUPPORT, "PREFIX=(qaohv)~&@%+ CHANTYPES=# CHANMODES=beI,k,l,imnpst NETWORK="+c.server.config.Server.Network+" :are supported by this server") + c.SendNumeric(RPL_ISUPPORT, "MAXCHANNELS="+fmt.Sprintf("%d", c.server.config.Limits.MaxChannels)+" NICKLEN="+fmt.Sprintf("%d", c.server.config.Limits.MaxNickLength)+" TOPICLEN="+fmt.Sprintf("%d", c.server.config.Limits.MaxTopicLength)+" :are supported by this server") + c.SendNumeric(RPL_ISUPPORT, "MODES=20 STATUSMSG=~&@%+ EXCEPTS=e INVEX=I CASEMAPPING=rfc1459 CHANNELLEN=32 :are supported by this server") + + // Send user modes (221) - properly formatted + userModes := c.GetModes() + if userModes == "" { + userModes = "+" + } else if !strings.HasPrefix(userModes, "+") { + userModes = "+" + userModes + } + c.SendNumeric(RPL_UMODEIS, userModes) + + // Send connection info (378) - shows real hostname + c.SendNumeric(RPL_WHOISHOST, fmt.Sprintf("%s :is connecting from %s", c.Nick(), c.Host())) + + // Send LUSERS statistics + c.server.mu.RLock() + totalUsers := len(c.server.clients) + totalChannels := len(c.server.channels) + operCount := 0 + for _, client := range c.server.clients { + if client.IsOper() { + operCount++ + } + } + c.server.mu.RUnlock() + + c.SendNumeric(RPL_LUSERCLIENT, fmt.Sprintf("There are %d users and 0 services on 1 servers", totalUsers)) + if operCount > 0 { + c.SendNumeric(RPL_LUSEROP, fmt.Sprintf("%d :operator(s) online", operCount)) + } + c.SendNumeric(RPL_LUSERUNKNOWN, "0 :unknown connection(s)") + if totalChannels > 0 { + c.SendNumeric(RPL_LUSERCHANNELS, fmt.Sprintf("%d :channels formed", totalChannels)) + } + c.SendNumeric(RPL_LUSERME, fmt.Sprintf("I have %d clients and 0 servers", totalUsers)) + + // Send MOTD + if len(c.server.config.MOTD) > 0 { + c.SendNumeric(RPL_MOTDSTART, fmt.Sprintf("- %s Message of the Day -", c.server.config.Server.Name)) + for _, line := range c.server.config.MOTD { + c.SendNumeric(RPL_MOTD, fmt.Sprintf("- %s", line)) + } + c.SendNumeric(RPL_ENDOFMOTD, "End of /MOTD command") + } + + // Send snomask notification for new client connection + if c.server != nil { + c.server.sendSnomask('c', fmt.Sprintf("Client connect: %s (%s@%s)", + c.Nick(), c.User(), c.Host())) + } +} + +// handlePing handles PING command +func (c *Client) handlePing(parts []string) { + if len(parts) < 2 { + log.Printf("Invalid PING from %s: missing token", c.Nick()) + return + } + + token := parts[1] + if len(token) > 0 && token[0] == ':' { + token = token[1:] + } + + // Log PING received for debugging + log.Printf("Received PING from client %s with token: %s", c.Nick(), token) + + // Determine the correct PONG format based on the ping token + var pongMsg string + + if strings.HasPrefix(token, "LAG") { + // HexChat LAG ping - use the exact format HexChat expects + // HexChat expects: :servername PONG servername :LAGxxxxx + pongMsg = fmt.Sprintf(":%s PONG %s :%s", c.server.config.Server.Name, c.server.config.Server.Name, token) + log.Printf("Handling HexChat LAG ping with token: %s", token) + } else if token == c.server.config.Server.Name { + // Standard server ping - respond with server name + pongMsg = fmt.Sprintf(":%s PONG %s :%s", c.server.config.Server.Name, c.server.config.Server.Name, token) + } else { + // Generic ping - echo back the token with colon prefix + pongMsg = fmt.Sprintf(":%s PONG %s :%s", c.server.config.Server.Name, c.server.config.Server.Name, token) + } + + log.Printf("Sending PONG to client %s: %s", c.Nick(), pongMsg) + + // Send the PONG response + c.SendMessage(pongMsg) + + // Update ping tracking - treat any client PING as activity + c.mu.Lock() + c.lastPong = time.Now() + c.lastActivity = time.Now() // Update activity time too + c.waitingForPong = false + c.mu.Unlock() + + log.Printf("Updated ping tracking for client %s", c.Nick()) +} + +// handlePong handles PONG command +func (c *Client) handlePong(parts []string) { + // Update the last pong time for ping timeout tracking + c.mu.Lock() + c.lastPong = time.Now() + c.lastActivity = time.Now() // Update activity time too + c.waitingForPong = false + c.mu.Unlock() + + // Log PONG receipt for debugging + token := "unknown" + if len(parts) > 1 { + token = parts[1] + if len(token) > 0 && token[0] == ':' { + token = token[1:] + } + } + + log.Printf("Received PONG from client %s with token: %s", c.Nick(), token) +} + +// handleJoin handles JOIN command +func (c *Client) handleJoin(parts []string) { + log.Printf("JOIN command from %s (registered: %v): %v", c.Nick(), c.IsRegistered(), parts) + + if !c.IsRegistered() { + c.SendNumeric(ERR_NOTREGISTERED, ":You have not registered") + return + } + + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "JOIN :Not enough parameters") + return + } + + channelNames := strings.Split(parts[1], ",") + keys := []string{} + if len(parts) > 2 { + keys = strings.Split(parts[2], ",") + } + + for i, channelName := range channelNames { + if channelName == "0" { + // Leave all channels + for _, channel := range c.GetChannels() { + c.handlePartChannel(channel.Name(), "Leaving all channels") + } + continue + } + + if !isValidChannelName(channelName) { + c.SendNumeric(ERR_NOSUCHCHANNEL, channelName+" :No such channel") + continue + } + + channel := c.server.GetOrCreateChannel(channelName) + + // Check if already in channel + if c.IsInChannel(channelName) { + continue + } + + // Check channel modes and limits (God Mode can bypass all restrictions) + key := "" + if i < len(keys) { + key = keys[i] + } + + if !c.HasGodMode() { + if channel.HasMode('k') && channel.Key() != key { + c.SendNumeric(ERR_BADCHANNELKEY, channelName+" :Cannot join channel (+k)") + continue + } + + if channel.HasMode('l') && channel.UserCount() >= channel.Limit() { + c.SendNumeric(ERR_CHANNELISFULL, channelName+" :Cannot join channel (+l)") + continue + } + + // Check for bans (God Mode bypasses bans) + if channel.IsBanned(c) { + c.SendNumeric(ERR_BANNEDFROMCHAN, channelName+" :Cannot join channel (+b)") + continue + } + + // Check invite-only mode (God Mode bypasses invite requirement) + if channel.HasMode('i') && !channel.IsInvited(c) { + c.SendNumeric(ERR_INVITEONLYCHAN, channelName+" :Cannot join channel (+i)") + continue + } + } else { + // God Mode user joining - notify operators + c.sendSnomask('o', fmt.Sprintf("GOD MODE: %s bypassed restrictions to join %s", c.Nick(), channelName)) + } + + // Check if user is already in the channel + if c.IsInChannel(channelName) { + // User is already in the channel, skip + continue + } + + // Join the channel + channel.AddClient(c) + c.AddChannel(channel) + + message := fmt.Sprintf(":%s JOIN :%s", c.Prefix(), channelName) + + // Send JOIN to the client themselves first + c.SendMessage(message) + + // Then broadcast to others in the channel + channel.Broadcast(message, c) + + // Send topic if exists + if channel.Topic() != "" { + c.SendNumeric(RPL_TOPIC, channelName+" :"+channel.Topic()) + c.SendNumeric(RPL_TOPICWHOTIME, fmt.Sprintf("%s %s %d", channelName, channel.TopicBy(), channel.TopicTime().Unix())) + } + + // Send names list + c.sendNames(channel) + } +} + +// handlePart handles PART command +func (c *Client) handlePart(parts []string) { + if !c.IsRegistered() { + c.SendNumeric(ERR_NOTREGISTERED, ":You have not registered") + return + } + + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "PART :Not enough parameters") + return + } + + channelNames := strings.Split(parts[1], ",") + reason := "Leaving" + if len(parts) > 2 { + reason = strings.Join(parts[2:], " ") + if len(reason) > 0 && reason[0] == ':' { + reason = reason[1:] + } + } + + for _, channelName := range channelNames { + c.handlePartChannel(channelName, reason) + } +} + +func (c *Client) handlePartChannel(channelName, reason string) { + if !c.IsInChannel(channelName) { + c.SendNumeric(ERR_NOTONCHANNEL, channelName+" :You're not on that channel") + return + } + + channel := c.server.GetChannel(channelName) + if channel == nil { + return + } + + message := fmt.Sprintf(":%s PART %s :%s", c.Prefix(), channelName, reason) + channel.Broadcast(message, nil) + + channel.RemoveClient(c) + c.RemoveChannel(channelName) + + // Remove empty channel + if channel.UserCount() == 0 { + c.server.RemoveChannel(channelName) + } +} + +// handlePrivmsg handles PRIVMSG command with IRCv3 support +func (c *Client) handlePrivmsg(parts []string) { + if !c.IsRegistered() { + c.SendNumeric(ERR_NOTREGISTERED, ":You have not registered") + return + } + + if len(parts) < 2 { + c.SendNumeric(ERR_NORECIPIENT, ":No recipient given (PRIVMSG)") + return + } + + if len(parts) < 3 { + c.SendNumeric(ERR_NOTEXTTOSEND, ":No text to send") + return + } + + target := parts[1] + message := strings.Join(parts[2:], " ") + if len(message) > 0 && message[0] == ':' { + message = message[1:] + } + + // Create IRCv3 message + ircMsg := &IRCMessage{ + Tags: make(map[string]string), + Prefix: c.Prefix(), + Command: "PRIVMSG", + Params: []string{target, ":" + message}, + } + + // Add IRCv3 tags + c.AddServerTime(ircMsg) + c.AddAccountTag(ircMsg) + + if isChannelName(target) { + // Channel message + channel := c.server.GetChannel(target) + if channel == nil { + c.SendNumeric(ERR_NOSUCHCHANNEL, target+" :No such channel") + return + } + + if !c.IsInChannel(target) { + c.SendNumeric(ERR_CANNOTSENDTOCHAN, target+" :Cannot send to channel") + return + } + + // Check if user is quieted first (takes priority over moderated check) + if channel.IsQuieted(c) { + // Check if user has privilege to speak despite being quieted + if !channel.IsOwner(c) && !channel.IsOperator(c) && !channel.IsHalfop(c) { + c.SendNumeric(ERR_QUIETED, target+" :Cannot send to channel (you are quieted)") + return + } + } + + // Check if channel is moderated and user lacks privileges + if channel.HasMode('m') && !channel.CanSendMessage(c) { + c.SendNumeric(ERR_MODERATED, target+" :Cannot send to channel (channel is moderated)") + return + } + + // Broadcast with IRCv3 support + c.broadcastToChannel(channel, ircMsg) + + // Echo message back to sender if they have echo-message capability + // TEMPORARILY DISABLED TO FIX DUPLICATION ISSUE + // if c.HasCapability("echo-message") { + // c.SendMessage(ircMsg.FormatMessage()) + // } + + } else { + // Private message + targetClient := c.server.GetClient(target) + if targetClient == nil { + c.SendNumeric(ERR_NOSUCHNICK, target+" :No such nick/channel") + return + } + + if targetClient.Away() != "" { + c.SendNumeric(RPL_AWAY, fmt.Sprintf("%s :%s", target, targetClient.Away())) + } + + // Send IRCv3 message to target + targetClient.SendMessage(ircMsg.FormatMessage()) + + // Echo message back to sender if they have echo-message capability + // TEMPORARILY DISABLED TO FIX DUPLICATION ISSUE + // if c.HasCapability("echo-message") { + // c.SendMessage(ircMsg.FormatMessage()) + // } + } +} + +// handleNotice handles NOTICE command +func (c *Client) handleNotice(parts []string) { + if !c.IsRegistered() { + return // NOTICE should not generate error responses + } + + if len(parts) < 3 { + return + } + + target := parts[1] + message := strings.Join(parts[2:], " ") + if len(message) > 0 && message[0] == ':' { + message = message[1:] + } + + if isChannelName(target) { + // Channel notice + channel := c.server.GetChannel(target) + if channel == nil || !c.IsInChannel(target) { + return + } + + msg := fmt.Sprintf(":%s NOTICE %s :%s", c.Prefix(), target, message) + channel.Broadcast(msg, c) + } else { + // Private notice + targetClient := c.server.GetClient(target) + if targetClient == nil { + return + } + + msg := fmt.Sprintf(":%s NOTICE %s :%s", c.Prefix(), target, message) + targetClient.SendMessage(msg) + } +} + +// handleTagmsg handles TAGMSG command (IRCv3.2) +// TAGMSG is used for tag-only messages like typing indicators, reactions, etc. +func (c *Client) handleTagmsg(parts []string, tags map[string]string) { + if !c.IsRegistered() { + c.SendNumeric(ERR_NOTREGISTERED, ":You have not registered") + return + } + + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "TAGMSG :Not enough parameters") + return + } + + target := parts[1] + + // Build tag string + var tagString string + if len(tags) > 0 { + var tagPairs []string + for key, value := range tags { + if value == "" { + tagPairs = append(tagPairs, key) + } else { + tagPairs = append(tagPairs, fmt.Sprintf("%s=%s", key, value)) + } + } + tagString = "@" + strings.Join(tagPairs, ";") + " " + } + + if isChannelName(target) { + // Channel tagmsg + channel := c.server.GetChannel(target) + if channel == nil || !c.IsInChannel(target) { + c.SendNumeric(ERR_NOTONCHANNEL, target+" :You're not on that channel") + return + } + + msg := fmt.Sprintf("%s:%s TAGMSG %s", tagString, c.Prefix(), target) + channel.Broadcast(msg, c) + } else { + // Private tagmsg + targetClient := c.server.GetClient(target) + if targetClient == nil { + c.SendNumeric(ERR_NOSUCHNICK, target+" :No such nick/channel") + return + } + + msg := fmt.Sprintf("%s:%s TAGMSG %s", tagString, c.Prefix(), target) + targetClient.SendMessage(msg) + } +} + +// handleWho handles WHO command +func (c *Client) handleWho(parts []string) { + if !c.IsRegistered() { + c.SendNumeric(ERR_NOTREGISTERED, ":You have not registered") + return + } + + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "WHO :Not enough parameters") + return + } + + target := parts[1] + + if isChannelName(target) { + channel := c.server.GetChannel(target) + if channel == nil { + c.SendNumeric(ERR_NOSUCHCHANNEL, target+" :No such channel") + return + } + + for _, client := range channel.GetClients() { + // Skip stealth mode users unless requester is an operator + if !client.IsVisibleTo(c) { + continue + } + + flags := "" + if client.IsOper() { + flags += "*" + } + if client.Away() != "" { + flags += "G" + } else { + flags += "H" + } + + // Add channel status flags in order of hierarchy + if channel.IsOwner(client) { + flags += "~" + } else if channel.IsAdmin(client) { + flags += "&" + } else if channel.IsOperator(client) { + flags += "@" + } else if channel.IsHalfop(client) { + flags += "%" + } else if channel.IsVoice(client) { + flags += "+" + } + + c.SendNumeric(352, fmt.Sprintf("%s %s %s %s %s %s :0 %s", + target, client.User(), client.HostForUser(c), c.server.config.Server.Name, + client.Nick(), flags, client.Realname())) + } + } + + c.SendNumeric(315, target+" :End of /WHO list") +} + +// handleWhois handles WHOIS command +func (c *Client) handleWhois(parts []string) { + if !c.IsRegistered() { + c.SendNumeric(ERR_NOTREGISTERED, ":You have not registered") + return + } + + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "WHOIS :Not enough parameters") + return + } + + nick := parts[1] + target := c.server.GetClient(nick) + if target == nil { + c.SendNumeric(ERR_NOSUCHNICK, nick+" :No such nick") + return + } + + // Basic user information (always shown) + hostname := target.HostForUser(c) + + // Always show the public hostname in RPL_WHOISUSER + c.SendNumeric(RPL_WHOISUSER, fmt.Sprintf("%s %s %s * :%s", + target.Nick(), target.User(), hostname, target.Realname())) + + // Show real host if configured and permitted + if c.canSeeWhoisInfo(target, "real_host") { + realHost := target.Host() + if hostname != realHost { + // Show real host if different from displayed host + c.SendNumeric(RPL_WHOISHOST, fmt.Sprintf("%s :is connecting from %s", + target.Nick(), realHost)) + } else { + // Even if same, show real IP for debugging + c.SendNumeric(RPL_WHOISHOST, fmt.Sprintf("%s :is connecting from %s (real IP)", + target.Nick(), realHost)) + } + } + + // Server information + c.SendNumeric(RPL_WHOISSERVER, fmt.Sprintf("%s %s :%s", + target.Nick(), c.server.config.Server.Name, c.server.config.Server.Description)) + + // Operator status + if target.IsOper() { + c.SendNumeric(RPL_WHOISOPERATOR, target.Nick()+" :is an IRC operator") + + // Show operator class if configured + if c.canSeeWhoisInfo(target, "oper_class") { + operClass := target.OperClass() + if operClass != "" { + // Load operator config to get class description and rank name + operConfig, err := LoadOperConfig(c.server.config.OperConfig.ConfigFile) + if err == nil && c.server.config.OperConfig.Enable { + class := operConfig.GetOperClass(operClass) + if class != nil { + rankName := operConfig.GetRankName(class.Rank) + c.SendMessage(fmt.Sprintf(":%s 313 %s %s :is an IRC operator (%s - %s) [%s]", + c.server.config.Server.Name, c.Nick(), target.Nick(), class.Name, class.Description, rankName)) + } else { + c.SendMessage(fmt.Sprintf(":%s 313 %s %s :is an IRC operator (class: %s)", + c.server.config.Server.Name, c.Nick(), target.Nick(), operClass)) + } + } else { + c.SendMessage(fmt.Sprintf(":%s 313 %s %s :is an IRC operator (class: %s)", + c.server.config.Server.Name, c.Nick(), target.Nick(), operClass)) + } + } + } + } + + // Away status + if target.Away() != "" { + c.SendNumeric(RPL_AWAY, fmt.Sprintf("%s :%s", target.Nick(), target.Away())) + } + + // User modes + if c.canSeeWhoisInfo(target, "user_modes") { + modes := target.GetModes() + if modes != "" { + c.SendMessage(fmt.Sprintf(":%s 379 %s %s :is using modes %s", + c.server.config.Server.Name, c.Nick(), target.Nick(), modes)) + } + } + + // SSL status + if c.canSeeWhoisInfo(target, "ssl_status") && target.IsSSL() { + c.SendMessage(fmt.Sprintf(":%s 671 %s %s :is using a secure connection", + c.server.config.Server.Name, c.Nick(), target.Nick())) + } + + // Channels + if c.canSeeChannels(target) { + var channels []string + config := c.server.config.WhoisFeatures.ShowChannels + + for _, channel := range target.GetChannels() { + // Skip secret/private channels based on config + if config.HideSecret && channel.HasMode('s') && !channel.HasClient(c) { + continue + } + if config.HidePrivate && channel.HasMode('p') && !channel.HasClient(c) { + continue + } + + channelName := channel.Name() + if config.ShowMembership { + if channel.IsOperator(target) { + channelName = "@" + channelName + } else if channel.IsVoice(target) { + channelName = "+" + channelName + } + } + channels = append(channels, channelName) + } + + if len(channels) > 0 { + c.SendNumeric(RPL_WHOISCHANNELS, fmt.Sprintf("%s :%s", target.Nick(), strings.Join(channels, " "))) + } + } + + // Idle time + if c.canSeeWhoisInfo(target, "idle_time") { + idleTime := int(time.Since(target.LastActivity()).Seconds()) + c.SendNumeric(RPL_WHOISIDLE, fmt.Sprintf("%s %d %d :seconds idle, signon time", + target.Nick(), idleTime, target.ConnectTime().Unix())) + } + + // Signon time (alternative if idle time is not shown) + if !c.canSeeWhoisInfo(target, "idle_time") && c.canSeeWhoisInfo(target, "signon_time") { + c.SendMessage(fmt.Sprintf(":%s 317 %s %s :signed on %s", + c.server.config.Server.Name, c.Nick(), target.Nick(), + target.ConnectTime().Format("Mon Jan 2 15:04:05 2006"))) + } + + // Account name (for services integration) + if c.canSeeWhoisInfo(target, "account_name") && target.Account() != "" { + c.SendMessage(fmt.Sprintf(":%s 330 %s %s %s :is logged in as", + c.server.config.Server.Name, c.Nick(), target.Nick(), target.Account())) + } + + // Client information + if c.canSeeWhoisInfo(target, "client_info") { + c.SendMessage(fmt.Sprintf(":%s 351 %s %s :is using client TechIRCd-Client", + c.server.config.Server.Name, c.Nick(), target.Nick())) + } + + c.SendNumeric(RPL_ENDOFWHOIS, target.Nick()+" :End of /WHOIS list") +} + +// handleNames handles NAMES command +func (c *Client) handleNames(parts []string) { + if !c.IsRegistered() { + c.SendNumeric(ERR_NOTREGISTERED, ":You have not registered") + return + } + + if len(parts) < 2 { + // Send names for all channels + for _, channel := range c.server.GetChannels() { + if c.IsInChannel(channel.Name()) { + c.sendNames(channel) + } + } + return + } + + channelNames := strings.Split(parts[1], ",") + for _, channelName := range channelNames { + channel := c.server.GetChannel(channelName) + if channel != nil && c.IsInChannel(channelName) { + c.sendNames(channel) + } + } +} + +func (c *Client) sendNames(channel *Channel) { + var names []string + for _, client := range channel.GetClients() { + // Skip stealth mode users unless requester is an operator + if !client.IsVisibleTo(c) { + continue + } + + name := client.Nick() + var prefixes string + + // Build prefixes in order of hierarchy: owner > admin > operator > halfop > voice + if channel.IsOwner(client) { + prefixes += "~" + } + if channel.IsAdmin(client) { + prefixes += "&" + } + if channel.IsOperator(client) { + prefixes += "@" + } + if channel.IsHalfop(client) { + prefixes += "%" + } + if channel.IsVoice(client) { + prefixes += "+" + } + + // For multi-prefix clients, show all prefixes. For others, show only the highest + if c.HasCapability("multi-prefix") { + name = prefixes + name + } else { + // Show only the highest prefix for non-multi-prefix clients + if len(prefixes) > 0 { + name = string(prefixes[0]) + name + } + } + names = append(names, name) + } + + symbol := "=" + if channel.HasMode('s') { + symbol = "@" + } else if channel.HasMode('p') { + symbol = "*" + } + + c.SendNumeric(RPL_NAMREPLY, fmt.Sprintf("%s %s :%s", symbol, channel.Name(), strings.Join(names, " "))) + c.SendNumeric(RPL_ENDOFNAMES, channel.Name()+" :End of /NAMES list") +} + +// handleQuit handles QUIT command +func (c *Client) handleQuit(parts []string) { + reason := "Client quit" + if len(parts) > 1 { + reason = strings.Join(parts[1:], " ") + if len(reason) > 0 && reason[0] == ':' { + reason = reason[1:] + } + } + + // Broadcast QUIT message to all channels the client is in + quitMessage := fmt.Sprintf(":%s QUIT :%s", c.Prefix(), reason) + for _, channel := range c.GetChannels() { + // Send QUIT message to all users in the channel + for _, client := range channel.GetClients() { + if client != c { // Don't send to the quitting client + client.SendMessage(quitMessage) + } + } + // Remove client from channel + channel.RemoveClient(c) + // Remove empty channels + if len(channel.GetClients()) == 0 && c.server != nil { + c.server.RemoveChannel(channel.name) + } + } + + // Remove client from server + c.server.RemoveClient(c) + + // Close the connection + c.conn.Close() +} + +// handleMode handles MODE command +func (c *Client) handleMode(parts []string) { + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "MODE :Not enough parameters") + return + } + + target := parts[1] + + // Handle user mode requests + if !isChannelName(target) { + if target != c.Nick() { + c.SendNumeric(ERR_USERSDONTMATCH, ":Cannot change mode for other users") + return + } + + // If no mode changes specified, return current user modes + if len(parts) == 2 { + modes := c.GetModes() + if modes == "" { + modes = "+" + } + c.SendNumeric(RPL_UMODEIS, modes) + return + } + + // Parse user mode changes + modeString := parts[2] + adding := true + var appliedModes []string + + for _, char := range modeString { + switch char { + case '+': + adding = true + case '-': + adding = false + case 'i': // invisible + c.SetMode('i', adding) + if adding { + appliedModes = append(appliedModes, "+i") + } else { + appliedModes = append(appliedModes, "-i") + } + case 'w': // wallops + c.SetMode('w', adding) + if adding { + appliedModes = append(appliedModes, "+w") + } else { + appliedModes = append(appliedModes, "-w") + } + case 's': // server notices (requires oper) + if !c.IsOper() && adding { + continue // silently ignore for non-opers + } + c.SetMode('s', adding) + if adding { + appliedModes = append(appliedModes, "+s") + } else { + appliedModes = append(appliedModes, "-s") + } + case 'o': // operator (cannot be set manually) + if adding { + c.SendNumeric(ERR_UMODEUNKNOWNFLAG, ":Unknown MODE flag") + } else { + // Allow de-opering + c.SetOper(false) + c.SetMode('o', false) + appliedModes = append(appliedModes, "-o") + // Clear snomasks when de-opering + c.snomasks = make(map[rune]bool) + c.sendSnomask('o', fmt.Sprintf("%s is no longer an IRC operator", c.Nick())) + } + case 'r': // registered (cannot be set manually, services only) + c.SendNumeric(ERR_UMODEUNKNOWNFLAG, ":Unknown MODE flag") + case 'x': // host masking (TechIRCd special) + c.SetMode('x', adding) + if adding { + appliedModes = append(appliedModes, "+x") + // TODO: Implement host masking + } else { + appliedModes = append(appliedModes, "-x") + } + case 'z': // SSL/TLS (automatic, cannot be manually set) + if c.IsSSL() { + c.SetMode('z', true) + } + // Ignore attempts to manually set/unset + case 'B': // bot flag (TechIRCd special) + c.SetMode('B', adding) + if adding { + appliedModes = append(appliedModes, "+B") + } else { + appliedModes = append(appliedModes, "-B") + } + case 'G': // God Mode (requires oper and god_mode permission) + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + continue + } + if !c.HasOperPermission("god_mode") { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied - You need god_mode permission") + continue + } + // Only change mode if it's different from current state + if c.HasMode('G') != adding { + c.SetMode('G', adding) + if adding { + appliedModes = append(appliedModes, "+G") + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** GOD MODE enabled - You have ultimate power!", + c.server.config.Server.Name, c.Nick())) + c.sendSnomask('o', fmt.Sprintf("%s has enabled GOD MODE - Ultimate channel override powers active", c.Nick())) + } else { + appliedModes = append(appliedModes, "-G") + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** GOD MODE disabled", + c.server.config.Server.Name, c.Nick())) + c.sendSnomask('o', fmt.Sprintf("%s has disabled GOD MODE", c.Nick())) + } + } + case 'S': // Stealth Mode (requires oper and stealth_mode permission) + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + continue + } + if !c.HasOperPermission("stealth_mode") { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied - You need stealth_mode permission") + continue + } + // Only change mode if it's different from current state + if c.HasMode('S') != adding { + c.SetMode('S', adding) + if adding { + appliedModes = append(appliedModes, "+S") + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** STEALTH MODE enabled - You are now invisible to users", + c.server.config.Server.Name, c.Nick())) + c.sendSnomask('o', fmt.Sprintf("%s has enabled STEALTH MODE - Now invisible to regular users", c.Nick())) + } else { + appliedModes = append(appliedModes, "-S") + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** STEALTH MODE disabled - You are now visible", + c.server.config.Server.Name, c.Nick())) + c.sendSnomask('o', fmt.Sprintf("%s has disabled STEALTH MODE", c.Nick())) + } + } + default: + c.SendNumeric(ERR_UMODEUNKNOWNFLAG, ":Unknown MODE flag") + } + } + + // Send mode changes back to user + if len(appliedModes) > 0 { + modeStr := strings.Join(appliedModes, "") + c.SendMessage(fmt.Sprintf(":%s MODE %s :%s", c.Nick(), c.Nick(), modeStr)) + } + return + } + + // Handle channel mode requests + channel := c.server.GetChannel(target) + if channel == nil { + c.SendNumeric(ERR_NOSUCHCHANNEL, target+" :No such channel") + return + } + + if !c.IsInChannel(target) { + c.SendNumeric(ERR_NOTONCHANNEL, target+" :You're not on that channel") + return + } + + // If no mode changes specified, return current channel modes + if len(parts) == 2 { + modes := channel.GetModes() + if modes == "" { + modes = "+" + } + c.SendNumeric(RPL_CHANNELMODEIS, fmt.Sprintf("%s %s", target, modes)) + return + } + + // Parse mode changes + modeString := parts[2] + args := parts[3:] + argIndex := 0 + + // Check if user has operator privileges (required for most mode changes) + // God Mode users can bypass operator requirement + if !c.HasGodMode() && !channel.IsOwner(c) && !channel.IsOperator(c) && !channel.IsHalfop(c) { + c.SendNumeric(ERR_CHANOPRIVSNEEDED, target+" :You're not channel operator") + return + } + + // If using God Mode to set modes, notify operators + if c.HasGodMode() && !channel.IsOwner(c) && !channel.IsOperator(c) && !channel.IsHalfop(c) { + c.sendSnomask('o', fmt.Sprintf("GOD MODE: %s set modes on %s without operator privileges", c.Nick(), target)) + } + + adding := true + var appliedModes []string + var appliedArgs []string + + for _, char := range modeString { + switch char { + case '+': + adding = true + case '-': + adding = false + case 'o': // operator + if argIndex >= len(args) { + continue + } + targetNick := args[argIndex] + argIndex++ + + // Check if operator mode is allowed in config + if !c.server.config.Channels.AllowedModes.Operator { + c.SendNumeric(ERR_UNKNOWNMODE, "+o :Operator mode is disabled") + continue + } + + targetClient := c.server.GetClient(targetNick) + if targetClient == nil { + c.SendNumeric(ERR_NOSUCHNICK, targetNick+" :No such nick/channel") + continue + } + + if !targetClient.IsInChannel(target) { + c.SendNumeric(ERR_USERNOTINCHANNEL, fmt.Sprintf("%s %s :They aren't on that channel", targetNick, target)) + continue + } + + // Check if the user already has the operator status we're trying to set + currentlyOp := channel.IsOperator(targetClient) + if adding && currentlyOp { + // Already an operator, skip this change + continue + } + if !adding && !currentlyOp { + // Already not an operator, skip this change + continue + } + + channel.SetOperator(targetClient, adding) + if adding { + appliedModes = append(appliedModes, "+o") + } else { + appliedModes = append(appliedModes, "-o") + } + appliedArgs = append(appliedArgs, targetNick) + + case 'v': // voice + if argIndex >= len(args) { + continue + } + targetNick := args[argIndex] + argIndex++ + + // Check if voice mode is allowed in config + if !c.server.config.Channels.AllowedModes.Voice { + c.SendNumeric(ERR_UNKNOWNMODE, "+v :Voice mode is disabled") + continue + } + + targetClient := c.server.GetClient(targetNick) + if targetClient == nil { + c.SendNumeric(ERR_NOSUCHNICK, targetNick+" :No such nick/channel") + continue + } + + if !targetClient.IsInChannel(target) { + c.SendNumeric(ERR_USERNOTINCHANNEL, fmt.Sprintf("%s %s :They aren't on that channel", targetNick, target)) + continue + } + + // Check if the user already has the voice status we're trying to set + currentlyVoiced := channel.IsVoice(targetClient) + if adding && currentlyVoiced { + // Already has voice, skip this change + continue + } + if !adding && !currentlyVoiced { + // Already doesn't have voice, skip this change + continue + } + + channel.SetVoice(targetClient, adding) + if adding { + appliedModes = append(appliedModes, "+v") + } else { + appliedModes = append(appliedModes, "-v") + } + appliedArgs = append(appliedArgs, targetNick) + + case 'h': // halfop + if argIndex >= len(args) { + continue + } + targetNick := args[argIndex] + argIndex++ + + // Check if halfop mode is allowed in config + if !c.server.config.Channels.AllowedModes.Halfop { + c.SendNumeric(ERR_UNKNOWNMODE, "+h :Halfop mode is disabled") + continue + } + + targetClient := c.server.GetClient(targetNick) + if targetClient == nil { + c.SendNumeric(ERR_NOSUCHNICK, targetNick+" :No such nick/channel") + continue + } + + if !targetClient.IsInChannel(target) { + c.SendNumeric(ERR_USERNOTINCHANNEL, fmt.Sprintf("%s %s :They aren't on that channel", targetNick, target)) + continue + } + + // Check if the user already has the halfop status we're trying to set + currentlyHalfop := channel.IsHalfop(targetClient) + if adding && currentlyHalfop { + // Already has halfop, skip this change + continue + } + if !adding && !currentlyHalfop { + // Already doesn't have halfop, skip this change + continue + } + + channel.SetHalfop(targetClient, adding) + if adding { + appliedModes = append(appliedModes, "+h") + } else { + appliedModes = append(appliedModes, "-h") + } + appliedArgs = append(appliedArgs, targetNick) + + case 'q': // owner/founder + if argIndex >= len(args) { + continue + } + targetNick := args[argIndex] + argIndex++ + + // Check if owner mode is allowed in config + if !c.server.config.Channels.AllowedModes.Owner { + c.SendNumeric(ERR_UNKNOWNMODE, "+q :Owner mode is disabled") + continue + } + + // Only existing owners can grant/remove owner status, or God Mode users + if !channel.IsOwner(c) && !c.HasGodMode() { + c.SendNumeric(ERR_CHANOPRIVSNEEDED, target+" :You're not channel owner") + continue + } + + targetClient := c.server.GetClient(targetNick) + if targetClient == nil { + c.SendNumeric(ERR_NOSUCHNICK, targetNick+" :No such nick/channel") + continue + } + + if !targetClient.IsInChannel(target) { + c.SendNumeric(ERR_USERNOTINCHANNEL, fmt.Sprintf("%s %s :They aren't on that channel", targetNick, target)) + continue + } + + // Check if the user already has the owner status we're trying to set + currentlyOwner := channel.IsOwner(targetClient) + if adding && currentlyOwner { + // Already has owner, skip this change + continue + } + if !adding && !currentlyOwner { + // Already doesn't have owner, skip this change + continue + } + + channel.SetOwner(targetClient, adding) + if adding { + appliedModes = append(appliedModes, "+q") + } else { + appliedModes = append(appliedModes, "-q") + } + appliedArgs = append(appliedArgs, targetNick) + + case 'a': // admin + if argIndex >= len(args) { + continue + } + targetNick := args[argIndex] + argIndex++ + + // Check if admin mode is allowed in config + if !c.server.config.Channels.AllowedModes.Admin { + c.SendNumeric(ERR_UNKNOWNMODE, "+a :Admin mode is disabled") + continue + } + + // Only existing owners/admins can grant/remove admin status, or God Mode users + if !channel.IsOwner(c) && !channel.IsAdmin(c) && !c.HasGodMode() { + c.SendNumeric(ERR_CHANOPRIVSNEEDED, target+" :You need admin or owner privileges") + continue + } + + targetClient := c.server.GetClient(targetNick) + if targetClient == nil { + c.SendNumeric(ERR_NOSUCHNICK, targetNick+" :No such nick/channel") + continue + } + + if !targetClient.IsInChannel(target) { + c.SendNumeric(ERR_USERNOTINCHANNEL, fmt.Sprintf("%s %s :They aren't on that channel", targetNick, target)) + continue + } + + // Check if the user already has the admin status we're trying to set + currentlyAdmin := channel.IsAdmin(targetClient) + if adding && currentlyAdmin { + // Already has admin, skip this change + continue + } + if !adding && !currentlyAdmin { + // Already doesn't have admin, skip this change + continue + } + + channel.SetAdmin(targetClient, adding) + if adding { + appliedModes = append(appliedModes, "+a") + } else { + appliedModes = append(appliedModes, "-a") + } + appliedArgs = append(appliedArgs, targetNick) + + case 'm': // moderated + channel.SetMode('m', adding) + if adding { + appliedModes = append(appliedModes, "+m") + } else { + appliedModes = append(appliedModes, "-m") + } + + case 'n': // no external messages + channel.SetMode('n', adding) + if adding { + appliedModes = append(appliedModes, "+n") + } else { + appliedModes = append(appliedModes, "-n") + } + + case 't': // topic restriction + channel.SetMode('t', adding) + if adding { + appliedModes = append(appliedModes, "+t") + } else { + appliedModes = append(appliedModes, "-t") + } + + case 'i': // invite only + channel.SetMode('i', adding) + if adding { + appliedModes = append(appliedModes, "+i") + } else { + appliedModes = append(appliedModes, "-i") + } + + case 's': // secret + channel.SetMode('s', adding) + if adding { + appliedModes = append(appliedModes, "+s") + } else { + appliedModes = append(appliedModes, "-s") + } + + case 'p': // private + channel.SetMode('p', adding) + if adding { + appliedModes = append(appliedModes, "+p") + } else { + appliedModes = append(appliedModes, "-p") + } + + case 'k': // key (password) + if adding { + if argIndex >= len(args) { + continue + } + key := args[argIndex] + argIndex++ + channel.SetKey(key) + channel.SetMode('k', true) + appliedModes = append(appliedModes, "+k") + appliedArgs = append(appliedArgs, key) + } else { + channel.SetKey("") + channel.SetMode('k', false) + appliedModes = append(appliedModes, "-k") + } + + case 'l': // limit + if adding { + if argIndex >= len(args) { + continue + } + limitStr := args[argIndex] + argIndex++ + // Parse limit (simplified - should validate it's a number) + limit := 0 + fmt.Sscanf(limitStr, "%d", &limit) + if limit > 0 { + channel.SetLimit(limit) + channel.SetMode('l', true) + appliedModes = append(appliedModes, "+l") + appliedArgs = append(appliedArgs, limitStr) + } + } else { + channel.SetLimit(0) + channel.SetMode('l', false) + appliedModes = append(appliedModes, "-l") + } + + case 'b': // ban (enhanced with extended ban types) + var mask string + if argIndex >= len(args) { + if adding { + // If no mask provided and we're setting +b, generate user's hostmask + mask = fmt.Sprintf("%s!%s@%s", c.Nick(), c.User(), c.Host()) + } else { + // List bans - send ban list to client + bans := channel.GetBans() + for _, ban := range bans { + // RPL_BANLIST: 367 + c.SendNumeric(RPL_BANLIST, fmt.Sprintf("%s %s %s %d", target, ban, "server", time.Now().Unix())) + } + // Also list quiet bans with ~q: prefix + for _, quiet := range channel.quietList { + c.SendNumeric(RPL_BANLIST, fmt.Sprintf("%s ~q:%s %s %d", target, quiet, "server", time.Now().Unix())) + } + // RPL_ENDOFBANLIST: 368 :End of channel ban list + c.SendNumeric(RPL_ENDOFBANLIST, target+" :End of channel ban list") + continue + } + } else { + mask = args[argIndex] + argIndex++ + + // Expand partial masks to full hostmask format + mask = expandBanMask(mask) + } + + // Check for extended ban types (e.g., ~q:nick!user@host for quiet) + if strings.HasPrefix(mask, "~") && len(mask) > 2 && mask[2] == ':' { + banType := mask[1] // The character after ~ + banMask := mask[3:] // The mask after ~x: + + switch banType { + case 'q': // Quiet ban + if adding { + // Add to quiet list + channel.quietList = append(channel.quietList, banMask) + appliedModes = append(appliedModes, "+b") + appliedArgs = append(appliedArgs, mask) + + // Send snomask to opers + if c.IsOper() { + c.server.sendSnomask('x', fmt.Sprintf("%s set quiet ban %s on %s", c.Nick(), banMask, target)) + } + } else { + // Remove from quiet list + for i, quiet := range channel.quietList { + if quiet == banMask { + channel.quietList = append(channel.quietList[:i], channel.quietList[i+1:]...) + appliedModes = append(appliedModes, "-b") + appliedArgs = append(appliedArgs, mask) + + // Send snomask to opers + if c.IsOper() { + c.server.sendSnomask('x', fmt.Sprintf("%s removed quiet ban %s on %s", c.Nick(), banMask, target)) + } + break + } + } + } + default: + // Unknown extended ban type - treat as regular ban for now + if adding { + channel.AddBan(mask) + appliedModes = append(appliedModes, "+b") + } else { + channel.RemoveBan(mask) + appliedModes = append(appliedModes, "-b") + } + appliedArgs = append(appliedArgs, mask) + } + } else { + // Regular ban + if adding { + channel.AddBan(mask) + appliedModes = append(appliedModes, "+b") + } else { + channel.RemoveBan(mask) + appliedModes = append(appliedModes, "-b") + } + appliedArgs = append(appliedArgs, mask) + } + + case 'R': // registered users only + channel.SetMode('R', adding) + if adding { + appliedModes = append(appliedModes, "+R") + } else { + appliedModes = append(appliedModes, "-R") + } + + case 'M': // muted (only ops can speak) + channel.SetMode('M', adding) + if adding { + appliedModes = append(appliedModes, "+M") + } else { + appliedModes = append(appliedModes, "-M") + } + + case 'N': // no notice messages + channel.SetMode('N', adding) + if adding { + appliedModes = append(appliedModes, "+N") + } else { + appliedModes = append(appliedModes, "-N") + } + + case 'C': // no CTCP messages + channel.SetMode('C', adding) + if adding { + appliedModes = append(appliedModes, "+C") + } else { + appliedModes = append(appliedModes, "-C") + } + + case 'c': // no colors/formatting + channel.SetMode('c', adding) + if adding { + appliedModes = append(appliedModes, "+c") + } else { + appliedModes = append(appliedModes, "-c") + } + + case 'S': // SSL/TLS users only + channel.SetMode('S', adding) + if adding { + appliedModes = append(appliedModes, "+S") + } else { + appliedModes = append(appliedModes, "-S") + } + + case 'O': // opers only + channel.SetMode('O', adding) + if adding { + appliedModes = append(appliedModes, "+O") + } else { + appliedModes = append(appliedModes, "-O") + } + + case 'z': // reduced moderation (halfops can use voice) + channel.SetMode('z', adding) + if adding { + appliedModes = append(appliedModes, "+z") + } else { + appliedModes = append(appliedModes, "-z") + } + + case 'D': // delay join (users don't appear until they speak) + channel.SetMode('D', adding) + if adding { + appliedModes = append(appliedModes, "+D") + } else { + appliedModes = append(appliedModes, "-D") + } + + case 'G': // word filter/profanity filter + channel.SetMode('G', adding) + if adding { + appliedModes = append(appliedModes, "+G") + } else { + appliedModes = append(appliedModes, "-G") + } + + case 'f': // flood protection + if adding { + if argIndex >= len(args) { + continue + } + floodSettings := args[argIndex] + argIndex++ + // Parse flood settings (e.g., "10:5" = 10 messages in 5 seconds) + channel.SetFloodSettings(floodSettings) + channel.SetMode('f', true) + appliedModes = append(appliedModes, "+f") + appliedArgs = append(appliedArgs, floodSettings) + } else { + channel.SetFloodSettings("") + channel.SetMode('f', false) + appliedModes = append(appliedModes, "-f") + } + + case 'j': // join throttling + if adding { + if argIndex >= len(args) { + continue + } + joinThrottle := args[argIndex] + argIndex++ + // Parse join throttle (e.g., "3:10" = 3 joins per 10 seconds) + channel.SetJoinThrottle(joinThrottle) + channel.SetMode('j', true) + appliedModes = append(appliedModes, "+j") + appliedArgs = append(appliedArgs, joinThrottle) + } else { + channel.SetJoinThrottle("") + channel.SetMode('j', false) + appliedModes = append(appliedModes, "-j") + } + + default: + // Unknown mode - ignore for now + } + } + + // Broadcast mode changes to all channel members + if len(appliedModes) > 0 { + modeChangeMsg := fmt.Sprintf("MODE %s %s", target, strings.Join(appliedModes, "")) + if len(appliedArgs) > 0 { + modeChangeMsg += " " + strings.Join(appliedArgs, " ") + } + + for _, client := range channel.GetClients() { + client.SendFrom(c.Prefix(), modeChangeMsg) + } + } +} + +// handleTopic handles TOPIC command +func (c *Client) handleTopic(parts []string) { + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "TOPIC :Not enough parameters") + return + } + + channelName := parts[1] + if !isChannelName(channelName) { + c.SendNumeric(ERR_NOSUCHCHANNEL, channelName+" :No such channel") + return + } + + channel := c.server.GetChannel(channelName) + if channel == nil { + c.SendNumeric(ERR_NOSUCHCHANNEL, channelName+" :No such channel") + return + } + + if !c.IsInChannel(channelName) { + c.SendNumeric(ERR_NOTONCHANNEL, channelName+" :You're not on that channel") + return + } + + // If no topic provided, return current topic + if len(parts) == 2 { + topic := channel.Topic() + if topic == "" { + c.SendNumeric(RPL_NOTOPIC, channelName+" :No topic is set") + } else { + c.SendNumeric(RPL_TOPIC, fmt.Sprintf("%s :%s", channelName, topic)) + } + return + } + + // Check if user can set topic (for now, anyone in channel can) + // TODO: Add proper +t mode checking + newTopic := strings.Join(parts[2:], " ") + if len(newTopic) > 0 && newTopic[0] == ':' { + newTopic = newTopic[1:] + } + + channel.SetTopic(newTopic, c.Nick()) + + // Broadcast topic change to all channel members + for _, client := range channel.GetClients() { + client.SendFrom(c.Prefix(), fmt.Sprintf("TOPIC %s :%s", channelName, newTopic)) + } +} + +// handleAway handles AWAY command +func (c *Client) handleAway(parts []string) { + if len(parts) == 1 { + // Remove away status + c.SetAway("") + c.SendNumeric(RPL_UNAWAY, ":You are no longer marked as being away") + return + } + + // Set away message + awayMsg := strings.Join(parts[1:], " ") + if len(awayMsg) > 0 && awayMsg[0] == ':' { + awayMsg = awayMsg[1:] + } + + c.SetAway(awayMsg) + c.SendNumeric(RPL_NOWAWAY, ":You have been marked as being away") +} + +// handleList handles LIST command +func (c *Client) handleList() { + c.SendNumeric(RPL_LISTSTART, "Channel :Users Name") + + for _, channel := range c.server.GetChannels() { + // For now, show all channels (TODO: Add proper mode checking for secret channels) + userCount := len(channel.GetClients()) + topic := channel.Topic() + if topic == "" { + topic = "" + } + c.SendNumeric(RPL_LIST, fmt.Sprintf("%s %d :%s", channel.Name(), userCount, topic)) + } + + c.SendNumeric(RPL_LISTEND, ":End of /LIST") +} + +// handleInvite handles INVITE command +func (c *Client) handleInvite(parts []string) { + if len(parts) < 3 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "INVITE :Not enough parameters") + return + } + + nick := parts[1] + channelName := parts[2] + + target := c.server.GetClient(nick) + if target == nil { + c.SendNumeric(ERR_NOSUCHNICK, nick+" :No such nick/channel") + return + } + + if !isChannelName(channelName) { + c.SendNumeric(ERR_NOSUCHCHANNEL, channelName+" :No such channel") + return + } + + channel := c.server.GetChannel(channelName) + if channel == nil { + c.SendNumeric(ERR_NOSUCHCHANNEL, channelName+" :No such channel") + return + } + + if !c.IsInChannel(channelName) { + c.SendNumeric(ERR_NOTONCHANNEL, channelName+" :You're not on that channel") + return + } + + if target.IsInChannel(channelName) { + c.SendNumeric(ERR_USERONCHANNEL, fmt.Sprintf("%s %s :is already on channel", nick, channelName)) + return + } + + // TODO: Check if user has operator privileges for invite-only channels + + // Send invite to target + target.SendFrom(c.Prefix(), fmt.Sprintf("INVITE %s %s", target.Nick(), channelName)) + c.SendNumeric(RPL_INVITING, fmt.Sprintf("%s %s", target.Nick(), channelName)) +} + +// handleKick handles KICK command +func (c *Client) handleKick(parts []string) { + if len(parts) < 3 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "KICK :Not enough parameters") + return + } + + channelName := parts[1] + nick := parts[2] + reason := "No reason given" + if len(parts) > 3 { + reason = strings.Join(parts[3:], " ") + if len(reason) > 0 && reason[0] == ':' { + reason = reason[1:] + } + } + + if !isChannelName(channelName) { + c.SendNumeric(ERR_NOSUCHCHANNEL, channelName+" :No such channel") + return + } + + channel := c.server.GetChannel(channelName) + if channel == nil { + c.SendNumeric(ERR_NOSUCHCHANNEL, channelName+" :No such channel") + return + } + + if !c.IsInChannel(channelName) { + c.SendNumeric(ERR_NOTONCHANNEL, channelName+" :You're not on that channel") + return + } + + target := c.server.GetClient(nick) + if target == nil { + c.SendNumeric(ERR_NOSUCHNICK, nick+" :No such nick/channel") + return + } + + if !target.IsInChannel(channelName) { + c.SendNumeric(ERR_USERNOTINCHANNEL, fmt.Sprintf("%s %s :They aren't on that channel", nick, channelName)) + return + } + + // God Mode users cannot be kicked + if target.HasGodMode() { + c.SendNumeric(ERR_NOPRIVILEGES, fmt.Sprintf("%s :Cannot kick user (GOD MODE)", nick)) + c.sendSnomask('o', fmt.Sprintf("GOD MODE: %s attempted to kick %s from %s but was blocked", c.Nick(), target.Nick(), channelName)) + return + } + + // TODO: Check if user has operator privileges + // For now, allow anyone to kick (will fix with proper channel modes) + + // Broadcast kick to all channel members + kickMsg := fmt.Sprintf("KICK %s %s :%s", channelName, target.Nick(), reason) + for _, client := range channel.GetClients() { + client.SendFrom(c.Prefix(), kickMsg) + } + + // Remove target from channel + channel.RemoveClient(target) + target.RemoveChannel(channelName) +} + +// handleKill handles KILL command (operator only) +func (c *Client) handleKill(parts []string) { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "KILL :Not enough parameters") + return + } + + nick := parts[1] + reason := "Killed by operator" + if len(parts) > 2 { + reason = strings.Join(parts[2:], " ") + if len(reason) > 0 && reason[0] == ':' { + reason = reason[1:] + } + } + + target := c.server.GetClient(nick) + if target == nil { + c.SendNumeric(ERR_NOSUCHNICK, nick+" :No such nick/channel") + return + } + + // Can't kill other operators + if target.IsOper() { + c.SendNumeric(ERR_CANTKILLSERVER, ":You can't kill other operators") + return + } + + // Send kill message to target and disconnect + target.SendMessage(fmt.Sprintf("ERROR :Killed (%s (%s))", c.Nick(), reason)) + + // Broadcast to other operators + for _, client := range c.server.GetClients() { + if client.IsOper() && client != c { + client.SendMessage(fmt.Sprintf(":%s WALLOPS :%s killed %s (%s)", + c.server.config.Server.Name, c.Nick(), target.Nick(), reason)) + } + } + + // Disconnect the target + target.conn.Close() +} + +// handleOper handles OPER command +func (c *Client) handleOper(parts []string) { + if len(parts) < 3 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "OPER :Not enough parameters") + return + } + + if c.server == nil || c.server.config == nil { + c.SendNumeric(ERR_NOOPERHOST, ":No O-lines for your host") + return + } + + name := parts[1] + password := parts[2] + + // Check if opers are enabled + if !c.server.config.Features.EnableOper { + c.SendNumeric(ERR_NOOPERHOST, ":O-lines are disabled") + return + } + + var matchedOper *Oper + var operConfig *OperConfig + + // Try to load advanced oper configuration first + if c.server.config.OperConfig.Enable { + var err error + operConfig, err = LoadOperConfig(c.server.config.OperConfig.ConfigFile) + if err == nil { + oper := operConfig.GetOper(name) + if oper != nil && oper.Password == password { + // TODO: Implement proper host matching + if oper.Host == "*@localhost" || oper.Host == "*@*" { + matchedOper = oper + } + } + } + } + + // Fallback to legacy configuration + if matchedOper == nil { + for _, oper := range c.server.config.Opers { + if oper.Name == name && oper.Password == password { + // Check host mask (simplified - just check if it matches *@localhost for now) + if oper.Host == "*@localhost" || oper.Host == "*@*" { + // Convert legacy oper to new format for consistency + matchedOper = &Oper{ + Name: oper.Name, + Password: oper.Password, + Host: oper.Host, + Class: oper.Class, + Flags: oper.Flags, + } + break + } + } + } + } + + if matchedOper == nil { + c.SendNumeric(ERR_PASSWDMISMATCH, ":Password incorrect") + return + } + + // Set operator status + c.SetOper(true) + c.SetOperClass(matchedOper.Class) + + // Set operator user mode + c.SetMode('o', true) + c.SetMode('s', true) // Enable server notices by default + c.SetMode('w', true) // Enable wallops by default + + // Set default snomasks for new operators + c.SetSnomask('c', true) // Client connects/disconnects + c.SetSnomask('o', true) // Oper-up messages + c.SetSnomask('s', true) // Server messages + + // Get operator class information for display + var className string + if operConfig != nil { + class := operConfig.GetOperClass(matchedOper.Class) + if class != nil { + className = fmt.Sprintf(" (%s)", class.Description) + } + } + + c.SendNumeric(RPL_YOUREOPER, ":You are now an IRC operator"+className) + c.SendNumeric(RPL_SNOMASK, fmt.Sprintf("%s :Server notice mask", c.GetSnomasks())) + + // Send mode change notification + c.SendMessage(fmt.Sprintf(":%s MODE %s :+osw", c.Nick(), c.Nick())) + + // Send snomask to other operators + operSymbol := c.GetOperSymbol() + c.sendSnomask('o', fmt.Sprintf("%s%s (%s@%s) is now an IRC operator%s", + operSymbol, c.Nick(), c.User(), c.Host(), className)) +} + +// handleSnomask handles SNOMASK command (server notice masks for operators) +func (c *Client) handleSnomask(parts []string) { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + if len(parts) < 2 { + // Show current snomasks + current := c.GetSnomasks() + if current == "" { + current = "+" + } + c.SendNumeric(RPL_SNOMASK, fmt.Sprintf("%s :Server notice mask", current)) + return + } + + modeString := parts[1] + adding := true + changed := false + + for _, char := range modeString { + switch char { + case '+': + adding = true + case '-': + adding = false + case 'c': // Client connects/disconnects + c.SetSnomask('c', adding) + changed = true + case 'k': // Kill messages + c.SetSnomask('k', adding) + changed = true + case 'o': // Oper-up messages + c.SetSnomask('o', adding) + changed = true + case 'x': // X-line (ban) messages + c.SetSnomask('x', adding) + changed = true + case 'f': // Flood messages + c.SetSnomask('f', adding) + changed = true + case 'n': // Nick changes + c.SetSnomask('n', adding) + changed = true + case 's': // Server messages + c.SetSnomask('s', adding) + changed = true + case 'd': // Debug messages (TechIRCd special) + c.SetSnomask('d', adding) + changed = true + } + } + + if changed { + current := c.GetSnomasks() + if current == "" { + current = "+" + } + c.SendNumeric(RPL_SNOMASK, fmt.Sprintf("%s :Server notice mask", current)) + } +} + +// handleGlobalNotice handles GLOBALNOTICE command (TechIRCd special oper command) +func (c *Client) handleGlobalNotice(parts []string) { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "GLOBALNOTICE :Not enough parameters") + return + } + + message := strings.Join(parts[1:], " ") + if len(message) > 0 && message[0] == ':' { + message = message[1:] + } + + // Send global notice to all users + for _, client := range c.server.GetClients() { + client.SendMessage(fmt.Sprintf(":%s NOTICE %s :[GLOBAL] %s", + c.server.config.Server.Name, client.Nick(), message)) + } + + // Send snomask to operators watching global notices + c.sendSnomask('s', fmt.Sprintf("Global notice from %s: %s", c.Nick(), message)) +} + +// handleWallops handles WALLOPS command (send to users with +w mode) +func (c *Client) handleWallops(parts []string) { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "WALLOPS :Not enough parameters") + return + } + + message := strings.Join(parts[1:], " ") + if len(message) > 0 && message[0] == ':' { + message = message[1:] + } + + // Send to all users with +w mode + for _, client := range c.server.GetClients() { + if client.HasMode('w') { + client.SendMessage(fmt.Sprintf(":%s WALLOPS :%s", c.Nick(), message)) + } + } +} + +// handleOperWall handles OPERWALL command (message to all operators) +func (c *Client) handleOperWall(parts []string) { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "OPERWALL :Not enough parameters") + return + } + + message := strings.Join(parts[1:], " ") + if len(message) > 0 && message[0] == ':' { + message = message[1:] + } + + // Send to all operators + for _, client := range c.server.GetClients() { + if client.IsOper() { + client.SendMessage(fmt.Sprintf(":%s WALLOPS :%s", c.Nick(), message)) + } + } +} + +// handleRehash handles REHASH command (reload configuration) +func (c *Client) handleRehash() { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + // Reload configuration + if c.server != nil { + err := c.server.ReloadConfig() + if err != nil { + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** REHASH failed: %s", + c.server.config.Server.Name, c.Nick(), err.Error())) + c.sendSnomask('s', fmt.Sprintf("REHASH failed by %s: %s", c.Nick(), err.Error())) + } else { + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Configuration reloaded successfully", + c.server.config.Server.Name, c.Nick())) + c.sendSnomask('s', fmt.Sprintf("Configuration reloaded by %s", c.Nick())) + } + } +} + +// handleTrace handles TRACE command (show server connection tree) +func (c *Client) handleTrace(_ []string) { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + // Show basic server info (simplified implementation) + c.SendMessage(fmt.Sprintf(":%s 200 %s Link %s %s %s", + c.server.config.Server.Name, c.Nick(), + c.server.config.Server.Version, + c.server.config.Server.Name, + "TechIRCd")) + + clientCount := len(c.server.GetClients()) + c.SendMessage(fmt.Sprintf(":%s 262 %s %s :End of TRACE with %d clients", + c.server.config.Server.Name, c.Nick(), + c.server.config.Server.Name, clientCount)) +} + +// isValidNickname checks if a nickname is valid +func isValidNickname(nick string) bool { + if len(nick) == 0 || len(nick) > 30 { + return false + } + + // First character must be a letter or special char + first := nick[0] + if !((first >= 'A' && first <= 'Z') || (first >= 'a' && first <= 'z') || + first == '[' || first == ']' || first == '\\' || first == '`' || + first == '_' || first == '^' || first == '{' || first == '|' || first == '}') { + return false + } + + // Rest can be letters, digits, or special chars + for i := 1; i < len(nick); i++ { + c := nick[i] + if !((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || + c == '[' || c == ']' || c == '\\' || c == '`' || + c == '_' || c == '^' || c == '{' || c == '|' || c == '}' || c == '-') { + return false + } + } + + return true +} + +// isValidChannelName checks if a channel name is valid +func isValidChannelName(name string) bool { + if len(name) == 0 || len(name) > 50 { + return false + } + + return name[0] == '#' || name[0] == '&' || name[0] == '!' || name[0] == '+' +} + +// isChannelName checks if a name is a channel name +func isChannelName(name string) bool { + if len(name) == 0 { + return false + } + return name[0] == '#' || name[0] == '&' || name[0] == '!' || name[0] == '+' +} + +// Server linking commands + +// handleConnect handles CONNECT command for server linking +func (c *Client) handleConnect(parts []string) { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + if len(parts) < 3 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "CONNECT :Not enough parameters") + return + } + + serverName := parts[1] + portStr := parts[2] + host := "localhost" + if len(parts) > 3 { + host = parts[3] + } + + port := 0 + if _, err := fmt.Sscanf(portStr, "%d", &port); err != nil || port <= 0 || port > 65535 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "CONNECT :Invalid port number") + return + } + + // Check if server is already connected + if c.server.GetLinkedServer(serverName) != nil { + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Server %s is already connected", + c.server.config.Server.Name, c.Nick(), serverName)) + return + } + + // Find the server in configuration + var linkConfig *struct { + Name string `json:"name"` + Host string `json:"host"` + Port int `json:"port"` + Password string `json:"password"` + AutoConnect bool `json:"auto_connect"` + Hub bool `json:"hub"` + Description string `json:"description"` + } + + for _, link := range c.server.config.Linking.Links { + if link.Name == serverName { + linkConfig = &link + break + } + } + + if linkConfig == nil { + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** No link configuration found for %s", + c.server.config.Server.Name, c.Nick(), serverName)) + return + } + + // Use configured values, but allow override from command + connectHost := linkConfig.Host + connectPort := linkConfig.Port + if host != "localhost" { + connectHost = host + } + if portStr != "0" { + connectPort = port + } + + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Attempting to connect to %s at %s:%d", + c.server.config.Server.Name, c.Nick(), serverName, connectHost, connectPort)) + + // Send snomask to other operators + c.sendSnomask('s', fmt.Sprintf("%s initiated connection to %s (%s:%d)", + c.Nick(), serverName, connectHost, connectPort)) + + // Start connection attempt + go c.server.connectToServer(linkConfig.Name, connectHost, connectPort, + linkConfig.Password, linkConfig.Hub, linkConfig.Description) +} + +// handleSquit handles SQUIT command for server disconnection +func (c *Client) handleSquit(parts []string) { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "SQUIT :Not enough parameters") + return + } + + serverName := parts[1] + reason := "Operator requested disconnection" + if len(parts) > 2 { + reason = strings.Join(parts[2:], " ") + if len(reason) > 0 && reason[0] == ':' { + reason = reason[1:] + } + } + + linkedServer := c.server.GetLinkedServer(serverName) + if linkedServer == nil { + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Server %s is not connected", + c.server.config.Server.Name, c.Nick(), serverName)) + return + } + + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Disconnecting from %s: %s", + c.server.config.Server.Name, c.Nick(), serverName, reason)) + + // Send snomask to other operators + c.sendSnomask('s', fmt.Sprintf("%s disconnected %s: %s", c.Nick(), serverName, reason)) + + // Send SQUIT to remote server + linkedServer.SendMessage(fmt.Sprintf("SQUIT %s :%s", c.server.config.Server.Name, reason)) + + // Remove the server + c.server.RemoveLinkedServer(serverName) +} + +// handleLinks handles LINKS command to show server links +func (c *Client) handleLinks() { + if !c.IsRegistered() { + c.SendNumeric(ERR_NOTREGISTERED, ":You have not registered") + return + } + + // Show our server first + c.SendNumeric(364, fmt.Sprintf("%s %s :0 %s", + c.server.config.Server.Name, c.server.config.Server.Name, c.server.config.Server.Description)) + + // Show linked servers + linkedServers := c.server.GetLinkedServers() + for _, linkedServer := range linkedServers { + if linkedServer.IsConnected() { + c.SendNumeric(364, fmt.Sprintf("%s %s :1 %s", + linkedServer.Name(), c.server.config.Server.Name, linkedServer.Description())) + } + } + + c.SendNumeric(365, ":End of /LINKS list") +} + +// handleUserhost handles USERHOST command +func (c *Client) handleUserhost(parts []string) { + if !c.IsRegistered() { + c.SendNumeric(ERR_NOTREGISTERED, ":You have not registered") + return + } + + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "USERHOST :Not enough parameters") + return + } + + var responses []string + // USERHOST can take up to 5 nicknames + maxNicks := 5 + if len(parts)-1 < maxNicks { + maxNicks = len(parts) - 1 + } + + for i := 1; i <= maxNicks && i < len(parts); i++ { + nick := parts[i] + target := c.server.GetClient(nick) + if target != nil { + response := target.Nick() + "=" + if target.IsOper() { + response += "*" + } + if target.Away() != "" { + response += "-" + } else { + response += "+" + } + response += target.User() + "@" + target.HostForUser(c) + responses = append(responses, response) + } + } + + if len(responses) > 0 { + c.SendNumeric(RPL_USERHOST, ":"+strings.Join(responses, " ")) + } +} + +// handleIson handles ISON command +func (c *Client) handleIson(parts []string) { + if !c.IsRegistered() { + c.SendNumeric(ERR_NOTREGISTERED, ":You have not registered") + return + } + + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "ISON :Not enough parameters") + return + } + + var onlineNicks []string + for i := 1; i < len(parts); i++ { + nick := parts[i] + if c.server.GetClient(nick) != nil { + onlineNicks = append(onlineNicks, nick) + } + } + + c.SendNumeric(RPL_ISON, ":"+strings.Join(onlineNicks, " ")) +} + +// handleTime handles TIME command +func (c *Client) handleTime() { + if !c.IsRegistered() { + c.SendNumeric(ERR_NOTREGISTERED, ":You have not registered") + return + } + + currentTime := time.Now().Format("Mon Jan 2 15:04:05 2006") + c.SendNumeric(RPL_TIME, fmt.Sprintf("%s :%s", c.server.config.Server.Name, currentTime)) +} + +// handleVersion handles VERSION command +func (c *Client) handleVersion() { + if !c.IsRegistered() { + c.SendNumeric(ERR_NOTREGISTERED, ":You have not registered") + return + } + + version := fmt.Sprintf("TechIRCd-%s", c.server.config.Server.Version) + c.SendNumeric(RPL_VERSION, fmt.Sprintf("%s %s :Go IRC Server by ComputerTech312", + version, c.server.config.Server.Name)) +} + +// handleAdmin handles ADMIN command +func (c *Client) handleAdmin() { + if !c.IsRegistered() { + c.SendNumeric(ERR_NOTREGISTERED, ":You have not registered") + return + } + + c.SendNumeric(RPL_ADMINME, fmt.Sprintf("%s :Administrative info", c.server.config.Server.Name)) + c.SendNumeric(RPL_ADMINLOC1, ":TechIRCd Server") + c.SendNumeric(RPL_ADMINLOC2, ":Modern IRC Server written in Go") + c.SendNumeric(RPL_ADMINEMAIL, fmt.Sprintf(":%s", c.server.config.Server.AdminInfo)) +} + +// handleInfo handles INFO command +func (c *Client) handleInfo() { + if !c.IsRegistered() { + c.SendNumeric(ERR_NOTREGISTERED, ":You have not registered") + return + } + + infoLines := []string{ + "TechIRCd - Modern IRC Server", + "", + "Version: " + c.server.config.Server.Version, + "Network: " + c.server.config.Server.Network, + "Description: " + c.server.config.Server.Description, + "", + "Features:", + "- Full RFC 2812 compliance", + "- Advanced operator system with hierarchical classes", + "- Comprehensive channel management", + "- God Mode and Stealth Mode", + "- Revolutionary WHOIS system", + "- Server linking support", + "- Real-time health monitoring", + "", + "Written in Go by ComputerTech312", + "https://github.com/ComputerTech312/TechIRCd", + } + + for _, line := range infoLines { + c.SendNumeric(RPL_INFO, ":"+line) + } + c.SendNumeric(RPL_ENDOFINFO, ":End of /INFO list") +} + +// handleLusers handles LUSERS command +func (c *Client) handleLusers() { + if !c.IsRegistered() { + c.SendNumeric(ERR_NOTREGISTERED, ":You have not registered") + return + } + + totalUsers := len(c.server.GetClients()) + totalChannels := len(c.server.GetChannels()) + + // Count operators + operCount := 0 + invisibleCount := 0 + for _, client := range c.server.GetClients() { + if client.IsOper() { + operCount++ + } + if client.HasMode('i') { + invisibleCount++ + } + } + + visibleUsers := totalUsers - invisibleCount + unknownConnections := 0 // Connections that haven't completed registration + + c.SendNumeric(RPL_LUSERCLIENT, fmt.Sprintf(":There are %d users and %d invisible on 1 servers", + visibleUsers, invisibleCount)) + + if operCount > 0 { + c.SendNumeric(RPL_LUSEROP, fmt.Sprintf("%d :operator(s) online", operCount)) + } + + if unknownConnections > 0 { + c.SendNumeric(RPL_LUSERUNKNOWN, fmt.Sprintf("%d :unknown connection(s)", unknownConnections)) + } + + if totalChannels > 0 { + c.SendNumeric(RPL_LUSERCHANNELS, fmt.Sprintf("%d :channels formed", totalChannels)) + } + + c.SendNumeric(RPL_LUSERME, fmt.Sprintf(":I have %d clients and 1 servers", totalUsers)) +} + +// handleStats handles STATS command +func (c *Client) handleStats(parts []string) { + if !c.IsRegistered() { + c.SendNumeric(ERR_NOTREGISTERED, ":You have not registered") + return + } + + // Only operators can use most STATS queries + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + statsType := "l" // default to links + if len(parts) > 1 { + statsType = strings.ToLower(parts[1]) + } + + switch statsType { + case "l": // Links + // Show server links + linkedServers := c.server.GetLinkedServers() + for name, server := range linkedServers { + if server.IsConnected() { + c.SendNumeric(RPL_STATSLINKINFO, fmt.Sprintf("%s 0 0 0 0 0 0", name)) + } + } + + case "u": // Uptime + uptime := time.Since(c.server.healthMonitor.startTime) + c.SendNumeric(RPL_STATSUPTIME, fmt.Sprintf(":Server Up %d days %d:%02d:%02d", + int(uptime.Hours())/24, int(uptime.Hours())%24, + int(uptime.Minutes())%60, int(uptime.Seconds())%60)) + + case "o": // Operators + for _, oper := range c.server.config.Opers { + c.SendNumeric(RPL_STATSOLINE, fmt.Sprintf("O %s * %s", oper.Host, oper.Name)) + } + + case "m": // Commands + // Would show command usage statistics + c.SendNumeric(RPL_STATSCOMMANDS, "PRIVMSG 1234 567 890") + c.SendNumeric(RPL_STATSCOMMANDS, "JOIN 456 123 789") + c.SendNumeric(RPL_STATSCOMMANDS, "PART 234 89 456") + + default: + c.SendNumeric(ERR_NOSUCHSERVER, fmt.Sprintf("%s :No such server", statsType)) + return + } + + c.SendNumeric(RPL_ENDOFSTATS, fmt.Sprintf("%s :End of /STATS report", statsType)) +} + +// handleSilence handles SILENCE command (user-level blocking) +func (c *Client) handleSilence(parts []string) { + if !c.IsRegistered() { + c.SendNumeric(ERR_NOTREGISTERED, ":You have not registered") + return + } + + if len(parts) < 2 { + // List current silence masks + if len(c.silenceList) == 0 { + c.SendNumeric(RPL_ENDOFSILELIST, ":End of Silence list") + return + } + + for _, mask := range c.silenceList { + c.SendNumeric(RPL_SILELIST, mask) + } + c.SendNumeric(RPL_ENDOFSILELIST, ":End of Silence list") + return + } + + mask := parts[1] + if strings.HasPrefix(mask, "-") { + // Remove from silence list + mask = mask[1:] + for i, silenceMask := range c.silenceList { + if silenceMask == mask { + c.silenceList = append(c.silenceList[:i], c.silenceList[i+1:]...) + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :Removed %s from silence list", + c.server.config.Server.Name, c.Nick(), mask)) + return + } + } + } else { + // Add to silence list + if len(c.silenceList) >= 32 { // Limit silence list size + c.SendNumeric(ERR_SILELISTFULL, ":Your silence list is full") + return + } + + c.silenceList = append(c.silenceList, mask) + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :Added %s to silence list", + c.server.config.Server.Name, c.Nick(), mask)) + } +} + +// handleMonitor handles MONITOR command (IRCv3) +func (c *Client) handleMonitor(parts []string) { + if !c.IsRegistered() { + c.SendNumeric(ERR_NOTREGISTERED, ":You have not registered") + return + } + + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "MONITOR :Not enough parameters") + return + } + + subcommand := strings.ToUpper(parts[1]) + + switch subcommand { + case "+": // Add nicknames to monitor list + if len(parts) < 3 { + return + } + + nicks := strings.Split(parts[2], ",") + var online []string + var offline []string + + for _, nick := range nicks { + if len(c.monitorList) >= 100 { // Limit monitor list size + c.SendNumeric(ERR_MONLISTFULL, fmt.Sprintf("%d %s :Monitor list is full", + 100, nick)) + break + } + + c.monitorList = append(c.monitorList, nick) + + if target := c.server.GetClient(nick); target != nil { + online = append(online, nick) + } else { + offline = append(offline, nick) + } + } + + if len(online) > 0 { + c.SendNumeric(RPL_MONONLINE, ":"+strings.Join(online, ",")) + } + if len(offline) > 0 { + c.SendNumeric(RPL_MONOFFLINE, ":"+strings.Join(offline, ",")) + } + + case "-": // Remove nicknames from monitor list + if len(parts) < 3 { + return + } + + nicks := strings.Split(parts[2], ",") + for _, nick := range nicks { + for i, monitorNick := range c.monitorList { + if strings.EqualFold(monitorNick, nick) { + c.monitorList = append(c.monitorList[:i], c.monitorList[i+1:]...) + break + } + } + } + + case "C": // Clear monitor list + c.monitorList = []string{} + + case "L": // List monitor list + if len(c.monitorList) == 0 { + c.SendNumeric(RPL_ENDOFMONLIST, ":End of MONITOR list") + return + } + + // Send in batches of 10 + for i := 0; i < len(c.monitorList); i += 10 { + end := i + 10 + if end > len(c.monitorList) { + end = len(c.monitorList) + } + batch := c.monitorList[i:end] + c.SendNumeric(RPL_MONLIST, ":"+strings.Join(batch, ",")) + } + c.SendNumeric(RPL_ENDOFMONLIST, ":End of MONITOR list") + + case "S": // Show status + var online []string + var offline []string + + for _, nick := range c.monitorList { + if c.server.GetClient(nick) != nil { + online = append(online, nick) + } else { + offline = append(offline, nick) + } + } + + if len(online) > 0 { + c.SendNumeric(RPL_MONONLINE, ":"+strings.Join(online, ",")) + } + if len(offline) > 0 { + c.SendNumeric(RPL_MONOFFLINE, ":"+strings.Join(offline, ",")) + } + } +} + +// handleAuthenticate handles AUTHENTICATE command for SASL +func (c *Client) handleAuthenticate(parts []string) { + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "AUTHENTICATE :Not enough parameters") + return + } + + if c.IsRegistered() { + c.SendNumeric(ERR_ALREADYREGISTRED, ":You may not reregister") + return + } + + mechanism := strings.ToUpper(parts[1]) + + // Support PLAIN mechanism for now + if mechanism == "PLAIN" { + c.saslMech = "PLAIN" + c.SendMessage("AUTHENTICATE +") + return + } + + if mechanism == "*" { + // Abort SASL authentication + c.saslMech = "" + c.saslData = "" + c.SendNumeric(ERR_SASLABORTED, ":SASL authentication aborted") + return + } + + if c.saslMech == "PLAIN" && mechanism != "PLAIN" { + // This is the SASL data + saslData := parts[1] + + if saslData == "+" { + // Empty response, wait for data + return + } + + // Decode base64 SASL data (simplified - in real implementation would use proper base64) + // Format: authzid\0authcid\0password + // For simplicity, we'll expect username:password format + if strings.Contains(saslData, ":") { + credentials := strings.SplitN(saslData, ":", 2) + if len(credentials) == 2 { + username := credentials[0] + password := credentials[1] + + // Check against configured accounts (simplified) + if c.authenticateUser(username, password) { + c.account = username + c.SendNumeric(RPL_SASLSUCCESS, ":SASL authentication successful") + } else { + c.SendNumeric(ERR_SASLFAIL, ":SASL authentication failed") + } + } else { + c.SendNumeric(ERR_SASLFAIL, ":SASL authentication failed") + } + } else { + c.SendNumeric(ERR_SASLFAIL, ":SASL authentication failed") + } + + c.saslMech = "" + c.saslData = "" + return + } + + // Unsupported mechanism + c.SendNumeric(ERR_SASLNOTSUPP, fmt.Sprintf("%s :are available SASL mechanisms", "PLAIN")) +} + +// authenticateUser checks user credentials (simplified implementation) +func (c *Client) authenticateUser(username, password string) bool { + // In a real implementation, this would check against a database or services + // For now, we'll use a simple hardcoded check + accounts := map[string]string{ + "admin": "password123", + "testuser": "test123", + } + + if storedPassword, exists := accounts[username]; exists { + return storedPassword == password + } + + return false +} + +// handleHelpop handles HELPOP command - help for IRC operators and users +func (c *Client) handleHelpop(parts []string) { + if !c.IsRegistered() { + c.SendNumeric(ERR_NOTREGISTERED, ":You have not registered") + return + } + + topic := "index" + if len(parts) > 1 { + topic = strings.ToLower(parts[1]) + } + + switch topic { + case "index", "help", "": + c.sendHelpIndex() + + case "modes": + c.sendHelpModes() + + case "commands": + c.sendHelpCommands() + + case "chanmodes": + c.sendHelpChannelModes() + + case "usermodes": + c.sendHelpUserModes() + + case "oper": + c.sendHelpOper() + + case "god", "godmode": + c.sendHelpGodMode() + + case "stealth", "stealthmode": + c.sendHelpStealthMode() + + case "ircv3": + c.sendHelpIRCv3() + + case "linking": + c.sendHelpLinking() + + case "examples": + c.sendHelpExamples() + + default: + c.SendMessage(fmt.Sprintf(":%s 292 %s :*** Unknown HELPOP topic: %s", + c.server.config.Server.Name, c.Nick(), topic)) + c.SendMessage(fmt.Sprintf(":%s 292 %s :*** Use /HELPOP INDEX for available topics", + c.server.config.Server.Name, c.Nick())) + } +} + +// sendHelpIndex sends the main help index +func (c *Client) sendHelpIndex() { + c.SendMessage(fmt.Sprintf(":%s 292 %s :*** TechIRCd Help System ***", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :Available help topics:", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : COMMANDS - List of available commands", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : CHANMODES - Channel modes (+mntispkl etc)", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : USERMODES - User modes (+iwsoBGS etc)", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : OPER - IRC Operator commands", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : GODMODE - God Mode features (+G)", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : STEALTHMODE - Stealth Mode features (+S)", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : IRCV3 - IRCv3 capabilities", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : LINKING - Server linking", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : EXAMPLES - Usage examples", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :Usage: /HELPOP ", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 294 %s :End of /HELPOP", + c.server.config.Server.Name, c.Nick())) +} + +// sendHelpChannelModes sends help about channel modes +func (c *Client) sendHelpChannelModes() { + c.SendMessage(fmt.Sprintf(":%s 292 %s :*** TechIRCd Channel Modes ***", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :Basic Modes:", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +m Moderated (only voiced users can speak)", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +n No external messages", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +t Topic protection (ops only)", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +i Invite only", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +s Secret channel", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +p Private channel", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +k Channel key/password", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +l User limit", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :Advanced Modes:", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +R Registered users only", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +M Muted (only ops can speak)", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +N No notice messages", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +C No CTCP messages", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +c No colors/formatting", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +S SSL/TLS users only", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +O Opers only", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +z Reduced moderation", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +D Delay join", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +G Word filter", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +f Flood protection", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +j Join throttling", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :User Levels:", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +q Owner/Founder (~)", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +o Operator (@)", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +h Half-operator (%%)", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +v Voice (+)", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 294 %s :End of /HELPOP", + c.server.config.Server.Name, c.Nick())) +} + +// sendHelpUserModes sends help about user modes +func (c *Client) sendHelpUserModes() { + c.SendMessage(fmt.Sprintf(":%s 292 %s :*** TechIRCd User Modes ***", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :Basic Modes:", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +i Invisible (hidden from WHO)", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +w Wallops (receive WALLOPS messages)", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +s Server notices (oper only)", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +o IRC Operator (automatic)", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +r Registered (services only)", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +x Host masking", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +z SSL/TLS connection (automatic)", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +B Bot flag", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :TechIRCd Special Modes:", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +G God Mode (oper only, ultimate power)", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : +S Stealth Mode (oper only, invisible to users)", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :Usage: /MODE yournick +mode", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 294 %s :End of /HELPOP", + c.server.config.Server.Name, c.Nick())) +} + +// sendHelpGodMode sends help about God Mode +func (c *Client) sendHelpGodMode() { + c.SendMessage(fmt.Sprintf(":%s 292 %s :*** TechIRCd God Mode (+G) ***", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :God Mode gives ultimate channel override powers:", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :• Bypass ALL channel restrictions (+k, +l, +i, +b)", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :• Set channel modes without operator privileges", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :• Cannot be kicked from channels", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :• Immune to channel bans", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :• Join invite-only channels instantly", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :Requirements:", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :• Must be an IRC operator (/OPER)", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :• Operator class needs 'god_mode' permission", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :Usage: /MODE yournick +G", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 294 %s :End of /HELPOP", + c.server.config.Server.Name, c.Nick())) +} + +// sendHelpCommands sends help about available commands +func (c *Client) sendHelpCommands() { + c.SendMessage(fmt.Sprintf(":%s 292 %s :*** TechIRCd Commands ***", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :Basic Commands:", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /JOIN #channel - Join a channel", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /PART #channel - Leave a channel", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /PRIVMSG target - Send a message", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /NOTICE target - Send a notice", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /WHOIS nick - Get user info", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /MODE target - Set modes", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /TOPIC #channel - Set/view topic", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /KICK #chan nick - Kick user", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /INVITE nick #chan - Invite user", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :Operator Commands:", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /OPER name pass - Become operator", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /KILL nick reason - Disconnect user", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /WALLOPS message - Message to +w users", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /REHASH - Reload config", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :IRCv3 Commands:", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /CAP LS - List capabilities", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /MONITOR +nick - Monitor user", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /AUTHENTICATE - SASL authentication", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 294 %s :End of /HELPOP", + c.server.config.Server.Name, c.Nick())) +} + +// sendHelpModes sends general help about modes +func (c *Client) sendHelpModes() { + c.SendMessage(fmt.Sprintf(":%s 292 %s :*** TechIRCd Modes ***", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :TechIRCd supports both user modes and channel modes.", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :Use /HELPOP USERMODES for user mode help", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :Use /HELPOP CHANMODES for channel mode help", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 294 %s :End of /HELPOP", + c.server.config.Server.Name, c.Nick())) +} + +// sendHelpOper sends help about operator commands +func (c *Client) sendHelpOper() { + if !c.IsOper() { + c.SendMessage(fmt.Sprintf(":%s 292 %s :*** You are not an IRC operator ***", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 294 %s :End of /HELPOP", + c.server.config.Server.Name, c.Nick())) + return + } + + c.SendMessage(fmt.Sprintf(":%s 292 %s :*** TechIRCd Operator Help ***", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :Available operator commands:", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /KILL nick reason - Disconnect user", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /WALLOPS message - Send to +w users", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /OPERWALL message - Send to operators", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /GLOBALNOTICE message - Send to all users", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /REHASH - Reload configuration", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /SNOMASK +modes - Set notice masks", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /CONNECT server port - Link to server", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /SQUIT server reason - Unlink server", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :Your operator class: %s", + c.server.config.Server.Name, c.Nick(), c.OperClass())) + c.SendMessage(fmt.Sprintf(":%s 294 %s :End of /HELPOP", + c.server.config.Server.Name, c.Nick())) +} + +// sendHelpStealthMode sends help about Stealth Mode +func (c *Client) sendHelpStealthMode() { + c.SendMessage(fmt.Sprintf(":%s 292 %s :*** TechIRCd Stealth Mode (+S) ***", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :Stealth Mode makes you invisible to regular users:", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :• Hidden from WHO commands", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :• Hidden from NAMES lists", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :• WHOIS restricted (operators can still see you)", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :• Still visible to other operators", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :Requirements:", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :• Must be an IRC operator (/OPER)", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :• Operator class needs 'stealth_mode' permission", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :Usage: /MODE yournick +S", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 294 %s :End of /HELPOP", + c.server.config.Server.Name, c.Nick())) +} + +// sendHelpIRCv3 sends help about IRCv3 features +func (c *Client) sendHelpIRCv3() { + c.SendMessage(fmt.Sprintf(":%s 292 %s :*** TechIRCd IRCv3 Support ***", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :TechIRCd supports modern IRCv3 features:", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :• server-time - Message timestamps", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :• account-notify - Account change notifications", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :• away-notify - Away status notifications", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :• extended-join - Enhanced JOIN with account info", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :• multi-prefix - Multiple user prefixes", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :• sasl - SASL authentication", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :• message-tags - Message tag support", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :• echo-message - Message echo back to sender", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :Usage: /CAP REQ :server-time account-notify", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 294 %s :End of /HELPOP", + c.server.config.Server.Name, c.Nick())) +} + +// sendHelpLinking sends help about server linking +func (c *Client) sendHelpLinking() { + if !c.IsOper() { + c.SendMessage(fmt.Sprintf(":%s 292 %s :*** Operator access required ***", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 294 %s :End of /HELPOP", + c.server.config.Server.Name, c.Nick())) + return + } + + c.SendMessage(fmt.Sprintf(":%s 292 %s :*** TechIRCd Server Linking ***", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :Server linking commands:", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /CONNECT server port - Connect to remote server", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /SQUIT server reason - Disconnect server", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /LINKS - Show linked servers", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :Servers must be configured in linking.json", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 294 %s :End of /HELPOP", + c.server.config.Server.Name, c.Nick())) +} + +// sendHelpExamples sends usage examples +func (c *Client) sendHelpExamples() { + c.SendMessage(fmt.Sprintf(":%s 292 %s :*** TechIRCd Usage Examples ***", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :Join a channel:", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /JOIN #help", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :Set channel to moderated:", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /MODE #help +m", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :Enable God Mode (operators only):", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /MODE yournick +G", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s :Request IRCv3 capabilities:", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 292 %s : /CAP REQ :server-time message-tags", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s 294 %s :End of /HELPOP", + c.server.config.Server.Name, c.Nick())) +} + +// expandBanMask expands partial ban masks to full IRC hostmask format +func expandBanMask(mask string) string { + // If it's already a full hostmask (contains ! and @), return as-is + if strings.Contains(mask, "!") && strings.Contains(mask, "@") { + return mask + } + + // If it's just a nickname, expand to nick!*@* + if !strings.Contains(mask, "!") && !strings.Contains(mask, "@") { + return fmt.Sprintf("%s!*@*", mask) + } + + // If it has ! but no @, assume it's nick!user format, add @* + if strings.Contains(mask, "!") && !strings.Contains(mask, "@") { + return fmt.Sprintf("%s@*", mask) + } + + // If it has @ but no !, assume it's @host format, add *!* + if !strings.Contains(mask, "!") && strings.Contains(mask, "@") { + return fmt.Sprintf("*!*%s", mask) + } + + // Default case - return as-is + return mask +} + +// Services/Admin Commands + +// handleChghost handles CHGHOST command - change user's hostname +func (c *Client) handleChghost(parts []string) { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + if len(parts) < 3 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "CHGHOST :Not enough parameters") + return + } + + targetNick := parts[1] + newHost := parts[2] + + target := c.server.GetClient(targetNick) + if target == nil { + c.SendNumeric(ERR_NOSUCHNICK, targetNick+" :No such nick/channel") + return + } + + oldHost := target.Host() + target.host = newHost + + // Notify the target user + target.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Your hostname has been changed to %s", + c.server.config.Server.Name, target.Nick(), newHost)) + + // Notify all channels the user is in + for channelName := range target.channels { + channel := c.server.GetChannel(channelName) + if channel != nil { + channel.BroadcastFrom(c.server.config.Server.Name, + fmt.Sprintf("CHGHOST %s %s %s", target.Nick(), oldHost, newHost), nil) + } + } + + // Send snomask to operators + c.server.sendSnomask('o', fmt.Sprintf("%s used CHGHOST to change %s's hostname from %s to %s", + c.Nick(), target.Nick(), oldHost, newHost)) + + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Changed hostname of %s from %s to %s", + c.server.config.Server.Name, c.Nick(), target.Nick(), oldHost, newHost)) +} + +// handleSvsnick handles SVSNICK command - services force nickname change +func (c *Client) handleSvsnick(parts []string) { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + if len(parts) < 3 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "SVSNICK :Not enough parameters") + return + } + + targetNick := parts[1] + newNick := parts[2] + + target := c.server.GetClient(targetNick) + if target == nil { + c.SendNumeric(ERR_NOSUCHNICK, targetNick+" :No such nick/channel") + return + } + + // Check if new nick is already in use + if c.server.IsNickInUse(newNick) { + c.SendNumeric(ERR_NICKNAMEINUSE, newNick+" :Nickname is already in use") + return + } + + oldNick := target.Nick() + + // Send NICK change to all channels the user is in + for channelName := range target.channels { + channel := c.server.GetChannel(channelName) + if channel != nil { + channel.BroadcastFrom(target.Prefix(), fmt.Sprintf("NICK :%s", newNick), nil) + } + } + + // Change the nickname + target.SetNick(newNick) + + // Notify the user + target.SendMessage(fmt.Sprintf(":%s NICK :%s", oldNick, newNick)) + + // Send snomask to operators + c.server.sendSnomask('o', fmt.Sprintf("%s used SVSNICK to change %s's nickname to %s", + c.Nick(), oldNick, newNick)) + + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Changed nickname of %s to %s", + c.server.config.Server.Name, c.Nick(), oldNick, newNick)) +} + +// handleSvsmode handles SVSMODE command - services force mode change +func (c *Client) handleSvsmode(parts []string) { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + if len(parts) < 3 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "SVSMODE :Not enough parameters") + return + } + + target := parts[1] + modeString := parts[2] + + // Check if target is a channel or user + if strings.HasPrefix(target, "#") || strings.HasPrefix(target, "&") { + // Channel mode change + channel := c.server.GetChannel(target) + if channel == nil { + c.SendNumeric(ERR_NOSUCHCHANNEL, target+" :No such channel") + return + } + + // Apply mode changes (simplified implementation) + channel.BroadcastFrom(c.server.config.Server.Name, fmt.Sprintf("MODE %s %s", target, modeString), nil) + + // Send snomask to operators + c.server.sendSnomask('o', fmt.Sprintf("%s used SVSMODE to set %s on %s", + c.Nick(), modeString, target)) + } else { + // User mode change + targetClient := c.server.GetClient(target) + if targetClient == nil { + c.SendNumeric(ERR_NOSUCHNICK, target+" :No such nick/channel") + return + } + + // Apply user mode changes + targetClient.SendMessage(fmt.Sprintf(":%s MODE %s %s", + c.server.config.Server.Name, target, modeString)) + + // Send snomask to operators + c.server.sendSnomask('o', fmt.Sprintf("%s used SVSMODE to set %s on %s", + c.Nick(), modeString, target)) + } + + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Set mode %s on %s", + c.server.config.Server.Name, c.Nick(), modeString, target)) +} + +// handleSamode handles SAMODE command - services admin mode change +func (c *Client) handleSamode(parts []string) { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + if len(parts) < 3 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "SAMODE :Not enough parameters") + return + } + + target := parts[1] + modeString := parts[2] + args := parts[3:] + + if !strings.HasPrefix(target, "#") && !strings.HasPrefix(target, "&") { + c.SendNumeric(ERR_NOSUCHCHANNEL, target+" :No such channel") + return + } + + channel := c.server.GetChannel(target) + if channel == nil { + c.SendNumeric(ERR_NOSUCHCHANNEL, target+" :No such channel") + return + } + + // Build complete mode command + modeCommand := fmt.Sprintf("MODE %s %s", target, modeString) + if len(args) > 0 { + modeCommand += " " + strings.Join(args, " ") + } + + // Broadcast mode change + channel.BroadcastFrom(c.Prefix(), modeCommand, nil) + + // Send snomask to operators + c.server.sendSnomask('o', fmt.Sprintf("%s used SAMODE: %s", c.Nick(), modeCommand)) +} + +// handleSanick handles SANICK command - services admin nickname change +func (c *Client) handleSanick(parts []string) { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + if len(parts) < 3 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "SANICK :Not enough parameters") + return + } + + targetNick := parts[1] + newNick := parts[2] + + target := c.server.GetClient(targetNick) + if target == nil { + c.SendNumeric(ERR_NOSUCHNICK, targetNick+" :No such nick/channel") + return + } + + // Check if new nick is already in use + if c.server.IsNickInUse(newNick) { + c.SendNumeric(ERR_NICKNAMEINUSE, newNick+" :Nickname is already in use") + return + } + + oldNick := target.Nick() + + // Send NICK change to all channels the user is in + for channelName := range target.channels { + channel := c.server.GetChannel(channelName) + if channel != nil { + channel.BroadcastFrom(target.Prefix(), fmt.Sprintf("NICK :%s", newNick), nil) + } + } + + // Change the nickname + target.SetNick(newNick) + + // Notify the user + target.SendMessage(fmt.Sprintf(":%s NICK :%s", oldNick, newNick)) + target.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Your nickname has been changed by services", + c.server.config.Server.Name, newNick)) + + // Send snomask to operators + c.server.sendSnomask('o', fmt.Sprintf("%s used SANICK to change %s's nickname to %s", + c.Nick(), oldNick, newNick)) + + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Changed nickname of %s to %s", + c.server.config.Server.Name, c.Nick(), oldNick, newNick)) +} + +// handleSakick handles SAKICK command - services admin kick +func (c *Client) handleSakick(parts []string) { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + if len(parts) < 3 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "SAKICK :Not enough parameters") + return + } + + channelName := parts[1] + targetNick := parts[2] + reason := "Services Admin Kick" + + if len(parts) > 3 { + reason = strings.Join(parts[3:], " ") + if strings.HasPrefix(reason, ":") { + reason = reason[1:] + } + } + + channel := c.server.GetChannel(channelName) + if channel == nil { + c.SendNumeric(ERR_NOSUCHCHANNEL, channelName+" :No such channel") + return + } + + target := c.server.GetClient(targetNick) + if target == nil { + c.SendNumeric(ERR_NOSUCHNICK, targetNick+" :No such nick/channel") + return + } + + if !target.IsInChannel(channelName) { + c.SendNumeric(ERR_USERNOTINCHANNEL, targetNick+" "+channelName+" :They aren't on that channel") + return + } + + // Broadcast kick message + kickMsg := fmt.Sprintf("KICK %s %s :%s", channelName, targetNick, reason) + channel.BroadcastFrom(c.Prefix(), kickMsg, nil) + + // Remove user from channel + channel.RemoveClient(target) + target.RemoveChannel(channelName) + + // Send snomask to operators + c.server.sendSnomask('o', fmt.Sprintf("%s used SAKICK to kick %s from %s (%s)", + c.Nick(), targetNick, channelName, reason)) +} + +// handleSapart handles SAPART command - services admin part +func (c *Client) handleSapart(parts []string) { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + if len(parts) < 3 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "SAPART :Not enough parameters") + return + } + + targetNick := parts[1] + channelName := parts[2] + reason := "Services Admin Part" + + if len(parts) > 3 { + reason = strings.Join(parts[3:], " ") + if strings.HasPrefix(reason, ":") { + reason = reason[1:] + } + } + + target := c.server.GetClient(targetNick) + if target == nil { + c.SendNumeric(ERR_NOSUCHNICK, targetNick+" :No such nick/channel") + return + } + + channel := c.server.GetChannel(channelName) + if channel == nil { + c.SendNumeric(ERR_NOSUCHCHANNEL, channelName+" :No such channel") + return + } + + if !target.IsInChannel(channelName) { + c.SendNumeric(ERR_NOTONCHANNEL, channelName+" :You're not on that channel") + return + } + + // Broadcast part message + partMsg := fmt.Sprintf("PART %s :%s", channelName, reason) + channel.BroadcastFrom(target.Prefix(), partMsg, nil) + + // Remove user from channel + channel.RemoveClient(target) + target.RemoveChannel(channelName) + + // Send snomask to operators + c.server.sendSnomask('o', fmt.Sprintf("%s used SAPART to part %s from %s (%s)", + c.Nick(), targetNick, channelName, reason)) + + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Forced %s to part %s", + c.server.config.Server.Name, c.Nick(), targetNick, channelName)) +} + +// handleSajoin handles SAJOIN command - services admin join +func (c *Client) handleSajoin(parts []string) { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + if len(parts) < 3 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "SAJOIN :Not enough parameters") + return + } + + targetNick := parts[1] + channelName := parts[2] + + target := c.server.GetClient(targetNick) + if target == nil { + c.SendNumeric(ERR_NOSUCHNICK, targetNick+" :No such nick/channel") + return + } + + if target.IsInChannel(channelName) { + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** %s is already on %s", + c.server.config.Server.Name, c.Nick(), targetNick, channelName)) + return + } + + // Get or create channel + channel := c.server.GetOrCreateChannel(channelName) + + // Add user to channel + channel.AddClient(target) + target.AddChannel(channel) + + // Broadcast join message + joinMsg := fmt.Sprintf("JOIN :%s", channelName) + channel.BroadcastFrom(target.Prefix(), joinMsg, nil) + + // Send topic if exists + if channel.Topic() != "" { + target.SendNumeric(RPL_TOPIC, channelName+" :"+channel.Topic()) + target.SendNumeric(RPL_TOPICWHOTIME, fmt.Sprintf("%s %s %d", + channelName, channel.TopicBy(), channel.TopicTime().Unix())) + } + + // Send names list + target.sendNames(channel) + + // Send snomask to operators + c.server.sendSnomask('o', fmt.Sprintf("%s used SAJOIN to join %s to %s", + c.Nick(), targetNick, channelName)) + + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Forced %s to join %s", + c.server.config.Server.Name, c.Nick(), targetNick, channelName)) +} + +// handleWhowas handles WHOWAS command +func (c *Client) handleWhowas(parts []string) { + if len(parts) < 2 { + c.SendNumeric(ERR_NONICKNAMEGIVEN, ":No nickname given") + return + } + + nick := parts[1] + + // Send end of whowas (we don't store history) + c.SendNumeric(RPL_ENDOFWHOWAS, nick+" :End of WHOWAS") +} + +// handleMotd handles MOTD command +func (c *Client) handleMotd() { + motd := []string{ + "Welcome to TechIRCd!", + "", + "This is a modern IRC server with IRCv3 features.", + "For help, join #help or contact an operator.", + "", + "Enjoy your stay!", + } + + c.SendNumeric(RPL_MOTDSTART, ":- " + c.server.config.Server.Name + " Message of the day - ") + for _, line := range motd { + c.SendNumeric(RPL_MOTD, ":- " + line) + } + c.SendNumeric(RPL_ENDOFMOTD, ":End of MOTD command") +} + +// handleRules handles RULES command +func (c *Client) handleRules() { + rules := []string{ + "Server Rules:", + "", + "1. Be respectful to other users", + "2. No spamming or flooding", + "3. No harassment or abuse", + "4. Keep channels on-topic", + "5. Follow operator instructions", + "", + "Violations may result in kicks, bans, or K-lines.", + } + + c.SendNumeric(RPL_RULESSTART, ":- " + c.server.config.Server.Name + " server rules:") + for _, line := range rules { + c.SendNumeric(RPL_RULES, ":- " + line) + } + c.SendNumeric(RPL_ENDOFRULES, ":End of RULES command") +} + +// handleMap handles MAP command +func (c *Client) handleMap() { + c.SendNumeric(RPL_MAP, fmt.Sprintf(":%s (Users: %d)", + c.server.config.Server.Name, len(c.server.clients))) + c.SendNumeric(RPL_MAPEND, ":End of MAP") +} + +// handleKnock handles KNOCK command +func (c *Client) handleKnock(parts []string) { + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "KNOCK :Not enough parameters") + return + } + + channelName := parts[1] + message := "has asked for an invite" + if len(parts) > 2 { + message = strings.Join(parts[2:], " ") + } + + c.server.mu.RLock() + channel, exists := c.server.channels[channelName] + c.server.mu.RUnlock() + + if !exists { + c.SendNumeric(ERR_NOSUCHCHANNEL, channelName+" :No such channel") + return + } + + // Check if user is already on channel + if channel.HasClient(c) { + c.SendNumeric(ERR_KNOCKONCHAN, channelName+" :You are already on that channel") + return + } + + // Check if channel is invite-only + if !channel.HasMode('i') { + c.SendNumeric(ERR_CHANOPEN, channelName+" :Channel is open") + return + } + + // Send knock to channel operators + knockMsg := fmt.Sprintf("KNOCK %s :%s (%s@%s) %s", + channelName, c.Nick(), c.User(), c.Host(), message) + + // Broadcast to operators only + clients := channel.GetClients() + for _, client := range clients { + if channel.IsOperator(client) || channel.IsOwner(client) || channel.IsAdmin(client) { + client.SendMessage(":" + c.server.config.Server.Name + " " + knockMsg) + } + } + + c.SendNumeric(RPL_KNOCKDLVR, channelName+" :Your KNOCK has been delivered") +} + +// handleSetname handles SETNAME command (change real name) +func (c *Client) handleSetname(parts []string) { + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "SETNAME :Not enough parameters") + return + } + + newRealname := strings.Join(parts[1:], " ") + if strings.HasPrefix(newRealname, ":") { + newRealname = newRealname[1:] + } + + if len(newRealname) > 50 { + c.SendNumeric(ERR_INVALIDUSERNAME, ":Real name too long") + return + } + + c.SetRealname(newRealname) + + // Broadcast setname to users who can see this client + setnameMsg := fmt.Sprintf("SETNAME :%s", newRealname) + + // Send to all channels user is in + c.server.mu.RLock() + for _, channel := range c.server.channels { + if channel.HasClient(c) { + channel.BroadcastFrom(c.Prefix(), setnameMsg, nil) + } + } + c.server.mu.RUnlock() + + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Your real name is now '%s'", + c.server.config.Server.Name, c.Nick(), newRealname)) +} + +// handleDie handles DIE command (shutdown server) +func (c *Client) handleDie() { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + // Check for die permission + if !c.HasOperPermission("die") { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You need die permission") + return + } + + // Send snomask to operators + c.server.sendSnomask('o', fmt.Sprintf("%s (%s@%s) issued DIE command", + c.Nick(), c.User(), c.Host())) + + // Send notice to all users + dieMsg := fmt.Sprintf(":%s NOTICE * :*** Server shutting down by %s", + c.server.config.Server.Name, c.Nick()) + + c.server.mu.RLock() + for _, client := range c.server.clients { + client.SendMessage(dieMsg) + } + c.server.mu.RUnlock() + + // Shutdown server + go c.server.Shutdown() +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..b5fc8f8 --- /dev/null +++ b/config.go @@ -0,0 +1,407 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "time" +) + +type Config struct { + Server struct { + Name string `json:"name"` + Network string `json:"network"` + Description string `json:"description"` + Version string `json:"version"` + AdminInfo string `json:"admin_info"` + Listen struct { + Host string `json:"host"` + Port int `json:"port"` + SSLPort int `json:"ssl_port"` + EnableSSL bool `json:"enable_ssl"` + } `json:"listen"` + SSL struct { + CertFile string `json:"cert_file"` + KeyFile string `json:"key_file"` + RequireSSL bool `json:"require_ssl"` + } `json:"ssl"` + } `json:"server"` + + Limits struct { + MaxClients int `json:"max_clients"` + MaxChannels int `json:"max_channels"` + MaxChannelUsers int `json:"max_channel_users"` + MaxNickLength int `json:"max_nick_length"` + MaxChannelLength int `json:"max_channel_length"` + MaxTopicLength int `json:"max_topic_length"` + MaxKickLength int `json:"max_kick_length"` + MaxAwayLength int `json:"max_away_length"` + PingTimeout int `json:"ping_timeout"` + RegistrationTimeout int `json:"registration_timeout"` + FloodLines int `json:"flood_lines"` + FloodSeconds int `json:"flood_seconds"` + } `json:"limits"` + + Features struct { + EnableOper bool `json:"enable_oper"` + EnableServices bool `json:"enable_services"` + EnableModes bool `json:"enable_modes"` + EnableCTCP bool `json:"enable_ctcp"` + EnableDCC bool `json:"enable_dcc"` + CaseMapping string `json:"case_mapping"` + } `json:"features"` + + NickChangeNotification struct { + ShowToEveryone bool `json:"to_everyone"` + ShowToOpers bool `json:"to_opers"` + ShowToSelf bool `json:"to_self"` + } `json:"nick_change_notification"` + + Privacy struct { + HideHostsFromUsers bool `json:"hide_hosts_from_users"` + OperBypassHostHide bool `json:"oper_bypass_host_hide"` + MaskedHostSuffix string `json:"masked_host_suffix"` + } `json:"privacy"` + + WhoisFeatures struct { + // Basic information visibility + ShowUserModes struct { + ToEveryone bool `json:"to_everyone"` + ToOpers bool `json:"to_opers"` + ToSelf bool `json:"to_self"` + } `json:"show_user_modes"` + + ShowSSLStatus struct { + ToEveryone bool `json:"to_everyone"` + ToOpers bool `json:"to_opers"` + ToSelf bool `json:"to_self"` + } `json:"show_ssl_status"` + + ShowIdleTime struct { + ToEveryone bool `json:"to_everyone"` + ToOpers bool `json:"to_opers"` + ToSelf bool `json:"to_self"` + } `json:"show_idle_time"` + + ShowSignonTime struct { + ToEveryone bool `json:"to_everyone"` + ToOpers bool `json:"to_opers"` + ToSelf bool `json:"to_self"` + } `json:"show_signon_time"` + + ShowRealHost struct { + ToEveryone bool `json:"to_everyone"` + ToOpers bool `json:"to_opers"` + ToSelf bool `json:"to_self"` + } `json:"show_real_host"` + + ShowChannels struct { + ToEveryone bool `json:"to_everyone"` + ToOpers bool `json:"to_opers"` + ToSelf bool `json:"to_self"` + HideSecret bool `json:"hide_secret_channels"` + HidePrivate bool `json:"hide_private_channels"` + ShowMembership bool `json:"show_membership_levels"` + } `json:"show_channels"` + + ShowOperClass struct { + ToEveryone bool `json:"to_everyone"` + ToOpers bool `json:"to_opers"` + ToSelf bool `json:"to_self"` + } `json:"show_oper_class"` + + ShowClientInfo struct { + ToEveryone bool `json:"to_everyone"` + ToOpers bool `json:"to_opers"` + ToSelf bool `json:"to_self"` + } `json:"show_client_info"` + + ShowAccountName struct { + ToEveryone bool `json:"to_everyone"` + ToOpers bool `json:"to_opers"` + ToSelf bool `json:"to_self"` + } `json:"show_account_name"` + + // Advanced/unique features + ShowActivityStats bool `json:"show_activity_stats"` + ShowGitHubIntegration bool `json:"show_github_integration"` + ShowGeolocation bool `json:"show_geolocation"` + ShowPerformanceStats bool `json:"show_performance_stats"` + ShowDeviceInfo bool `json:"show_device_info"` + ShowSocialGraph bool `json:"show_social_graph"` + ShowSecurityScore bool `json:"show_security_score"` + + // Custom fields + CustomFields []struct { + Name string `json:"name"` + ToEveryone bool `json:"to_everyone"` + ToOpers bool `json:"to_opers"` + ToSelf bool `json:"to_self"` + Format string `json:"format"` + Description string `json:"description"` + } `json:"custom_fields"` + } `json:"whois_features"` + + Channels struct { + DefaultModes string `json:"default_modes"` + AutoJoin []string `json:"auto_join"` + AdminChannels []string `json:"admin_channels"` + FounderMode string `json:"founder_mode"` // Mode given to first user joining a channel: "o", "a", "q" + AllowedModes struct { + Voice bool `json:"voice"` // +v + Halfop bool `json:"halfop"` // +h + Operator bool `json:"operator"` // +o + Admin bool `json:"admin"` // +a + Owner bool `json:"owner"` // +q + } `json:"allowed_modes"` + Modes struct { + BanListSize int `json:"ban_list_size"` + ExceptListSize int `json:"except_list_size"` + InviteListSize int `json:"invite_list_size"` + } `json:"modes"` + } `json:"channels"` + + Opers []struct { + Name string `json:"name"` + Password string `json:"password"` + Host string `json:"host"` + Class string `json:"class"` + Flags []string `json:"flags"` + } `json:"opers"` + + OperConfig struct { + ConfigFile string `json:"config_file"` + Enable bool `json:"enable"` + } `json:"oper_config"` + + MOTD []string `json:"motd"` + + Linking struct { + Enable bool `json:"enable"` + ServerPort int `json:"server_port"` + Password string `json:"password"` + Hub bool `json:"hub"` + AutoConnect bool `json:"auto_connect"` + Links []struct { + Name string `json:"name"` + Host string `json:"host"` + Port int `json:"port"` + Password string `json:"password"` + AutoConnect bool `json:"auto_connect"` + Hub bool `json:"hub"` + Description string `json:"description"` + } `json:"links"` + } `json:"linking"` + + Logging struct { + Level string `json:"level"` + File string `json:"file"` + MaxSize int `json:"max_size"` + MaxBackups int `json:"max_backups"` + MaxAge int `json:"max_age"` + } `json:"logging"` +} + +func LoadConfig(filename string) (*Config, error) { + data, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %v", err) + } + + var config Config + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse config file: %v", err) + } + + return &config, nil +} + +func (c *Config) PingTimeoutDuration() time.Duration { + return time.Duration(c.Limits.PingTimeout) * time.Second +} + +func (c *Config) RegistrationTimeoutDuration() time.Duration { + return time.Duration(c.Limits.RegistrationTimeout) * time.Second +} + +func DefaultConfig() *Config { + return &Config{ + Server: struct { + Name string `json:"name"` + Network string `json:"network"` + Description string `json:"description"` + Version string `json:"version"` + AdminInfo string `json:"admin_info"` + Listen struct { + Host string `json:"host"` + Port int `json:"port"` + SSLPort int `json:"ssl_port"` + EnableSSL bool `json:"enable_ssl"` + } `json:"listen"` + SSL struct { + CertFile string `json:"cert_file"` + KeyFile string `json:"key_file"` + RequireSSL bool `json:"require_ssl"` + } `json:"ssl"` + }{ + Name: "TechIRCd", + Network: "TechNet", + Description: "A modern IRC server written in Go", + Version: "1.0.0", + AdminInfo: "admin@example.com", + Listen: struct { + Host string `json:"host"` + Port int `json:"port"` + SSLPort int `json:"ssl_port"` + EnableSSL bool `json:"enable_ssl"` + }{ + Host: "localhost", + Port: 6667, + SSLPort: 6697, + EnableSSL: false, + }, + SSL: struct { + CertFile string `json:"cert_file"` + KeyFile string `json:"key_file"` + RequireSSL bool `json:"require_ssl"` + }{ + CertFile: "server.crt", + KeyFile: "server.key", + RequireSSL: false, + }, + }, + Limits: struct { + MaxClients int `json:"max_clients"` + MaxChannels int `json:"max_channels"` + MaxChannelUsers int `json:"max_channel_users"` + MaxNickLength int `json:"max_nick_length"` + MaxChannelLength int `json:"max_channel_length"` + MaxTopicLength int `json:"max_topic_length"` + MaxKickLength int `json:"max_kick_length"` + MaxAwayLength int `json:"max_away_length"` + PingTimeout int `json:"ping_timeout"` + RegistrationTimeout int `json:"registration_timeout"` + FloodLines int `json:"flood_lines"` + FloodSeconds int `json:"flood_seconds"` + }{ + MaxClients: 1000, + MaxChannels: 100, + MaxChannelUsers: 500, + MaxNickLength: 30, + MaxChannelLength: 50, + MaxTopicLength: 307, + MaxKickLength: 307, + MaxAwayLength: 307, + PingTimeout: 300, + RegistrationTimeout: 60, + FloodLines: 20, + FloodSeconds: 10, + }, + Features: struct { + EnableOper bool `json:"enable_oper"` + EnableServices bool `json:"enable_services"` + EnableModes bool `json:"enable_modes"` + EnableCTCP bool `json:"enable_ctcp"` + EnableDCC bool `json:"enable_dcc"` + CaseMapping string `json:"case_mapping"` + }{ + EnableOper: true, + EnableServices: false, + EnableModes: true, + EnableCTCP: true, + EnableDCC: false, + CaseMapping: "rfc1459", + }, + Channels: struct { + DefaultModes string `json:"default_modes"` + AutoJoin []string `json:"auto_join"` + AdminChannels []string `json:"admin_channels"` + FounderMode string `json:"founder_mode"` // Mode given to first user joining a channel: "o", "a", "q" + AllowedModes struct { + Voice bool `json:"voice"` // +v + Halfop bool `json:"halfop"` // +h + Operator bool `json:"operator"` // +o + Admin bool `json:"admin"` // +a + Owner bool `json:"owner"` // +q + } `json:"allowed_modes"` + Modes struct { + BanListSize int `json:"ban_list_size"` + ExceptListSize int `json:"except_list_size"` + InviteListSize int `json:"invite_list_size"` + } `json:"modes"` + }{ + DefaultModes: "+nt", + AutoJoin: []string{"#general"}, + AdminChannels: []string{"#admin"}, + FounderMode: "o", // Default to operator mode for channel founders + AllowedModes: struct { + Voice bool `json:"voice"` + Halfop bool `json:"halfop"` + Operator bool `json:"operator"` + Admin bool `json:"admin"` + Owner bool `json:"owner"` + }{ + Voice: true, + Halfop: true, + Operator: true, + Admin: true, + Owner: true, + }, + Modes: struct { + BanListSize int `json:"ban_list_size"` + ExceptListSize int `json:"except_list_size"` + InviteListSize int `json:"invite_list_size"` + }{ + BanListSize: 100, + ExceptListSize: 100, + InviteListSize: 100, + }, + }, + Opers: []struct { + Name string `json:"name"` + Password string `json:"password"` + Host string `json:"host"` + Class string `json:"class"` + Flags []string `json:"flags"` + }{ + { + Name: "admin", + Password: "changeme", + Host: "*@localhost", + Class: "admin", + Flags: []string{"global_kill", "remote", "connect", "squit"}, + }, + }, + MOTD: []string{ + "Welcome to TechIRCd!", + "A modern IRC server written in Go", + "Enjoy your stay on TechNet!", + }, + Logging: struct { + Level string `json:"level"` + File string `json:"file"` + MaxSize int `json:"max_size"` + MaxBackups int `json:"max_backups"` + MaxAge int `json:"max_age"` + }{ + Level: "info", + File: "techircd.log", + MaxSize: 100, + MaxBackups: 3, + MaxAge: 28, + }, + } +} + +func SaveConfig(config *Config, filename string) error { + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %v", err) + } + + if err := os.WriteFile(filename, data, 0644); err != nil { + return fmt.Errorf("failed to write config file: %v", err) + } + + return nil +} diff --git a/config.json b/config.json new file mode 100644 index 0000000..42a18cf --- /dev/null +++ b/config.json @@ -0,0 +1,163 @@ +{ + "server": { + "name": "TechIRCd", + "network": "TechNet", + "description": "A modern IRC server written in Go", + "version": "1.0.0", + "admin_info": "admin@example.com", + "listen": { + "host": "localhost", + "port": 6667, + "ssl_port": 6697, + "enable_ssl": false + }, + "ssl": { + "cert_file": "server.crt", + "key_file": "server.key", + "require_ssl": false + } + }, + "limits": { + "max_clients": 1000, + "max_channels": 100, + "max_channel_users": 500, + "max_nick_length": 30, + "max_channel_length": 50, + "max_topic_length": 307, + "max_kick_length": 307, + "max_away_length": 307, + "ping_timeout": 300, + "registration_timeout": 60, + "flood_lines": 20, + "flood_seconds": 10 + }, + "features": { + "enable_oper": true, + "enable_services": false, + "enable_modes": true, + "enable_ctcp": true, + "enable_dcc": false, + "case_mapping": "rfc1459" + }, + "nick_change_notification": { + "to_everyone": true, + "to_opers": true, + "to_self": true + }, + "channels": { + "default_modes": "+nt", + "auto_join": [ + "#general" + ], + "admin_channels": [ + "#admin" + ], + "founder_mode": "o", + "allowed_modes": { + "voice": true, + "halfop": true, + "operator": true, + "admin": true, + "owner": true + }, + "modes": { + "ban_list_size": 100, + "except_list_size": 100, + "invite_list_size": 100 + } + }, + "opers": [ + { + "name": "admin", + "password": "changeme", + "host": "*@localhost", + "class": "admin", + "flags": [ + "global_kill", + "remote", + "connect", + "squit" + ] + } + ], + "linking": { + "enable": true, + "server_port": 6697, + "password": "linkpassword123", + "hub": false, + "auto_connect": false, + "links": [ + { + "name": "hub.technet.org", + "host": "127.0.0.1", + "port": 6697, + "password": "linkpassword123", + "auto_connect": false, + "hub": true, + "description": "TechNet Hub Server" + } + ] + }, + "whois_features": { + "show_user_modes": { + "to_everyone": false, + "to_opers": true, + "to_self": true + }, + "show_ssl_status": { + "to_everyone": true, + "to_opers": true, + "to_self": true + }, + "show_idle_time": { + "to_everyone": false, + "to_opers": true, + "to_self": true + }, + "show_signon_time": { + "to_everyone": false, + "to_opers": true, + "to_self": true + }, + "show_real_host": { + "to_everyone": false, + "to_opers": true, + "to_self": true + }, + "show_channels": { + "to_everyone": true, + "to_opers": true, + "to_self": true, + "hide_secret_channels": true, + "hide_private_channels": false, + "show_membership_levels": true + }, + "show_oper_class": { + "to_everyone": false, + "to_opers": true, + "to_self": true + }, + "show_client_info": { + "to_everyone": false, + "to_opers": true, + "to_self": false + }, + "show_account_name": { + "to_everyone": true, + "to_opers": true, + "to_self": true + } + }, + "motd": [ + "Welcome to TechIRCd!", + "A modern IRC server written in Go", + "Enjoy your stay on TechNet!" + ], + "logging": { + "level": "info", + "file": "techircd.log", + "max_size": 100, + "max_backups": 3, + "max_age": 28 + } +} \ No newline at end of file diff --git a/configs/config.json b/configs/config.json new file mode 100644 index 0000000..db53269 --- /dev/null +++ b/configs/config.json @@ -0,0 +1,150 @@ +{ + "server": { + "name": "TechIRCd", + "network": "TechNet", + "description": "A modern IRC server written in Go", + "version": "1.0.0", + "admin_info": "admin@example.com", + "listen": { + "host": "localhost", + "port": 6667, + "ssl_port": 6697, + "enable_ssl": false + }, + "ssl": { + "cert_file": "server.crt", + "key_file": "server.key", + "require_ssl": false + } + }, + "limits": { + "max_clients": 1000, + "max_channels": 100, + "max_channel_users": 500, + "max_nick_length": 30, + "max_channel_length": 50, + "max_topic_length": 307, + "max_kick_length": 307, + "max_away_length": 307, + "ping_timeout": 300, + "registration_timeout": 60, + "flood_lines": 10, + "flood_seconds": 60 + }, + "features": { + "enable_oper": true, + "enable_services": false, + "enable_modes": true, + "enable_ctcp": true, + "enable_dcc": false, + "case_mapping": "rfc1459" + }, + "privacy": { + "hide_hosts_from_users": true, + "oper_bypass_host_hide": true, + "masked_host_suffix": "users.technet" + }, + "whois_features": { + "show_user_modes": { + "to_everyone": false, + "to_opers": true, + "to_self": true + }, + "show_ssl_status": { + "to_everyone": true, + "to_opers": true, + "to_self": true + }, + "show_idle_time": { + "to_everyone": false, + "to_opers": true, + "to_self": true + }, + "show_signon_time": { + "to_everyone": false, + "to_opers": true, + "to_self": true + }, + "show_real_host": { + "to_everyone": false, + "to_opers": true, + "to_self": true + }, + "show_channels": { + "to_everyone": true, + "to_opers": true, + "to_self": true, + "hide_secret_channels": true, + "hide_private_channels": false, + "show_membership_levels": true + }, + "show_oper_class": { + "to_everyone": false, + "to_opers": true, + "to_self": true + }, + "show_client_info": { + "to_everyone": false, + "to_opers": true, + "to_self": false + }, + "show_account_name": { + "to_everyone": true, + "to_opers": true, + "to_self": true + }, + "show_activity_stats": false, + "show_github_integration": false, + "show_geolocation": false, + "show_performance_stats": false, + "show_device_info": false, + "show_social_graph": false, + "show_security_score": false, + "custom_fields": [] + }, + "channels": { + "default_modes": "+nt", + "auto_join": [ + "#general" + ], + "admin_channels": [ + "#admin" + ], + "founder_mode": "o", + "modes": { + "ban_list_size": 100, + "except_list_size": 100, + "invite_list_size": 100 + } + }, + "opers": [ + { + "name": "admin", + "password": "your_secure_password_here", + "host": "*@localhost", + "class": "admin", + "flags": [ + "global_kill", + "remote", + "connect", + "squit" + ] + } + ], + "oper_config": { + "config_file": "configs/opers.conf", + "enable": true + }, + "motd": [ + "Welcome to TechIRCd!", + "A modern IRC server written in Go", + "Enjoy your stay on TechNet!" + ], + "logging": { + "level": "info", + "file": "techircd.log", + "max_size": 100, + "max_backups": 3, + "max_age": 28 + } +} \ No newline at end of file diff --git a/configs/opers.conf b/configs/opers.conf new file mode 100644 index 0000000..ff89cdd --- /dev/null +++ b/configs/opers.conf @@ -0,0 +1,117 @@ +{ + "rank_names": { + "rank_1": "Helper", + "rank_2": "Moderator", + "rank_3": "Operator", + "rank_4": "Administrator", + "rank_5": "Owner" + }, + "classes": [ + { + "name": "helper", + "rank": 1, + "description": "Helper - Basic moderation commands", + "permissions": [ + "kick", + "topic", + "mode_channel" + ], + "color": "green", + "symbol": "%" + }, + { + "name": "moderator", + "rank": 2, + "description": "Moderator - Channel and user management", + "permissions": [ + "ban", + "unban", + "kick", + "mute", + "topic", + "mode_channel", + "mode_user", + "who_override" + ], + "inherits": "helper", + "color": "blue", + "symbol": "@" + }, + { + "name": "operator", + "rank": 3, + "description": "Operator - Server management commands", + "permissions": [ + "kill", + "rehash", + "connect", + "squit", + "wallops", + "operwall" + ], + "inherits": "moderator", + "color": "red", + "symbol": "*" + }, + { + "name": "admin", + "rank": 4, + "description": "Administrator - Full server control", + "permissions": ["*"], + "color": "purple", + "symbol": "&" + }, + { + "name": "owner", + "rank": 5, + "description": "Server Owner - Ultimate authority", + "permissions": [ + "*", + "shutdown", + "restart" + ], + "color": "gold", + "symbol": "~" + } + ], + "opers": [ + { + "name": "admin", + "password": "changeme_please", + "host": "*@localhost", + "class": "admin", + "flags": [], + "max_clients": 1000, + "contact": "admin@example.com", + "last_seen": "" + }, + { + "name": "helper1", + "password": "helper_password", + "host": "*@192.168.1.*", + "class": "helper", + "flags": [], + "max_clients": 100, + "contact": "helper@example.com", + "last_seen": "" + } + ], + "settings": { + "require_ssl": false, + "max_failed_attempts": 3, + "lockout_duration_minutes": 30, + "allowed_commands": [ + "OPER", + "KILL", + "REHASH", + "WALLOPS", + "OPERWALL", + "CONNECT", + "SQUIT" + ], + "log_oper_actions": true, + "notify_on_oper_up": true, + "auto_expire_inactive_days": 365, + "require_two_factor": false + } +} diff --git a/configs/services.json b/configs/services.json new file mode 100644 index 0000000..9e2f485 --- /dev/null +++ b/configs/services.json @@ -0,0 +1,53 @@ +{ + "enable": true, + "nickserv": { + "enable": true, + "nick": "NickServ", + "user": "services", + "host": "services.techircd.net", + "realname": "Nickname Registration Service", + "expire_time": 30, + "identify_timeout": 60, + "email_verify": false, + "restrict_registration": false + }, + "chanserv": { + "enable": true, + "nick": "ChanServ", + "user": "services", + "host": "services.techircd.net", + "realname": "Channel Registration Service", + "expire_time": 30, + "max_channels": 20, + "auto_deop": true + }, + "operserv": { + "enable": true, + "nick": "OperServ", + "user": "services", + "host": "services.techircd.net", + "realname": "Operator Service" + }, + "memoserv": { + "enable": true, + "nick": "MemoServ", + "user": "services", + "host": "services.techircd.net", + "realname": "Memo Service", + "max_memos": 50, + "memo_expire": 365 + }, + "database": { + "enable": true, + "type": "mysql", + "database": "techircd_services", + "host": "localhost", + "port": 3306, + "username": "techircd", + "password": "changeme", + "options": { + "charset": "utf8mb4", + "parseTime": "true" + } + } +} diff --git a/database.go b/database.go new file mode 100644 index 0000000..5ae6807 --- /dev/null +++ b/database.go @@ -0,0 +1,100 @@ +package main + +import ( + "database/sql" + "time" +) + +// Database layer for persistent storage +type DatabaseConfig struct { + Type string `json:"type"` // sqlite, mysql, postgres + Host string `json:"host"` + Port int `json:"port"` + Database string `json:"database"` + Username string `json:"username"` + Password string `json:"password"` + Options map[string]string `json:"options"` +} + +type Database struct { + db *sql.DB + config DatabaseConfig +} + +// User account storage +type UserAccount struct { + ID int `db:"id"` + Nick string `db:"nick"` + PasswordHash string `db:"password_hash"` + Email string `db:"email"` + RegisterTime time.Time `db:"register_time"` + LastSeen time.Time `db:"last_seen"` + Flags []string `db:"flags"` +} + +// Channel registration +type RegisteredChannel struct { + ID int `db:"id"` + Name string `db:"name"` + Founder string `db:"founder"` + RegisterTime time.Time `db:"register_time"` + Topic string `db:"topic"` + Modes string `db:"modes"` + AccessList []ChannelAccess `db:"-"` +} + +type ChannelAccess struct { + Nick string `db:"nick"` + Level int `db:"level"` // 1=voice, 10=halfop, 50=op, 100=admin, 500=founder +} + +// Persistent bans/quiets +type NetworkBan struct { + ID int `db:"id"` + Mask string `db:"mask"` + Reason string `db:"reason"` + SetBy string `db:"set_by"` + SetTime time.Time `db:"set_time"` + Duration int `db:"duration"` // 0 = permanent + Type string `db:"type"` // gline, kline, zline, qline +} + +// Statistics tracking +type ServerStats struct { + Date time.Time `db:"date"` + MaxUsers int `db:"max_users"` + MaxChannels int `db:"max_channels"` + TotalConnections int64 `db:"total_connections"` + MessageCount int64 `db:"message_count"` + OperCount int `db:"oper_count"` +} + +func NewDatabase(config DatabaseConfig) (*Database, error) { + // Initialize database connection + return &Database{config: config}, nil +} + +func (db *Database) CreateTables() error { + // Create all necessary tables + return nil +} + +func (db *Database) GetUserAccount(nick string) (*UserAccount, error) { + // Retrieve user account + return nil, nil +} + +func (db *Database) SaveUserAccount(account *UserAccount) error { + // Save user account + return nil +} + +func (db *Database) GetChannelAccess(channel string) ([]ChannelAccess, error) { + // Get channel access list + return nil, nil +} + +func (db *Database) LogStatistics(stats ServerStats) error { + // Log daily statistics + return nil +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 0000000..abe72d2 --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to TechIRCd will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2025-07-30 + +### Added +- Initial release of TechIRCd +- Full IRC protocol implementation (RFC 2812 compliant) +- Advanced channel management with operator hierarchy +- Extended ban system with quiet mode support (~q:mask) +- Comprehensive operator features and SNOmasks +- User modes system with SSL detection +- Enhanced stability with panic recovery +- Real-time health monitoring and metrics +- Configuration validation and sanitization +- Graceful shutdown capabilities +- Flood protection with operator exemption + +### Features +- Channel modes: +m +n +t +i +s +p +k +l +b +- User modes: +i +w +s +o +x +B +z +r +- Operator commands: KILL, GLOBALNOTICE, OPERWALL, WALLOPS, REHASH, TRACE +- SNOmasks: +c +k +o +x +f +n +s +d +- Extended ban types with quiet mode support +- Health monitoring with memory and goroutine tracking +- Comprehensive error handling and recovery systems + +[1.0.0]: https://github.com/ComputerTech312/TechIRCd/releases/tag/v1.0.0 diff --git a/docs/CONNECTION_HANDLING.md b/docs/CONNECTION_HANDLING.md new file mode 100644 index 0000000..b7f4fa6 --- /dev/null +++ b/docs/CONNECTION_HANDLING.md @@ -0,0 +1,165 @@ +# TechIRCd Connection Handling Improvements + +## Overview +TechIRCd now features **robust connection handling** designed to handle real-world network conditions, client behaviors, and edge cases gracefully. + +## Key Improvements + +### 🛡️ **Robust Client Connection Handling** + +#### **1. Comprehensive Error Recovery** +- **Panic Recovery**: All client handlers have panic recovery with detailed logging +- **Graceful Cleanup**: Proper resource cleanup even when connections fail +- **Connection Validation**: Validates connections and server state before processing + +#### **2. Smart Timeout Management** +- **Registration Timeout**: 60 seconds for initial registration (configurable) +- **Read Timeouts**: 5 minutes for registered clients, 30 seconds during registration +- **Progressive Timeouts**: More lenient timeouts for new connections + +#### **3. Enhanced Message Processing** +- **Binary Data Detection**: Detects and rejects non-text data +- **Size Limits**: Truncates oversized messages (512 bytes max) +- **Empty Line Handling**: Gracefully ignores empty lines +- **Encoding Validation**: Checks for valid IRC characters + +#### **4. Intelligent Flood Protection** +- **Registration Leniency**: More generous limits during connection setup +- **Sliding Window**: Proper flood detection with time windows +- **Configurable Limits**: Respects server configuration settings + +### 🔧 **Server Connection Management** + +#### **1. Connection Limits** +- **Max Client Enforcement**: Rejects connections when server is full +- **Resource Protection**: Prevents server overload +- **Graceful Rejection**: Sends proper error message before closing + +#### **2. Accept Error Handling** +- **Temporary Error Recovery**: Handles temporary network errors +- **Retry Logic**: Continues accepting after temporary failures +- **Error Classification**: Different handling for different error types + +#### **3. Client State Tracking** +- **Connection Counting**: Real-time client count tracking +- **State Validation**: Ensures clients are properly initialized +- **Resource Monitoring**: Tracks and logs connection statistics + +### 📊 **Enhanced Logging and Debugging** + +#### **1. Detailed Connection Logging** +``` +2025/09/03 19:02:44 New client connected from 46.24.193.29:60237 (total: 1) +2025/09/03 19:02:44 Starting robust client handler for 46.24.193.29:60237 +2025/09/03 19:02:44 Client 46.24.193.29:60237: CAP LS 302 +2025/09/03 19:02:44 Client 46.24.193.29:60237: NICK TestUser +2025/09/03 19:02:44 Client registration completed: TestUser (testuser) from 46.24.193.29:60237 +``` + +#### **2. Error Context** +- **Connection Source**: Always logs client IP/port +- **Error Details**: Specific error messages for troubleshooting +- **State Information**: Logs registration status and timing + +#### **3. Performance Metrics** +- **Connection Duration**: Tracks how long connections last +- **Message Counts**: Monitors message frequency +- **Registration Time**: Measures time to complete registration + +### 🔒 **Security Enhancements** + +#### **1. Protocol Validation** +- **Command Validation**: Ensures only valid IRC commands are processed +- **Data Sanitization**: Removes dangerous or malformed input +- **Buffer Management**: Prevents buffer overflow attacks + +#### **2. Connection Security** +- **Rate Limiting**: Prevents rapid connection attempts +- **Resource Limits**: Protects against resource exhaustion +- **Input Validation**: Validates all client input before processing + +### 🚀 **Real-World Compatibility** + +#### **1. IRC Client Support** +- **HexChat Compatible**: Tested with popular IRC clients +- **IRCv3 Support**: Handles capability negotiation properly +- **Standard Compliance**: Follows RFC 2812 standards + +#### **2. Network Conditions** +- **Slow Connections**: Handles high-latency connections +- **Unstable Networks**: Recovers from temporary network issues +- **Mobile Clients**: Optimized for mobile network conditions + +#### **3. Edge Cases** +- **Partial Messages**: Handles incomplete message transmission +- **Connection Drops**: Graceful handling of sudden disconnections +- **Server Restarts**: Proper cleanup during server shutdown + +## Configuration Options + +### **Timeout Settings** +```json +{ + "limits": { + "registration_timeout": 60, + "ping_timeout": 300, + "flood_lines": 10, + "flood_seconds": 60 + } +} +``` + +### **Connection Limits** +```json +{ + "limits": { + "max_clients": 1000, + "max_channels": 100 + } +} +``` + +## Benefits + +### **For Users** +- ✅ **Reliable Connections**: Less likely to drop due to network issues +- ✅ **Faster Registration**: Optimized registration process +- ✅ **Better Error Messages**: Clear feedback when issues occur +- ✅ **Mobile Friendly**: Works well on mobile networks + +### **For Administrators** +- ✅ **Detailed Logging**: Easy troubleshooting with comprehensive logs +- ✅ **Resource Protection**: Server stays stable under load +- ✅ **Security**: Protection against malformed data and attacks +- ✅ **Monitoring**: Real-time connection statistics + +### **For Developers** +- ✅ **Code Clarity**: Well-structured connection handling code +- ✅ **Error Recovery**: Robust error handling prevents crashes +- ✅ **Debugging**: Extensive logging for issue resolution +- ✅ **Maintainability**: Clean separation of concerns + +## Testing Recommendations + +### **Basic Connection Test** +```bash +# Test with telnet +telnet your-server.com 6667 + +# Send IRC commands +NICK TestUser +USER testuser 0 * :Test User +``` + +### **HexChat Configuration** +- **Server**: your-server.com +- **Port**: 6667 (or 6697 for SSL) +- **SSL**: Disabled (unless you have SSL configured) +- **Character Set**: UTF-8 + +### **Load Testing** +- Test with multiple simultaneous connections +- Try rapid connect/disconnect cycles +- Test with slow network conditions + +TechIRCd now provides **enterprise-grade connection handling** suitable for production IRC networks! diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..60ea06f --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,65 @@ +# Contributing to TechIRCd + +We love your input! We want to make contributing to TechIRCd as easy and transparent as possible. + +## Development Process + +We use GitHub to host code, to track issues and feature requests, as well as accept pull requests. + +## Pull Requests + +1. Fork the repo and create your branch from `main`. +2. If you've added code that should be tested, add tests. +3. If you've changed APIs, update the documentation. +4. Ensure the test suite passes. +5. Make sure your code lints. +6. Issue that pull request! + +## Code Style + +- Follow standard Go formatting (`gofmt`) +- Use meaningful variable and function names +- Add comments for exported functions and complex logic +- Keep functions focused and single-purpose + +## Testing + +```bash +# Run all tests +go test ./... + +# Run tests with coverage +go test -cover ./... + +# Run tests with race detection +go test -race ./... +``` + +## Commit Messages + +- Use the present tense ("Add feature" not "Added feature") +- Use the imperative mood ("Move cursor to..." not "Moves cursor to...") +- Limit the first line to 72 characters or less +- Reference issues and pull requests liberally after the first line + +## Bug Reports + +**Great Bug Reports** tend to have: + +- A quick summary and/or background +- Steps to reproduce +- What you expected would happen +- What actually happens +- Notes (possibly including why you think this might be happening) + +## Feature Requests + +We welcome feature requests! Please provide: + +- Clear description of the feature +- Use case or motivation +- Possible implementation approach (if you have ideas) + +## License + +By contributing, you agree that your contributions will be licensed under the MIT License. diff --git a/docs/ENHANCEMENT_ROADMAP.md b/docs/ENHANCEMENT_ROADMAP.md new file mode 100644 index 0000000..3dc668b --- /dev/null +++ b/docs/ENHANCEMENT_ROADMAP.md @@ -0,0 +1,174 @@ +# TechIRCd Enhancement Roadmap + +## 🚀 Phase 1: Core Improvements (High Priority) + +### 1.1 Missing IRC Commands (2-3 weeks) +- [ ] USERHOST command +- [ ] ISON command +- [ ] STATS command with detailed server statistics +- [ ] TIME command +- [ ] VERSION command +- [ ] ADMIN command +- [ ] INFO command +- [ ] LUSERS command +- [ ] Enhanced LIST with pattern matching +- [ ] SILENCE command for user-level blocking + +### 1.2 Channel Mode Enhancements (1-2 weeks) +- [ ] Ban exceptions (+e mode) +- [ ] Invite exceptions (+I mode) +- [ ] Permanent channels (+P mode) +- [ ] Registered-only channels (+r mode) +- [ ] SSL-only channels (+z mode) +- [ ] Channel forwarding (+f mode) +- [ ] Founder protection mode (~q) +- [ ] Admin protection mode (&a) + +### 1.3 Security & Authentication (2-3 weeks) +- [ ] SASL authentication support +- [ ] Certificate-based authentication +- [ ] Rate limiting per IP/user +- [ ] GeoIP blocking capabilities +- [ ] DDoS protection mechanisms +- [ ] Enhanced flood protection +- [ ] Connection throttling + +## 🌐 Phase 2: Network & Scaling (Medium Priority) + +### 2.1 Enhanced Server Linking (3-4 weeks) +- [ ] Burst optimization for large networks +- [ ] Network topology visualization +- [ ] Automatic failover and recovery +- [ ] Load balancing across servers +- [ ] Network-wide channel/user sync +- [ ] Cluster management interface +- [ ] Health monitoring between servers + +### 2.2 Database Integration (2-3 weeks) +- [ ] SQLite/MySQL/PostgreSQL support +- [ ] User account persistence +- [ ] Channel registration system +- [ ] Network-wide ban storage +- [ ] Statistics logging +- [ ] Audit trail logging +- [ ] Data migration tools + +### 2.3 Services Framework (4-5 weeks) +- [ ] NickServ implementation +- [ ] ChanServ implementation +- [ ] OperServ implementation +- [ ] MemoServ implementation +- [ ] BotServ implementation +- [ ] Services API framework +- [ ] Plugin system for custom services + +## 📊 Phase 3: Advanced Features (Lower Priority) + +### 3.1 Monitoring & Analytics (3-4 weeks) +- [ ] Prometheus metrics integration +- [ ] Grafana dashboard templates +- [ ] Real-time performance monitoring +- [ ] User activity analytics +- [ ] Channel analytics +- [ ] Alert system with notifications +- [ ] Performance profiling tools +- [ ] Capacity planning metrics + +### 3.2 Web Interface (4-5 weeks) +- [ ] Administrative web panel +- [ ] REST API endpoints +- [ ] GraphQL query interface +- [ ] Real-time dashboard with WebSockets +- [ ] Mobile-responsive design +- [ ] User management interface +- [ ] Channel management interface +- [ ] Network topology viewer +- [ ] Log viewer and search + +### 3.3 Modern Protocol Support (3-4 weeks) +- [ ] IRCv3 capabilities +- [ ] Message tags support +- [ ] Account tracking +- [ ] MONITOR command +- [ ] Extended JOIN +- [ ] CHGHOST support +- [ ] Batch processing +- [ ] Server-time capability + +## 🎨 Phase 4: Quality of Life (Ongoing) + +### 4.1 Developer Experience +- [ ] Comprehensive test suite (>80% coverage) +- [ ] CI/CD pipeline setup +- [ ] Automated security scanning +- [ ] Performance benchmarking +- [ ] Documentation improvements +- [ ] Code quality tools integration +- [ ] Dependency management + +### 4.2 Deployment & Operations +- [ ] Docker containerization +- [ ] Kubernetes manifests +- [ ] Helm charts +- [ ] Configuration management +- [ ] Backup and restore tools +- [ ] Migration utilities +- [ ] Health check endpoints + +### 4.3 Community Features +- [ ] Plugin architecture +- [ ] Extension API +- [ ] Custom command framework +- [ ] Event system +- [ ] Webhook integrations +- [ ] Third-party service connectors + +## 🔧 Technical Debt & Improvements + +### Code Structure +- [ ] Proper package structure (remove `package main` everywhere) +- [ ] Interface definitions for better testing +- [ ] Dependency injection framework +- [ ] Configuration validation improvements +- [ ] Error handling standardization +- [ ] Logging framework upgrade + +### Performance Optimizations +- [ ] Connection pooling +- [ ] Message batching +- [ ] Memory optimization +- [ ] CPU profiling and optimization +- [ ] Network I/O improvements +- [ ] Concurrent processing enhancements + +### Security Hardening +- [ ] Input validation improvements +- [ ] Buffer overflow protection +- [ ] Memory safety audits +- [ ] Cryptographic improvements +- [ ] Secure defaults +- [ ] Vulnerability scanning + +## 📈 Implementation Timeline + +**Total Estimated Time: 6-8 months** + +- **Month 1-2**: Phase 1 (Core Improvements) +- **Month 3-4**: Phase 2 (Network & Scaling) +- **Month 5-6**: Phase 3 (Advanced Features) +- **Month 7-8**: Phase 4 (Quality of Life) + Polish + +## 🎯 Quick Wins (Can implement immediately) + +1. **USERHOST command** (1 day) +2. **ISON command** (1 day) +3. **TIME command** (1 day) +4. **VERSION command** (1 day) +5. **Enhanced LIST filtering** (2-3 days) +6. **Ban exceptions (+e mode)** (2-3 days) +7. **Invite exceptions (+I mode)** (2-3 days) +8. **Basic SASL PLAIN** (3-4 days) +9. **Prometheus metrics** (3-4 days) +10. **Basic web stats API** (2-3 days) + +These improvements would make TechIRCd one of the most feature-complete and modern IRC servers available! diff --git a/docs/GOD_MODE_STEALTH.md b/docs/GOD_MODE_STEALTH.md new file mode 100644 index 0000000..418b6d0 --- /dev/null +++ b/docs/GOD_MODE_STEALTH.md @@ -0,0 +1,176 @@ +# God Mode and Stealth Mode + +TechIRCd supports two advanced operator features via user modes: + +## God Mode (+G) + +God Mode gives operators ultimate channel override capabilities. + +### Usage +``` +/mode nickname +G # Enable God Mode +/mode nickname -G # Disable God Mode +``` + +### Capabilities +- **Channel Access**: Can join any channel regardless of bans, limits, keys, or invite-only status +- **Kick Immunity**: Cannot be kicked from channels by anyone, including other operators +- **Ban Immunity**: Can join and remain in channels even when banned +- **Invite Override**: Can join invite-only channels without being invited +- **Limit Override**: Can join channels that have reached their user limit +- **Key Override**: Can join channels with passwords/keys without providing them + +### Requirements +- Must be an IRC operator (`/oper`) +- Must have `god_mode` permission in operator class configuration +- Only the user themselves can set/unset their God Mode + +### Security Notes +- God Mode actions are logged to operator snomasks (`+o`) +- Use responsibly - this bypasses all normal channel protections +- Intended for emergency situations and network administration + +## Stealth Mode (+S) + +Stealth Mode makes operators invisible to regular users while remaining visible to other operators. + +### Usage +``` +/mode nickname +S # Enable Stealth Mode +/mode nickname -S # Disable Stealth Mode +``` + +### Effects +- **WHO Command**: Stealth users don't appear in `/who` results for regular users +- **NAMES Command**: Stealth users don't appear in channel user lists for regular users +- **Channel Lists**: Regular users can't see stealth operators in channels +- **Operator Visibility**: Other operators can always see stealth users + +### Requirements +- Must be an IRC operator (`/oper`) +- Must have `stealth_mode` permission in operator class configuration +- Only the user themselves can set/unset their Stealth Mode + +### Use Cases +- Undercover moderation and monitoring +- Reduced operator visibility during investigations +- Network administration without user awareness + +## Configuration + +Add permissions to operator classes in `configs/opers.conf`: + +```json +{ + "classes": [ + { + "name": "admin", + "rank": 4, + "description": "Administrator with special powers", + "permissions": [ + "*", + "god_mode", + "stealth_mode" + ] + } + ] +} +``` + +## Examples + +### Basic Usage +``` +# As an operator with god_mode permission: +/mode mynick +G +# *** GOD MODE enabled - You have ultimate power! + +# Join a banned/invite-only channel: +/join #private-channel +# Successfully joins despite restrictions + +# Disable God Mode: +/mode mynick -G +# *** GOD MODE disabled + +# Enable Stealth Mode: +/mode mynick +S +# *** STEALTH MODE enabled - You are now invisible to users + +# Regular users won't see you in: +/who #channel +/names #channel + +# Other operators will still see you +``` + +### Combined Usage +``` +# Enable both modes simultaneously: +/mode mynick +GS +# *** GOD MODE enabled - You have ultimate power! +# *** STEALTH MODE enabled - You are now invisible to users + +# Now you can: +# - Join any channel (God Mode) +# - Remain invisible to regular users (Stealth Mode) +# - Be visible to other operators +``` + +### Permission Checking +``` +# Attempting without proper permissions: +/mode mynick +G +# :server 481 mynick :Permission Denied - You need god_mode permission + +# Must be oper first: +/mode mynick +S +# :server 481 mynick :Permission Denied- You're not an IRC operator +``` + +## Technical Implementation + +### God Mode +- Stored as user mode `+G` +- Checked via `HasGodMode()` method +- Bypasses channel restrictions in: + - `JOIN` command (bans, limits, keys, invite-only) + - `KICK` command (cannot be kicked) + - Channel access validation + +### Stealth Mode +- Stored as user mode `+S` +- Checked via `HasStealthMode()` method +- Filtered in: + - `WHO` command responses + - `NAMES` command responses + - Channel member visibility + +### Mode Persistence +- User modes are stored per-client session +- Lost on disconnect/reconnect +- Must be re-enabled after each connection + +## Security Considerations + +1. **Audit Trail**: All God Mode and Stealth Mode activations are logged +2. **Permission Based**: Requires explicit operator class permissions +3. **Self-Only**: Users can only set modes on themselves +4. **Operator Level**: Requires existing operator privileges +5. **Reversible**: Can be disabled at any time + +## Troubleshooting + +### Mode Not Setting +- Verify you are opered (`/oper`) +- Check operator class has required permissions +- Ensure using correct syntax (`/mode nickname +G`) + +### Not Working as Expected +- God Mode only affects channel restrictions, not other commands +- Stealth Mode only hides from regular users, not operators +- Modes are case-sensitive (`+G` not `+g`) + +### Permission Denied +- Contact network administrator to add permissions to your operator class +- Verify operator class configuration in `configs/opers.conf` diff --git a/docs/IRCV3_FEATURES.md b/docs/IRCV3_FEATURES.md new file mode 100644 index 0000000..75bc016 --- /dev/null +++ b/docs/IRCV3_FEATURES.md @@ -0,0 +1,122 @@ +# TechIRCd IRCv3 Features + +## ✅ Implemented IRCv3 Features + +### Core Capability Negotiation +- **CAP LS** - List available capabilities +- **CAP LIST** - List enabled capabilities +- **CAP REQ** - Request capabilities +- **CAP ACK/NAK** - Acknowledge/reject capability requests +- **CAP END** - End capability negotiation + +### Supported Capabilities + +#### IRCv3.1 Base +- ✅ **sasl** - SASL authentication (PLAIN mechanism) +- ✅ **multi-prefix** - Multiple channel prefixes (~@%+) +- ✅ **away-notify** - Away state change notifications +- ✅ **account-notify** - Account login/logout notifications +- ✅ **extended-join** - JOIN with account and real name + +#### IRCv3.2 Extensions +- ✅ **server-time** - Timestamp tags on messages +- ✅ **userhost-in-names** - Full hostmasks in NAMES +- ✅ **monitor-notify** - MONITOR command for presence +- ✅ **account-tag** - Account tags on messages +- ✅ **message-tags** - IRCv3 message tag framework + +#### IRCv3.3 Features +- ✅ **echo-message** - Echo sent messages back to sender +- ✅ **chghost** - Host change notifications +- ✅ **invite-notify** - Channel invite notifications +- ✅ **batch** - Message batching support + +## 🚀 Usage Examples + +### Basic Capability Negotiation +``` +Client: CAP LS 302 +Server: :server CAP * LS :account-notify away-notify extended-join multi-prefix sasl server-time userhost-in-names account-tag message-tags echo-message batch chghost invite-notify monitor-notify + +Client: CAP REQ :server-time message-tags sasl +Server: :server CAP * ACK :server-time message-tags sasl + +Client: CAP END +``` + +### SASL Authentication +``` +Client: CAP REQ :sasl +Server: :server CAP * ACK :sasl +Client: AUTHENTICATE PLAIN +Server: AUTHENTICATE + +Client: AUTHENTICATE dXNlcm5hbWU6cGFzc3dvcmQ= +Server: :server 903 * :SASL authentication successful +Client: CAP END +``` + +### Server-Time Tags +``` +@time=2025-08-28T17:01:49.123Z :nick!user@host PRIVMSG #channel :Hello! +``` + +### Account Tags +``` +@account=alice;time=2025-08-28T17:01:49.123Z :alice!user@host PRIVMSG #channel :Hello! +``` + +### Extended JOIN +``` +:nick!user@host JOIN #channel alice :Real Name +``` + +### MONITOR Command +``` +Client: MONITOR + alice,bob,charlie +Server: :server 730 nick :alice,bob +Server: :server 731 nick :charlie + +Client: MONITOR L +Server: :server 732 nick :alice,bob,charlie +Server: :server 733 nick :End of MONITOR list +``` + +## 🔧 Configuration + +IRCv3 features are automatically available when clients request them through CAP negotiation. No special server configuration required. + +### SASL Accounts +Currently supports simple username/password authentication. Edit the `authenticateUser()` function in `commands.go` to integrate with your authentication system. + +## 🎯 Advanced Features + +### Message Tags +TechIRCd automatically adds appropriate tags based on client capabilities: +- `time` - Server timestamp (if server-time enabled) +- `account` - User account name (if logged in and account-tag enabled) + +### Multi-Prefix Support +Shows all user channel modes: `~@%+nick` for Owner/Op/Halfop/Voice + +### Echo Message +When enabled, clients receive copies of their own messages, useful for multi-device synchronization. + +## 🔮 Future IRCv3 Extensions + +Planned for future releases: +- **draft/chathistory** - Message history replay +- **draft/resume** - Connection state resumption +- **labeled-response** - Request/response correlation +- **standard-replies** - Standardized error responses + +## 💡 Client Compatibility + +TechIRCd's IRCv3 implementation is compatible with: +- **HexChat** - Full feature support +- **IRCCloud** - Full feature support +- **Textual** - Full feature support +- **WeeChat** - Full feature support +- **KiwiIRC** - Full feature support +- **IRCv3-compliant bots** - Full API support + +Legacy IRC clients without IRCv3 support continue to work normally without any IRCv3 features. diff --git a/docs/LINKING.md b/docs/LINKING.md new file mode 100644 index 0000000..014cdf6 --- /dev/null +++ b/docs/LINKING.md @@ -0,0 +1,290 @@ +# TechIRCd Server Linking + +TechIRCd now supports IRC server linking, allowing multiple IRC servers to form a network. This enables users on different servers to communicate with each other seamlessly. + +## Features + +- **Server-to-Server Communication**: Full IRC protocol compliance for server linking +- **Hub and Leaf Configuration**: Support for hub/leaf network topologies +- **Auto-Connect**: Automatic connection to configured servers on startup +- **Operator Commands**: Manual connection and disconnection controls +- **Security**: Password-based authentication for server connections +- **Network Transparency**: Users can see and communicate across the entire network + +## Configuration + +Server linking is configured in the `config.json` file under the `linking` section: + +```json +{ + "linking": { + "enable": true, + "server_port": 6697, + "password": "linkpassword123", + "hub": false, + "auto_connect": false, + "links": [ + { + "name": "hub.technet.org", + "host": "127.0.0.1", + "port": 6697, + "password": "linkpassword123", + "auto_connect": false, + "hub": true, + "description": "TechNet Hub Server" + } + ] + } +} +``` + +### Configuration Options + +- **enable**: Enable or disable server linking functionality +- **server_port**: Port to listen on for incoming server connections +- **password**: Default password for server authentication +- **hub**: Whether this server acts as a hub (can connect to multiple servers) +- **auto_connect**: Automatically attempt to connect to configured links on startup +- **links**: Array of server configurations to link with + +### Link Configuration + +Each link in the `links` array supports: + +- **name**: Unique server name (should match the remote server's configured name) +- **host**: Hostname or IP address of the remote server +- **port**: Port number the remote server is listening on for server connections +- **password**: Authentication password (must match on both servers) +- **auto_connect**: Whether to automatically connect to this server +- **hub**: Whether the remote server is a hub server +- **description**: Human-readable description of the server + +## Operator Commands + +Operators can manually control server links using these commands: + +### CONNECT +Connect to a configured server: +``` +/CONNECT [host] +``` + +Examples: +``` +/CONNECT hub.technet.org 6697 +/CONNECT hub.technet.org 6697 192.168.1.100 +``` + +### SQUIT +Disconnect from a linked server: +``` +/SQUIT [reason] +``` + +Examples: +``` +/SQUIT hub.technet.org +/SQUIT hub.technet.org :Maintenance required +``` + +### LINKS +Show all connected servers: +``` +/LINKS +``` + +This displays the network topology showing all connected servers. + +## Network Topology + +TechIRCd supports both hub-and-leaf and mesh network topologies: + +### Hub-and-Leaf +``` + [Hub Server] + / | \ +[Leaf1] [Leaf2] [Leaf3] +``` + +In this configuration: +- The hub server (`hub: true`) connects to multiple leaf servers +- Leaf servers (`hub: false`) only connect to the hub +- All inter-server communication routes through the hub + +### Mesh Network +``` +[Server1] --- [Server2] + | \ / | + | \ / | +[Server3] --- [Server4] +``` + +In this configuration: +- All servers can connect to multiple other servers +- Provides redundancy and multiple communication paths +- More complex but more resilient + +## Server-to-Server Protocol + +TechIRCd implements standard IRC server-to-server protocol commands: + +- **PASS**: Authentication password +- **SERVER**: Server introduction and information +- **PING/PONG**: Keep-alive and lag detection +- **SQUIT**: Server quit/disconnect +- **NICK**: User nickname propagation +- **USER**: User information propagation +- **JOIN/PART**: Channel membership changes +- **PRIVMSG/NOTICE**: Message routing between servers +- **QUIT**: User disconnection notifications + +## Message Routing + +When servers are linked, messages are automatically routed across the network: + +1. **User Messages**: Private messages and notices are routed to the correct server +2. **Channel Messages**: Broadcast to all servers with users in the channel +3. **User Lists**: WHO, WHOIS, and NAMES commands work across the network +4. **Channel Lists**: LIST shows channels from all linked servers + +## Security Considerations + +- **Passwords**: Always use strong, unique passwords for server links +- **Network Access**: Restrict server linking ports to trusted networks +- **Operator Permissions**: Only trusted operators should have CONNECT/SQUIT privileges +- **Link Validation**: Server names and passwords are validated before connection + +## Troubleshooting + +### Connection Issues + +1. **Check Configuration**: Ensure server names, hosts, ports, and passwords match +2. **Network Connectivity**: Verify network connectivity between servers +3. **Firewall**: Ensure server linking ports are open +4. **Logs**: Check server logs for connection errors and authentication failures + +### Common Error Messages + +- `No link configuration found`: Server not configured in links array +- `Server already connected`: Attempted to connect to already linked server +- `Authentication failed`: Password mismatch between servers +- `Connection refused`: Network connectivity or firewall issues + +### Debugging + +Enable debug logging to see detailed server linking information: + +```json +{ + "logging": { + "level": "debug" + } +} +``` + +## Example Network Setup + +Here's an example of setting up a 3-server network: + +### Hub Server (hub.technet.org) +```json +{ + "server": { + "name": "hub.technet.org" + }, + "linking": { + "enable": true, + "server_port": 6697, + "hub": true, + "links": [ + { + "name": "leaf1.technet.org", + "host": "192.168.1.101", + "port": 6697, + "password": "linkpass123", + "auto_connect": true, + "hub": false + }, + { + "name": "leaf2.technet.org", + "host": "192.168.1.102", + "port": 6697, + "password": "linkpass123", + "auto_connect": true, + "hub": false + } + ] + } +} +``` + +### Leaf Server 1 (leaf1.technet.org) +```json +{ + "server": { + "name": "leaf1.technet.org" + }, + "linking": { + "enable": true, + "server_port": 6697, + "hub": false, + "links": [ + { + "name": "hub.technet.org", + "host": "192.168.1.100", + "port": 6697, + "password": "linkpass123", + "auto_connect": true, + "hub": true + } + ] + } +} +``` + +### Leaf Server 2 (leaf2.technet.org) +```json +{ + "server": { + "name": "leaf2.technet.org" + }, + "linking": { + "enable": true, + "server_port": 6697, + "hub": false, + "links": [ + { + "name": "hub.technet.org", + "host": "192.168.1.100", + "port": 6697, + "password": "linkpass123", + "auto_connect": true, + "hub": true + } + ] + } +} +``` + +This creates a hub-and-leaf network where: +- The hub automatically connects to both leaf servers +- Leaf servers connect only to the hub +- Users on any server can communicate with users on other servers +- Channels span across all servers in the network + +## Performance Considerations + +- **Network Latency**: High latency between servers may affect user experience +- **Bandwidth**: Server linking uses additional bandwidth for message propagation +- **CPU Usage**: Message routing and protocol handling requires CPU resources +- **Memory Usage**: Additional memory is needed to track remote users and channels + +## Future Enhancements + +Planned improvements for server linking include: + +- **Services Integration**: Support for network services (NickServ, ChanServ, etc.) +- **Channel Bursting**: Optimized channel synchronization +- **Network Statistics**: Detailed network topology and performance metrics +- **Load Balancing**: Intelligent routing for optimal performance +- **Redundancy**: Automatic failover and connection recovery diff --git a/docs/OPERATOR_SYSTEM.md b/docs/OPERATOR_SYSTEM.md new file mode 100644 index 0000000..8a7c57b --- /dev/null +++ b/docs/OPERATOR_SYSTEM.md @@ -0,0 +1,355 @@ +# Operator Configuration Guide + +TechIRCd features a sophisticated hierarchical operator system with separate configuration files and granular permission control. + +## Overview + +The operator system consists of: +- **Operator Classes**: Define ranks, permissions, and inheritance +- **Individual Operators**: Specific users with assigned classes +- **Permission System**: Granular control over what operators can do +- **Rank Hierarchy**: Higher ranks can operate on lower ranks + +## Configuration Files + +### Main Config (`config.json`) +```json +"oper_config": { + "config_file": "configs/opers.conf", + "enable": true +} +``` + +### Operator Config (`configs/opers.conf`) +Contains the complete operator hierarchy and individual operator definitions. + +## Operator Classes + +### Customizable Rank Names + +TechIRCd allows you to completely customize the names of operator ranks. Instead of using the standard Helper/Moderator/Operator names, you can create themed naming schemes: + +```json +"rank_names": { + "rank_1": "Cadet", // Instead of "Helper" + "rank_2": "Sergeant", // Instead of "Moderator" + "rank_3": "Lieutenant", // Instead of "Operator" + "rank_4": "Captain", // Instead of "Administrator" + "rank_5": "General", // Instead of "Owner" + "custom_ranks": { + "Field Marshal": 6, // Custom ranks beyond 5 + "Supreme Commander": 10 + } +} +``` + +### Popular Naming Themes + +#### Gaming/Military Theme +- Rank 1: `Cadet`, `Recruit`, `Private` +- Rank 2: `Sergeant`, `Corporal`, `Specialist` +- Rank 3: `Lieutenant`, `Captain`, `Major` +- Rank 4: `Colonel`, `General`, `Commander` +- Rank 5: `Admiral`, `Marshal`, `Supreme Commander` + +#### Corporate/Business Theme +- Rank 1: `Intern`, `Assistant`, `Associate` +- Rank 2: `Specialist`, `Senior Associate`, `Team Lead` +- Rank 3: `Manager`, `Senior Manager`, `Department Head` +- Rank 4: `Director`, `VP`, `Executive` +- Rank 5: `CEO`, `President`, `Chairman` + +#### Fantasy/Medieval Theme +- Rank 1: `Apprentice`, `Squire`, `Page` +- Rank 2: `Guardian`, `Warrior`, `Mage` +- Rank 3: `Knight`, `Paladin`, `Wizard` +- Rank 4: `Lord`, `Baron`, `Archmage` +- Rank 5: `King`, `Emperor`, `Divine Ruler` + +#### Sci-Fi Theme +- Rank 1: `Ensign`, `Technician`, `Cadet` +- Rank 2: `Engineer`, `Pilot`, `Specialist` +- Rank 3: `Commander`, `Captain`, `Leader` +- Rank 4: `Admiral`, `Fleet Commander`, `Director` +- Rank 5: `Supreme Admiral`, `Galactic Emperor`, `AI Overlord` + +### Built-in Classes (Default Names) + +#### Helper (Rank 1) +- **Symbol**: `%` +- **Color**: Green +- **Permissions**: Basic moderation + - `kick` - Kick users from channels + - `topic` - Change channel topics + - `mode_channel` - Change channel modes + +#### Moderator (Rank 2) +- **Symbol**: `@` +- **Color**: Blue +- **Inherits**: Helper permissions +- **Additional Permissions**: + - `ban` / `unban` - Manage channel bans + - `mute` - Mute users + - `mode_user` - Change user modes + - `who_override` - See hidden users in WHO + +#### Operator (Rank 3) +- **Symbol**: `*` +- **Color**: Red +- **Inherits**: Moderator permissions +- **Additional Permissions**: + - `kill` - Kill user connections + - `gline` - Global bans + - `rehash` - Reload configuration + - `connect` / `squit` - Server linking + - `wallops` / `operwall` - Send operator messages + +#### Administrator (Rank 4) +- **Symbol**: `&` +- **Color**: Purple +- **Permissions**: `*` (All permissions) + +#### Owner (Rank 5) +- **Symbol**: `~` +- **Color**: Gold +- **Special Permissions**: + - `*` - All permissions + - `override_rank` - Can operate on same/higher ranks + - `shutdown` / `restart` - Server control + +### Custom Classes + +Create your own operator classes: + +```json +{ + "name": "security", + "rank": 3, + "description": "Security Officer - Network protection", + "permissions": [ + "kill", + "gline", + "scan_network", + "access_logs" + ], + "inherits": "moderator", + "color": "orange", + "symbol": "!" +} +``` + +## Individual Operators + +### Basic Operator Definition + +```json +{ + "name": "alice", + "password": "secure_password_here", + "host": "*@trusted.example.com", + "class": "moderator", + "flags": ["extra_channels"], + "max_clients": 500, + "contact": "alice@example.com" +} +``` + +### Advanced Features + +```json +{ + "name": "bob", + "password": "another_secure_password", + "host": "admin@192.168.1.*", + "class": "admin", + "flags": ["debug_access", "special_channels"], + "max_clients": 1000, + "expires": "2025-12-31", + "contact": "bob@company.com", + "last_seen": "2025-07-30T10:30:00Z" +} +``` + +## Permission System + +### Standard Permissions + +#### Channel Management +- `kick` - Kick users from channels +- `ban` / `unban` - Manage channel bans +- `topic` - Change channel topics +- `mode_channel` - Change channel modes +- `mode_user` - Change user modes + +#### User Management +- `kill` - Disconnect users +- `gline` - Global network bans +- `mute` - Silence users +- `who_override` - See hidden information + +#### Server Management +- `rehash` - Reload configuration +- `connect` / `squit` - Server linking +- `wallops` / `operwall` - Operator communications +- `shutdown` / `restart` - Server control + +#### Special Permissions +- `*` - All permissions (wildcard) +- `override_rank` - Ignore rank restrictions +- `debug_access` - Debug commands +- `log_access` - View server logs + +### Custom Permissions + +Add your own permissions for custom commands: + +```json +"permissions": [ + "custom_report", + "special_database_access", + "network_monitoring" +] +``` + +## Security Features + +### Settings Configuration + +```json +"settings": { + "require_ssl": true, + "max_failed_attempts": 3, + "lockout_duration_minutes": 30, + "log_oper_actions": true, + "notify_on_oper_up": true, + "auto_expire_inactive_days": 365, + "require_two_factor": false +} +``` + +### Security Options + +- **SSL Requirement**: Force SSL for operator authentication +- **Failed Attempt Tracking**: Lock accounts after failed attempts +- **Action Logging**: Log all operator actions +- **Auto-Expiration**: Remove inactive operators +- **Two-Factor Authentication**: (Future feature) + +## Rank System + +### How Ranks Work + +- **Higher numbers = Higher authority** +- **Same rank cannot operate on each other** +- **Override permission bypasses rank restrictions** + +### Example Hierarchy + +``` +Owner (5) -> Can do anything to anyone +Admin (4) -> Can operate on Operator (3) and below +Operator (3) -> Can operate on Moderator (2) and below +Moderator (2) -> Can operate on Helper (1) and below +Helper (1) -> Can only operate on regular users +``` + +## WHOIS Integration + +The operator system integrates with WHOIS to show: + +- Operator status with custom symbols +- Class names and descriptions +- **Custom rank names** based on your configuration +- Permission levels + +Example WHOIS output with custom rank names: +``` +[313] alice is an IRC operator (moderator - Channel and user management) [Sergeant] +[313] bob is an IRC operator (captain - Senior officer with full authority) [Captain] +``` + +With fantasy theme: +``` +[313] merlin is an IRC operator (lord - Ruler of vast territories) [Lord] +``` + +## Configuration Examples + +### Quick Setup - Gaming Theme + +```json +{ + "rank_names": { + "rank_1": "Cadet", + "rank_2": "Sergeant", + "rank_3": "Lieutenant", + "rank_4": "Captain", + "rank_5": "General" + }, + "classes": [ + { + "name": "cadet", + "rank": 1, + "description": "New recruit with basic training", + "permissions": ["kick", "topic"], + "symbol": "+", + "color": "green" + } + ] +} +``` + +See `/configs/examples/` for complete themed configurations: +- `opers-gaming-theme.conf` - Military/Gaming ranks +- `opers-corporate-theme.conf` - Business hierarchy +- `opers-fantasy-theme.conf` - Medieval/Fantasy theme + +## Migration from Legacy + +TechIRCd automatically falls back to the legacy operator system if: +- `oper_config.enable` is `false` +- The opers.conf file cannot be loaded +- No matching operator is found in the new system + +Legacy operators get basic permissions and rank 1. + +## Commands for Operators + +### Checking Permissions +``` +/WHOIS operator_name # See operator class and rank +/OPERLIST # List all operators (future) +/OPERHELP # Show operator commands (future) +``` + +### Management Commands +``` +/OPER name password # Become an operator +/OPERWALL message # Send message to all operators +/REHASH # Reload configuration +``` + +## Best Practices + +### Security +1. Use strong passwords for all operators +2. Restrict host masks to trusted IPs +3. Enable SSL requirement for sensitive ranks +4. Regularly audit operator lists +5. Set expiration dates for temporary operators + +### Organization +1. Create classes that match your network structure +2. Use inheritance to avoid permission duplication +3. Document custom permissions clearly +4. Use descriptive class names and descriptions +5. Assign appropriate symbols and colors + +### Maintenance +1. Review operator activity regularly +2. Remove inactive operators +3. Update permissions as needed +4. Monitor operator actions through logs +5. Keep opers.conf in version control + +This operator system makes TechIRCd extremely flexible for networks of any size, from small communities to large networks with complex hierarchies! diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md new file mode 100644 index 0000000..8dbdfd2 --- /dev/null +++ b/docs/PROJECT_STRUCTURE.md @@ -0,0 +1,161 @@ +# TechIRCd Enhanced Project Structure + +## Current Structure Issues +- All packages use `package main` instead of proper names +- Missing comprehensive test coverage +- No CI/CD pipeline +- Limited documentation +- No deployment configurations + +## Proposed Enhanced Structure + +``` +``` +TechIRCd/ +├── .github/ # GitHub-specific files +│ ├── workflows/ # GitHub Actions CI/CD +│ │ ├── ci.yml # Continuous Integration +│ │ ├── release.yml # Automated releases +│ │ └── security.yml # Security scanning +│ ├── ISSUE_TEMPLATE/ # Issue templates +│ │ ├── bug_report.md +│ │ ├── feature_request.md +│ │ └── question.md +│ └── PULL_REQUEST_TEMPLATE.md +├── api/ # API definitions (if adding REST API) +│ └── v1/ +│ └── openapi.yaml +├── build/ # Build configurations +│ └── ci/ +│ └── scripts/ +├── cmd/ # Main applications +│ ├── techircd/ # Main server +│ │ └── main.go +│ ├── techircd-admin/ # Admin tool +│ │ └── main.go +│ └── techircd-client/ # Test client +│ └── main.go +├── configs/ # Configuration files +│ ├── config.json # Default config +│ ├── config.prod.json # Production config +│ └── config.dev.json # Development config +├── deployments/ # Deployment configurations +│ ├── kubernetes/ +│ │ ├── namespace.yaml +│ │ ├── deployment.yaml +│ │ └── service.yaml +│ └── systemd/ +│ └── techircd.service +``` +├── docs/ # Documentation +│ ├── api/ # API documentation +│ ├── admin/ # Administrator guide +│ ├── user/ # User guide +│ ├── development/ # Development guide +│ └── examples/ # Usage examples +├── examples/ # Example configurations +│ ├── simple/ +│ ├── production/ +│ └── cluster/ +├── internal/ # Private application code +│ ├── channel/ +│ │ ├── channel.go +│ │ ├── channel_test.go +│ │ ├── modes.go +│ │ └── permissions.go +│ ├── client/ +│ │ ├── client.go +│ │ ├── client_test.go +│ │ ├── auth.go +│ │ └── connection.go +│ ├── commands/ +│ │ ├── commands.go +│ │ ├── commands_test.go +│ │ ├── irc.go +│ │ └── operator.go +│ ├── config/ +│ │ ├── config.go +│ │ ├── config_test.go +│ │ └── validation.go +│ ├── database/ # Database layer (future) +│ │ ├── models/ +│ │ └── migrations/ +│ ├── health/ +│ │ ├── health.go +│ │ ├── health_test.go +│ │ └── metrics.go +│ ├── protocol/ # IRC protocol handling +│ │ ├── parser.go +│ │ ├── parser_test.go +│ │ └── numerics.go +│ ├── security/ # Security features +│ │ ├── auth.go +│ │ ├── ratelimit.go +│ │ └── validation.go +│ └── server/ +│ ├── server.go +│ ├── server_test.go +│ └── handlers.go +├── pkg/ # Public library code +│ └── irc/ # IRC utilities for external use +│ ├── client/ +│ │ └── client.go +│ └── protocol/ +│ └── constants.go +├── scripts/ # Build and utility scripts +│ ├── build.sh +│ ├── test.sh +│ ├── lint.sh +│ └── release.sh +├── test/ # Test data and utilities +│ ├── fixtures/ # Test data +│ ├── integration/ # Integration tests +│ │ └── server_test.go +│ ├── e2e/ # End-to-end tests +│ └── performance/ # Performance tests +├── tools/ # Supporting tools +│ └── migrate/ # Database migration tool +├── web/ # Web interface (future) +│ ├── static/ +│ └── templates/ +├── .editorconfig +├── .golangci.yml # Linter configuration +├── CHANGELOG.md +├── CODE_OF_CONDUCT.md +├── CONTRIBUTING.md +├── go.mod +├── go.sum +├── LICENSE +├── Makefile +├── README.md +└── SECURITY.md +``` + +## Implementation Priority + +### Phase 1: Core Structure +1. Fix package declarations +2. Add proper test files +3. Create CI/CD pipeline +4. Add linting configuration + +### Phase 2: Enhanced Features +1. Create admin tools +2. Add API endpoints +3. Implement database layer +4. Add monitoring tools + +### Phase 3: Production Ready +1. Add monitoring +2. Create deployment configs +3. Add security scanning +4. Performance optimization + +## Benefits of This Structure + +1. **Professional**: Follows Go and open-source best practices +2. **Scalable**: Easy to add new features and maintain +3. **Testable**: Comprehensive testing at all levels +4. **Deployable**: Ready for production environments +5. **Maintainable**: Clear separation of concerns +6. **Community-friendly**: Easy for contributors to understand diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..dfcd650 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,58 @@ +# Security Policy + +## Supported Versions + +We currently support the following versions with security updates: + +| Version | Supported | +| ------- | ------------------ | +| 1.0.x | :white_check_mark: | + +## Reporting a Vulnerability + +We take the security of TechIRCd seriously. If you believe you have found a security vulnerability, please report it to us as described below. + +**Please do not report security vulnerabilities through public GitHub issues.** + +Instead, please report them via email to: security@techircd.org (or the maintainer's email) + +Please include the following information (as much as you can provide) to help us better understand the nature and scope of the possible issue: + +- Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) +- Full paths of source file(s) related to the manifestation of the issue +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- Step-by-step instructions to reproduce the issue +- Proof-of-concept or exploit code (if possible) +- Impact of the issue, including how an attacker might exploit the issue + +This information will help us triage your report more quickly. + +## Preferred Languages + +We prefer all communications to be in English. + +## Response Time + +We will respond to your report within 48 hours and provide regular updates at least every 72 hours. + +## Security Measures + +TechIRCd implements several security measures: + +- Input validation and sanitization +- Flood protection and rate limiting +- Connection timeout management +- Panic recovery mechanisms +- Memory usage monitoring +- Secure configuration validation + +## Responsible Disclosure + +We follow responsible disclosure practices: + +1. **Report**: Submit vulnerability report privately +2. **Acknowledge**: We acknowledge receipt within 48 hours +3. **Investigate**: We investigate and develop a fix +4. **Coordinate**: We coordinate disclosure timeline with reporter +5. **Release**: We release security update and public disclosure diff --git a/docs/SERVICES_INTEGRATION.md b/docs/SERVICES_INTEGRATION.md new file mode 100644 index 0000000..58deb16 --- /dev/null +++ b/docs/SERVICES_INTEGRATION.md @@ -0,0 +1,218 @@ +# Services Integration Guide + +TechIRCd is designed to work with external IRC services packages like Anope, Atheme, or IRCServices. This document explains how to set up services integration. + +## Supported Services Packages + +### Recommended: Anope Services +- **Website**: https://www.anope.org/ +- **Features**: NickServ, ChanServ, OperServ, MemoServ, BotServ, HostServ +- **Database**: MySQL, PostgreSQL, SQLite +- **Protocol**: Full IRCd linking support + +### Alternative: Atheme Services +- **Website**: https://atheme.github.io/ +- **Features**: NickServ, ChanServ, OperServ, MemoServ +- **Database**: Multiple backend support +- **Protocol**: Standard IRC linking + +## TechIRCd Services Integration Features + +### 1. Standard IRC Protocol Support +TechIRCd implements the standard IRC protocol that services packages expect: +- **Server-to-Server linking** (`linking.json` configuration) +- **Standard IRC commands** (NICK, JOIN, PART, PRIVMSG, etc.) +- **Operator commands** (KILL, SQUIT, STATS, etc.) +- **User and channel modes** +- **IRCv3 capabilities** for enhanced features + +### 2. Services Connection Methods + +#### Method A: Services as Linked Server (Recommended) +Configure services to connect as a linked server using the linking configuration: + +```json +{ + "linking": { + "enable": true, + "links": [ + { + "name": "services.yourdomain.com", + "host": "127.0.0.1", + "port": 6697, + "password": "services-link-password", + "auto_connect": true, + "hub": false, + "class": "services" + } + ], + "classes": { + "services": { + "recvq": 16384, + "sendq": 16384, + "max_links": 1 + } + } + } +} +``` + +#### Method B: Services as Local Client +Services can also connect as regular IRC clients with special privileges. + +### 3. Services User Modes +TechIRCd supports standard services user modes: +- `+S` - Service mode (identifies services bots) +- `+o` - IRC Operator mode +- `+B` - Bot mode +- `+r` - Registered user mode + +### 4. Channel Modes for Services +- `+r` - Registered channel +- `+R` - Registered users only +- `+M` - Registered/voiced users only speak +- `+S` - Services bots only + +## Anope Configuration Example + +### anope.conf snippet: +``` +uplink +{ + host = "127.0.0.1" + port = 6667 + password = "link-password" +} + +serverinfo +{ + name = "services.yourdomain.com" + description = "Services for TechNet" +} + +networkinfo +{ + networkname = "TechNet" + ircd = "techircd" +} +``` + +### TechIRCd linking.json for Anope: +```json +{ + "linking": { + "enable": true, + "server_port": 6697, + "password": "link-password", + "links": [ + { + "name": "services.yourdomain.com", + "host": "127.0.0.1", + "port": 6697, + "password": "link-password", + "auto_connect": false, + "hub": false, + "class": "services" + } + ] + } +} +``` + +## Setup Instructions + +### 1. Configure TechIRCd +1. Edit `configs/linking.json` to add services link +2. Set up linking passwords +3. Configure operator access for services management + +### 2. Install Anope Services +```bash +# Download and compile Anope +wget https://github.com/anope/anope/releases/latest +tar -xzf anope-*.tar.gz +cd anope-* +./configure --prefix=/opt/anope +make && make install +``` + +### 3. Configure Anope +1. Edit `/opt/anope/conf/services.conf` +2. Set TechIRCd as the uplink server +3. Configure database settings +4. Set up services nicknames and channels + +### 4. Start Services +```bash +# Start TechIRCd first +./techircd start + +# Then start Anope services +/opt/anope/bin/anoperc start +``` + +## Services Protocol Support + +TechIRCd supports the standard IRC server protocol features needed by services: + +### User Management +- `NICK` - Nickname changes +- `USER` - User registration +- `QUIT` - User disconnection +- `KILL` - Force disconnect users + +### Channel Management +- `JOIN/PART` - Channel membership +- `MODE` - Channel and user modes +- `TOPIC` - Channel topics +- `KICK/BAN` - Channel moderation + +### Network Management +- `SQUIT` - Server disconnection +- `SERVER` - Server introduction +- `BURST` - Network state synchronization +- `STATS` - Server statistics + +### Services-Specific +- `SVSNICK` - Services nickname enforcement +- `SVSMODE` - Services mode changes +- `SVSJOIN` - Services-forced joins +- `SVSPART` - Services-forced parts + +## Troubleshooting + +### Services Won't Connect +1. Check linking password in both configs +2. Verify port configuration +3. Check TechIRCd logs for connection attempts +4. Ensure services hostname resolves + +### Services Commands Not Working +1. Verify services have operator status +2. Check services user modes (+S +o) +3. Review channel access levels +4. Check services configuration + +### Database Issues +Services handle their own database - TechIRCd doesn't need database access. + +## Benefits of External Services + +### For TechIRCd: +- ✅ **Simplified codebase** - Focus on IRC protocol +- ✅ **Better performance** - No database overhead +- ✅ **Higher reliability** - Services crashes don't affect IRC +- ✅ **Easier maintenance** - Separate concerns + +### For Users: +- ✅ **Mature services** - Anope/Atheme are battle-tested +- ✅ **Rich features** - Full services functionality +- ✅ **Database choice** - MySQL, PostgreSQL, SQLite +- ✅ **Web interfaces** - Many services offer web panels + +## Getting Help + +- **TechIRCd Support**: GitHub Issues +- **Anope Support**: https://www.anope.org/ +- **Atheme Support**: https://atheme.github.io/ +- **IRC Help**: #anope or #atheme on irc.anope.org diff --git a/docs/WHOIS_CONFIGURATION.md b/docs/WHOIS_CONFIGURATION.md new file mode 100644 index 0000000..cef9590 --- /dev/null +++ b/docs/WHOIS_CONFIGURATION.md @@ -0,0 +1,208 @@ +# WHOIS Configuration Guide + +TechIRCd provides extremely flexible WHOIS configuration that allows administrators to control exactly what information is visible to different types of users. + +## Overview + +The WHOIS system in TechIRCd uses a three-tier permission system: +- **to_everyone**: Information visible to all users +- **to_opers**: Information visible only to IRC operators +- **to_self**: Information visible when users query themselves + +## Configuration Options + +### Basic Information Controls + +#### User Modes (`show_user_modes`) +Controls who can see what user modes (+i, +w, +s, etc.) a user has set. + +```json +"show_user_modes": { + "to_everyone": false, + "to_opers": true, + "to_self": true +} +``` + +#### SSL Status (`show_ssl_status`) +Shows whether a user is connected via SSL/TLS. + +```json +"show_ssl_status": { + "to_everyone": true, + "to_opers": true, + "to_self": true +} +``` + +#### Idle Time (`show_idle_time`) +Shows how long a user has been idle and their signon time. + +```json +"show_idle_time": { + "to_everyone": false, + "to_opers": true, + "to_self": true +} +``` + +#### Signon Time (`show_signon_time`) +Shows when a user connected to the server (alternative to idle time). + +```json +"show_signon_time": { + "to_everyone": false, + "to_opers": true, + "to_self": true +} +``` + +#### Real Host (`show_real_host`) +Shows the user's real IP address/hostname (bypasses host masking). + +```json +"show_real_host": { + "to_everyone": false, + "to_opers": true, + "to_self": true +} +``` + +### Channel Information (`show_channels`) + +Controls channel visibility with additional granular options: + +```json +"show_channels": { + "to_everyone": true, + "to_opers": true, + "to_self": true, + "hide_secret_channels": true, + "hide_private_channels": false, + "show_membership_levels": true +} +``` + +- `hide_secret_channels`: Hide channels with mode +s from non-members +- `hide_private_channels`: Hide channels with mode +p from non-members +- `show_membership_levels`: Show @/+/% prefixes for ops/voice/halfop + +### Operator Information (`show_oper_class`) +Shows IRC operator class/type information. + +```json +"show_oper_class": { + "to_everyone": false, + "to_opers": true, + "to_self": true +} +``` + +### Client Information (`show_client_info`) +Shows information about the IRC client software being used. + +```json +"show_client_info": { + "to_everyone": false, + "to_opers": true, + "to_self": false +} +``` + +### Account Name (`show_account_name`) +Shows services account name (for networks with services integration). + +```json +"show_account_name": { + "to_everyone": true, + "to_opers": true, + "to_self": true +} +``` + +## Advanced Features (Future) + +These features are planned for future implementation: + +- `show_activity_stats`: User activity analytics +- `show_github_integration`: GitHub profile integration +- `show_geolocation`: Approximate location information +- `show_performance_stats`: Connection performance metrics +- `show_device_info`: Device and OS information +- `show_social_graph`: Mutual channels and connections +- `show_security_score`: Account security rating + +## Custom Fields + +TechIRCd supports custom WHOIS fields for maximum flexibility: + +```json +"custom_fields": [ + { + "name": "website", + "to_everyone": true, + "to_opers": true, + "to_self": true, + "format": "Website: %s", + "description": "User's personal website" + } +] +``` + +## Example Configurations + +### Maximum Privacy +Only show basic information to everyone, detailed info to opers: + +```json +"whois_features": { + "show_user_modes": {"to_everyone": false, "to_opers": true, "to_self": true}, + "show_ssl_status": {"to_everyone": false, "to_opers": true, "to_self": true}, + "show_idle_time": {"to_everyone": false, "to_opers": true, "to_self": true}, + "show_real_host": {"to_everyone": false, "to_opers": true, "to_self": true}, + "show_channels": { + "to_everyone": true, "to_opers": true, "to_self": true, + "hide_secret_channels": true, "hide_private_channels": true, + "show_membership_levels": false + } +} +``` + +### Maximum Transparency +Show most information to everyone: + +```json +"whois_features": { + "show_user_modes": {"to_everyone": true, "to_opers": true, "to_self": true}, + "show_ssl_status": {"to_everyone": true, "to_opers": true, "to_self": true}, + "show_idle_time": {"to_everyone": true, "to_opers": true, "to_self": true}, + "show_real_host": {"to_everyone": false, "to_opers": true, "to_self": true}, + "show_channels": { + "to_everyone": true, "to_opers": true, "to_self": true, + "hide_secret_channels": false, "hide_private_channels": false, + "show_membership_levels": true + } +} +``` + +### Development/Testing +Show everything to everyone for debugging: + +```json +"whois_features": { + "show_user_modes": {"to_everyone": true, "to_opers": true, "to_self": true}, + "show_ssl_status": {"to_everyone": true, "to_opers": true, "to_self": true}, + "show_idle_time": {"to_everyone": true, "to_opers": true, "to_self": true}, + "show_real_host": {"to_everyone": true, "to_opers": true, "to_self": true}, + "show_client_info": {"to_everyone": true, "to_opers": true, "to_self": true} +} +``` + +## Notes + +- The WHOIS system respects IRC operator privileges and host masking settings +- Secret and private channel hiding works in conjunction with channel membership +- All settings are hot-reloadable (restart server after config changes) +- The system is designed to be extremely flexible while maintaining IRC protocol compliance + +This configuration system makes TechIRCd one of the most configurable IRC servers available! diff --git a/extreme_stress.py b/extreme_stress.py new file mode 100644 index 0000000..4ce1686 --- /dev/null +++ b/extreme_stress.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +""" +EXTREME TechIRCd Stress Testing Tool +BRUTAL IRC server stress testing designed to break things +""" + +import asyncio +import random +import string +import time +import logging +import socket +import sys +from typing import List +import threading + +class ExtremeIRCClient: + def __init__(self, client_id: int): + self.client_id = client_id + self.nick = f"EXTREME{client_id:05d}" + self.reader = None + self.writer = None + self.connected = False + self.message_count = 0 + + async def connect(self): + try: + self.reader, self.writer = await asyncio.wait_for( + asyncio.open_connection('localhost', 6667), + timeout=5.0 + ) + self.connected = True + return True + except Exception as e: + return False + + async def register(self): + if not self.connected or self.writer is None: + return False + try: + self.writer.write(f"NICK {self.nick}\r\n".encode()) + self.writer.write(f"USER {self.nick} 0 * :Extreme Client {self.client_id}\r\n".encode()) + await self.writer.drain() + # Don't wait for response - BRUTAL mode + return True + except: + return False + + async def spam_messages(self, count: int, delay: float = 0.001): + """Send messages as fast as possible""" + if not self.connected or self.writer is None: + return + + messages = [ + f"PRIVMSG #extreme :SPAM MESSAGE {i} FROM {self.nick} - " + "X" * 100 + for i in range(count) + ] + + try: + for msg in messages: + self.writer.write(f"{msg}\r\n".encode()) + self.message_count += 1 + if delay > 0: + await asyncio.sleep(delay) + await self.writer.drain() + except: + pass + + async def chaos_commands(self): + """Send random commands rapidly""" + commands = [ + "JOIN #extreme", + "PART #extreme", + "JOIN #chaos1,#chaos2,#chaos3,#chaos4,#chaos5", + "LIST", + "WHO #extreme", + "VERSION", + "TIME", + "ADMIN", + "INFO", + "LUSERS", + f"NICK {self.nick}_CHAOS_{random.randint(1000,9999)}", + "PING :test", + ] + + try: + if self.writer is not None: + for _ in range(50): # 50 rapid commands + cmd = random.choice(commands) + self.writer.write(f"{cmd}\r\n".encode()) + await asyncio.sleep(0.01) # 10ms between commands + await self.writer.drain() + except: + pass + + async def disconnect(self): + if self.writer: + try: + self.writer.write(b"QUIT :EXTREME TEST COMPLETE\r\n") + await self.writer.drain() + self.writer.close() + await self.writer.wait_closed() + except: + pass + self.connected = False + +class ExtremeStressTester: + def __init__(self): + self.clients = [] + self.total_connections = 0 + self.successful_connections = 0 + self.total_messages = 0 + self.start_time = None + + async def extreme_connection_bomb(self, count: int): + """Connect as many clients as possible simultaneously""" + print(f"🔥 EXTREME: Launching {count} connections simultaneously...") + + tasks = [] + for i in range(count): + client = ExtremeIRCClient(i) + self.clients.append(client) + tasks.append(self.connect_and_register(client)) + + # Launch ALL connections at once - BRUTAL + results = await asyncio.gather(*tasks, return_exceptions=True) + + self.successful_connections = sum(1 for r in results if r is True) + print(f"💀 Connection bomb result: {self.successful_connections}/{count} successful") + + async def connect_and_register(self, client): + try: + if await client.connect(): + await client.register() + return True + except: + pass + return False + + async def extreme_message_hurricane(self, messages_per_client: int = 200): + """Send massive amounts of messages from all clients""" + print(f"🌪️ EXTREME: Message hurricane - {messages_per_client} msgs per client...") + + connected_clients = [c for c in self.clients if c.connected] + print(f"💀 Using {len(connected_clients)} connected clients") + + tasks = [] + for client in connected_clients: + tasks.append(client.spam_messages(messages_per_client, delay=0.001)) # 1ms delay + + await asyncio.gather(*tasks, return_exceptions=True) + + total_msgs = sum(c.message_count for c in connected_clients) + print(f"💀 Hurricane complete: {total_msgs} total messages sent") + + async def extreme_chaos_mode(self): + """Every client sends random commands rapidly""" + print(f"🔥 EXTREME: CHAOS MODE ACTIVATED...") + + connected_clients = [c for c in self.clients if c.connected] + tasks = [] + for client in connected_clients: + tasks.append(client.chaos_commands()) + + await asyncio.gather(*tasks, return_exceptions=True) + print(f"💀 Chaos mode complete") + + async def rapid_fire_connections(self, total: int, batch_size: int = 50): + """Connect clients in rapid batches""" + print(f"⚡ EXTREME: Rapid fire - {total} clients in batches of {batch_size}") + + for batch_start in range(0, total, batch_size): + batch_end = min(batch_start + batch_size, total) + batch_clients = [] + + # Create batch + for i in range(batch_start, batch_end): + client = ExtremeIRCClient(i + 10000) # Offset IDs + batch_clients.append(client) + self.clients.append(client) + + # Connect batch simultaneously + tasks = [self.connect_and_register(c) for c in batch_clients] + results = await asyncio.gather(*tasks, return_exceptions=True) + + successful = sum(1 for r in results if r is True) + print(f"💀 Batch {batch_start//batch_size + 1}: {successful}/{len(batch_clients)} connected") + + # Tiny delay between batches + await asyncio.sleep(0.1) + + async def cleanup(self): + """Disconnect all clients""" + print("🧹 Cleaning up connections...") + tasks = [] + for client in self.clients: + if client.connected: + tasks.append(client.disconnect()) + + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + + print(f"💀 Cleanup complete. Total clients created: {len(self.clients)}") + +async def run_extreme_test(): + """Run the most brutal stress test possible""" + print("💀💀💀 EXTREME STRESS TEST STARTING 💀💀💀") + print("⚠️ WARNING: This test is designed to break things!") + print() + + tester = ExtremeStressTester() + start_time = time.time() + + try: + # Phase 1: Connection bomb + await tester.extreme_connection_bomb(150) + await asyncio.sleep(2) + + # Phase 2: Message hurricane + await tester.extreme_message_hurricane(100) + await asyncio.sleep(1) + + # Phase 3: Chaos mode + await tester.extreme_chaos_mode() + await asyncio.sleep(1) + + # Phase 4: More connections while under load + await tester.rapid_fire_connections(100, 25) + await asyncio.sleep(2) + + # Phase 5: Final message barrage + await tester.extreme_message_hurricane(50) + + except KeyboardInterrupt: + print("\n🛑 Test interrupted by user") + except Exception as e: + print(f"\n💥 Test crashed: {e}") + finally: + # Always cleanup + await tester.cleanup() + + end_time = time.time() + duration = end_time - start_time + + print(f"\n💀 EXTREME STRESS TEST COMPLETE 💀") + print(f"Duration: {duration:.2f} seconds") + print(f"Total clients: {len(tester.clients)}") + print(f"Successful connections: {tester.successful_connections}") + total_messages = sum(c.message_count for c in tester.clients) + print(f"Total messages sent: {total_messages}") + if duration > 0: + print(f"Messages per second: {total_messages/duration:.2f}") + +if __name__ == "__main__": + print("💀 EXTREME STRESS TESTER 💀") + print("This will attempt to overwhelm the IRC server") + print("Make sure TechIRCd is running!") + print() + + try: + asyncio.run(run_extreme_test()) + except KeyboardInterrupt: + print("\n🛑 Aborted by user") diff --git a/extreme_stress.sh b/extreme_stress.sh new file mode 100644 index 0000000..378ce48 --- /dev/null +++ b/extreme_stress.sh @@ -0,0 +1,105 @@ +#!/bin/bash + +# EXTREME TechIRCd Stress Test Runner +# This script runs progressively more brutal stress tests + +echo "💀💀💀 EXTREME STRESS TEST SUITE 💀💀💀" +echo "" + +# Check if TechIRCd is running +if ! pgrep -f "techircd" > /dev/null; then + echo "🚨 TechIRCd is not running! Starting it..." + ./techircd start & + sleep 3 + echo "✅ TechIRCd started" +fi + +echo "🔥 Available EXTREME stress tests:" +echo "" +echo "1. extreme_bomb - 150 simultaneous connections + message flood" +echo "2. extreme_rapid - Rapid fire connection test (original + extreme scenarios)" +echo "3. extreme_nuclear - THE NUCLEAR OPTION (300 clients, everything at max)" +echo "4. extreme_python - Custom Python brutality test" +echo "5. extreme_all - ALL EXTREME TESTS IN SEQUENCE" +echo "" + +if [ $# -eq 0 ]; then + echo "Usage: $0 " + echo "Example: $0 extreme_bomb" + exit 1 +fi + +TEST_TYPE=$1 + +case $TEST_TYPE in + extreme_bomb) + echo "💣 Running EXTREME Mass Connection Bomb..." + python3 stress_test.py --scenario "EXTREME: Mass Connection Bomb" + ;; + extreme_rapid) + echo "⚡ Running EXTREME Rapid Fire Connections..." + python3 stress_test.py --scenario "EXTREME: Rapid Fire Connections" + ;; + extreme_nuclear) + echo "☢️ Running THE NUCLEAR OPTION..." + echo "⚠️ WARNING: This test uses 300 clients!" + python3 stress_test.py --scenario "EXTREME: The Nuclear Option" + ;; + extreme_python) + echo "🐍 Running Custom Python Brutality Test..." + python3 extreme_stress.py + ;; + extreme_hurricane) + echo "🌪️ Running EXTREME Message Hurricane..." + python3 stress_test.py --scenario "EXTREME: Message Hurricane" + ;; + extreme_chaos) + echo "🔥 Running EXTREME Connection Chaos..." + python3 stress_test.py --scenario "EXTREME: Connection Chaos" + ;; + extreme_all) + echo "💀 RUNNING ALL EXTREME TESTS IN SEQUENCE..." + echo "⚠️ This will be BRUTAL on your server!" + read -p "Are you sure? (yes/NO): " confirm + if [ "$confirm" = "yes" ]; then + echo "" + echo "🚀 Starting extreme test sequence..." + + echo "1/6: Mass Connection Bomb" + python3 stress_test.py --scenario "EXTREME: Mass Connection Bomb" + sleep 5 + + echo "2/6: Rapid Fire Connections" + python3 stress_test.py --scenario "EXTREME: Rapid Fire Connections" + sleep 5 + + echo "3/6: Message Hurricane" + python3 stress_test.py --scenario "EXTREME: Message Hurricane" + sleep 5 + + echo "4/6: Connection Chaos" + python3 stress_test.py --scenario "EXTREME: Connection Chaos" + sleep 5 + + echo "5/6: Custom Python Brutality" + python3 extreme_stress.py + sleep 10 + + echo "6/6: THE NUCLEAR OPTION" + python3 stress_test.py --scenario "EXTREME: The Nuclear Option" + + echo "💀 ALL EXTREME TESTS COMPLETE 💀" + else + echo "❌ Extreme test sequence cancelled" + fi + ;; + *) + echo "❌ Unknown test type: $TEST_TYPE" + echo "Available: extreme_bomb, extreme_rapid, extreme_nuclear, extreme_python, extreme_hurricane, extreme_chaos, extreme_all" + exit 1 + ;; +esac + +echo "" +echo "🏁 Stress test complete!" +echo "Check server logs for performance data" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4bc7444 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/ComputerTech312/TechIRCd + +go 1.21 diff --git a/health.go b/health.go new file mode 100644 index 0000000..42dbe0c --- /dev/null +++ b/health.go @@ -0,0 +1,99 @@ +package main + +import ( + "log" + "runtime" + "sync/atomic" + "time" +) + +// HealthMonitor tracks server health metrics +type HealthMonitor struct { + server *Server + totalClients int64 + totalMessages int64 + startTime time.Time + ticker *time.Ticker + shutdown chan bool +} + +func NewHealthMonitor(server *Server) *HealthMonitor { + return &HealthMonitor{ + server: server, + startTime: time.Now(), + shutdown: make(chan bool), + } +} + +func (h *HealthMonitor) Start() { + h.ticker = time.NewTicker(5 * time.Minute) // Log stats every 5 minutes + go h.monitor() +} + +func (h *HealthMonitor) Stop() { + if h.ticker != nil { + h.ticker.Stop() + } + close(h.shutdown) +} + +func (h *HealthMonitor) IncrementClients() { + atomic.AddInt64(&h.totalClients, 1) +} + +func (h *HealthMonitor) DecrementClients() { + atomic.AddInt64(&h.totalClients, -1) +} + +func (h *HealthMonitor) IncrementMessages() { + atomic.AddInt64(&h.totalMessages, 1) +} + +func (h *HealthMonitor) monitor() { + for { + select { + case <-h.ticker.C: + h.logHealthStats() + case <-h.shutdown: + return + } + } +} + +func (h *HealthMonitor) logHealthStats() { + var m runtime.MemStats + runtime.ReadMemStats(&m) + + h.server.mu.RLock() + clientCount := len(h.server.clients) + channelCount := len(h.server.channels) + h.server.mu.RUnlock() + + totalClients := atomic.LoadInt64(&h.totalClients) + totalMessages := atomic.LoadInt64(&h.totalMessages) + uptime := time.Since(h.startTime) + + log.Printf("Health Stats - Uptime: %v, Clients: %d, Channels: %d, Total Clients: %d, Total Messages: %d", + uptime.Round(time.Second), clientCount, channelCount, totalClients, totalMessages) + + log.Printf("Memory Stats - Alloc: %d KB, Sys: %d KB, NumGC: %d, Goroutines: %d", + bToKb(m.Alloc), bToKb(m.Sys), m.NumGC, runtime.NumGoroutine()) + + // Alert if memory usage is high + if m.Alloc > 100*1024*1024 { // 100MB + log.Printf("WARNING: High memory usage detected: %d MB", bToMb(m.Alloc)) + } + + // Alert if too many goroutines + if runtime.NumGoroutine() > 1000 { + log.Printf("WARNING: High goroutine count: %d", runtime.NumGoroutine()) + } +} + +func bToKb(b uint64) uint64 { + return b / 1024 +} + +func bToMb(b uint64) uint64 { + return b / 1024 / 1024 +} diff --git a/internal/commands/commands.go b/internal/commands/commands.go new file mode 100644 index 0000000..c3d4889 --- /dev/null +++ b/internal/commands/commands.go @@ -0,0 +1,1963 @@ +package main + +import ( + "fmt" + "strings" + "time" +) + +// Dummy Server type definition for compilation. +// Replace or expand this with your actual Server struct definition. +type Server struct { + clients map[string]*Client // Map of nicknames to clients +} + +// GetClient returns the client with the given nickname, or nil if not found. +func (s *Server) GetClient(nick string) *Client { + if s == nil || s.clients == nil { + return nil + } + return s.clients[nick] +} + +// Dummy Client type definition for compilation. +// Replace or expand this with your actual Client struct definition. +type Client struct { + // Add fields as needed for your implementation. + server *Server // Reference to the server instance + Nickname string // Nickname of the client + Username string // Username of the client + Hostname string // Hostname of the client +} + +// Prefix returns the client's prefix in the format nick!user@host. +func (c *Client) Prefix() string { + return fmt.Sprintf("%s!%s@%s", c.Nick(), c.User(), c.Host()) +} + +// Nick returns the client's nickname. +func (c *Client) Nick() string { + return c.Nickname +} + +// SetNick sets the client's nickname. +func (c *Client) SetNick(nick string) { + c.Nickname = nick +} + +// User returns the client's username. +func (c *Client) User() string { + return c.Username +} + +// SetUser sets the client's username. +func (c *Client) SetUser(user string) { + c.Username = user +} + +// Host returns the client's hostname. +func (c *Client) Host() string { + return c.Hostname +} + +// SetHost sets the client's hostname. +func (c *Client) SetHost(host string) { + c.Hostname = host +} + +// SendNumeric sends a numeric reply to the client. +// This is a stub implementation for compilation; replace with your actual logic. +func (c *Client) SendNumeric(code int, message string) { + fmt.Printf("Numeric %03d: %s\n", code, message) +} + +// IRC numeric reply codes +const ( + RPL_WELCOME = 001 + RPL_YOURHOST = 002 + RPL_CREATED = 003 + RPL_MYINFO = 004 + RPL_ISUPPORT = 005 + RPL_AWAY = 301 + RPL_UNAWAY = 305 + RPL_NOWAWAY = 306 + RPL_WHOISUSER = 311 + RPL_WHOISSERVER = 312 + RPL_WHOISOPERATOR = 313 + RPL_WHOISIDLE = 317 + RPL_ENDOFWHOIS = 318 + RPL_WHOISCHANNELS = 319 + RPL_LISTSTART = 321 + RPL_LIST = 322 + RPL_LISTEND = 323 + RPL_CHANNELMODEIS = 324 + RPL_NOTOPIC = 331 + RPL_TOPIC = 332 + RPL_TOPICWHOTIME = 333 + RPL_NAMREPLY = 353 + RPL_ENDOFNAMES = 366 + RPL_MOTDSTART = 375 + RPL_MOTD = 372 + RPL_ENDOFMOTD = 376 + RPL_UMODEIS = 221 + RPL_INVITING = 341 + RPL_YOUREOPER = 381 + ERR_NOSUCHNICK = 401 + ERR_NOSUCHSERVER = 402 + ERR_NOSUCHCHANNEL = 403 + ERR_CANNOTSENDTOCHAN = 404 + ERR_TOOMANYCHANNELS = 405 + ERR_WASNOSUCHNICK = 406 + ERR_TOOMANYTARGETS = 407 + ERR_NOORIGIN = 409 + ERR_NORECIPIENT = 411 + ERR_NOTEXTTOSEND = 412 + ERR_UNKNOWNCOMMAND = 421 + ERR_NOMOTD = 422 + ERR_NONICKNAMEGIVEN = 431 + ERR_ERRONEUSNICKNAME = 432 + ERR_NICKNAMEINUSE = 433 + ERR_NICKCOLLISION = 436 + ERR_USERNOTINCHANNEL = 441 + ERR_NOTONCHANNEL = 442 + ERR_USERONCHANNEL = 443 + ERR_NOLOGIN = 444 + ERR_SUMMONDISABLED = 445 + ERR_USERSDISABLED = 446 + ERR_NOTREGISTERED = 451 + ERR_NEEDMOREPARAMS = 461 + ERR_ALREADYREGISTRED = 462 + ERR_NOPERMFORHOST = 463 + ERR_PASSWDMISMATCH = 464 + ERR_YOUREBANNEDCREEP = 465 + ERR_YOUWILLBEBANNED = 466 + ERR_KEYSET = 467 + ERR_CHANNELISFULL = 471 + ERR_UNKNOWNMODE = 472 + ERR_INVITEONLYCHAN = 473 + ERR_BANNEDFROMCHAN = 474 + ERR_BADCHANNELKEY = 475 + ERR_BADCHANMASK = 476 + ERR_NOCHANMODES = 477 + ERR_BANLISTFULL = 478 + ERR_NOPRIVILEGES = 481 + ERR_CHANOPRIVSNEEDED = 482 + ERR_CANTKILLSERVER = 483 + ERR_RESTRICTED = 484 + ERR_UNIQOPPRIVSNEEDED = 485 + ERR_NOOPERHOST = 491 + ERR_UMODEUNKNOWNFLAG = 501 + ERR_USERSDONTMATCH = 502 + RPL_SNOMASK = 8 + RPL_GLOBALNOTICE = 710 + RPL_OPERWALL = 711 +) + +// handleNick handles NICK command +func (c *Client) handleNick(parts []string) { + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "NICK :Not enough parameters") + return + } + + newNick := parts[1] + if len(newNick) > 0 && newNick[0] == ':' { + newNick = newNick[1:] + } + + // Validate nickname + if !isValidNickname(newNick) { + c.SendNumeric(ERR_ERRONEUSNICKNAME, newNick+" :Erroneous nickname") + return + } + + // Check if nick is already in use + if existing := c.server.GetClient(newNick); existing != nil && existing != c { + c.SendNumeric(ERR_NICKNAMEINUSE, newNick+" :Nickname is already in use") + return + } + + oldNick := c.Nick() + c.SetNick(newNick) + + // If already registered, notify channels + if c.IsRegistered() && oldNick != "" { + message := fmt.Sprintf(":%s NICK :%s", c.Prefix(), newNick) + for _, channel := range c.GetChannels() { + channel.Broadcast(message, nil) + } + + // Send snomask notification for nick change + if c.server != nil && oldNick != newNick { + c.server.sendSnomask('n', fmt.Sprintf("Nick change: %s -> %s (%s@%s)", + oldNick, newNick, c.User(), c.Host())) + } + } + + c.checkRegistration() +} + +// Add a Nick field to Client and implement Nick/SetNick methods + +// Add this field to Client struct above (not shown here): +// Nickname string + +func (c *Client) Nick() string { + // Return the client's nickname + return c.Nickname +} + +func (c *Client) SetNick(nick string) { + c.Nickname = nick +} + +// handleUser handles USER command +func (c *Client) handleUser(parts []string) { + if len(parts) < 5 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "USER :Not enough parameters") + return + } + + if c.IsRegistered() { + c.SendNumeric(ERR_ALREADYREGISTRED, ":You may not reregister") + return + } + + c.SetUser(parts[1]) + // parts[2] and parts[3] are ignored (mode and unused) + realname := strings.Join(parts[4:], " ") + if len(realname) > 0 && realname[0] == ':' { + realname = realname[1:] + } + c.SetRealname(realname) + + c.checkRegistration() +} + +// checkRegistration checks if client is ready to be registered +func (c *Client) checkRegistration() { + if !c.IsRegistered() && c.Nick() != "" && c.User() != "" { + c.SetRegistered(true) + c.sendWelcome() + } +} + +// sendWelcome sends welcome messages to newly registered client +func (c *Client) sendWelcome() { + fmt.Printf("DEBUG: sendWelcome called\n") + if c.server == nil { + fmt.Printf("DEBUG: sendWelcome - server is nil\n") + return + } + if c.server.config == nil { + fmt.Printf("DEBUG: sendWelcome - config is nil\n") + return + } + + fmt.Printf("DEBUG: sendWelcome - about to send RPL_WELCOME\n") + c.SendNumeric(RPL_WELCOME, fmt.Sprintf("Welcome to %s, %s", c.server.config.Server.Network, c.Prefix())) + fmt.Printf("DEBUG: sendWelcome - sent RPL_WELCOME\n") + c.SendNumeric(RPL_YOURHOST, fmt.Sprintf("Your host is %s, running version %s", c.server.config.Server.Name, c.server.config.Server.Version)) + c.SendNumeric(RPL_CREATED, "This server was created recently") + c.SendNumeric(RPL_MYINFO, fmt.Sprintf("%s %s o o", c.server.config.Server.Name, c.server.config.Server.Version)) + + // Send MOTD + if len(c.server.config.MOTD) > 0 { + c.SendNumeric(RPL_MOTDSTART, fmt.Sprintf("- %s Message of the Day -", c.server.config.Server.Name)) + for _, line := range c.server.config.MOTD { + c.SendNumeric(RPL_MOTD, fmt.Sprintf("- %s", line)) + } + c.SendNumeric(RPL_ENDOFMOTD, "End of /MOTD command") + } + + // Send snomask notification for new client connection + if c.server != nil { + c.server.sendSnomask('c', fmt.Sprintf("Client connect: %s (%s@%s)", + c.Nick(), c.User(), c.Host())) + } + + fmt.Printf("DEBUG: sendWelcome completed\n") +} + +// handlePing handles PING command +func (c *Client) handlePing(parts []string) { + if len(parts) < 2 { + return + } + + token := parts[1] + if len(token) > 0 && token[0] == ':' { + token = token[1:] + } + + serverName := "localhost" + if c.server != nil && c.server.config != nil { + serverName = c.server.config.Server.Name + } + + c.SendMessage(fmt.Sprintf("PONG %s :%s", serverName, token)) +} + +// handlePong handles PONG command +func (c *Client) handlePong(parts []string) { + // Update the last pong time for ping timeout tracking + // This is used by the client Handler's ping timeout mechanism + c.mu.Lock() + c.lastPong = time.Now() + c.waitingForPong = false + c.mu.Unlock() +} + +// handleJoin handles JOIN command +func (c *Client) handleJoin(parts []string) { + if !c.IsRegistered() { + c.SendNumeric(ERR_NOTREGISTERED, ":You have not registered") + return + } + + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "JOIN :Not enough parameters") + return + } + + channelNames := strings.Split(parts[1], ",") + keys := []string{} + if len(parts) > 2 { + keys = strings.Split(parts[2], ",") + } + + for i, channelName := range channelNames { + if channelName == "0" { + // Leave all channels + for _, channel := range c.GetChannels() { + c.handlePartChannel(channel.Name(), "Leaving all channels") + } + continue + } + + if !isValidChannelName(channelName) { + c.SendNumeric(ERR_NOSUCHCHANNEL, channelName+" :No such channel") + continue + } + + channel := c.server.GetOrCreateChannel(channelName) + + // Check if already in channel + if c.IsInChannel(channelName) { + continue + } + + // Check channel modes and limits + key := "" + if i < len(keys) { + key = keys[i] + } + + if channel.HasMode('k') && channel.Key() != key { + c.SendNumeric(ERR_BADCHANNELKEY, channelName+" :Cannot join channel (+k)") + continue + } + + if channel.HasMode('l') && channel.UserCount() >= channel.Limit() { + c.SendNumeric(ERR_CHANNELISFULL, channelName+" :Cannot join channel (+l)") + continue + } + + // Join the channel + channel.AddClient(c) + c.AddChannel(channel) + + message := fmt.Sprintf(":%s JOIN :%s", c.Prefix(), channelName) + channel.Broadcast(message, nil) + + // Send topic if exists + if channel.Topic() != "" { + c.SendNumeric(RPL_TOPIC, channelName+" :"+channel.Topic()) + c.SendNumeric(RPL_TOPICWHOTIME, fmt.Sprintf("%s %s %d", channelName, channel.TopicBy(), channel.TopicTime().Unix())) + } + + // Send names list + c.sendNames(channel) + } +} + +// handlePart handles PART command +func (c *Client) handlePart(parts []string) { + if !c.IsRegistered() { + c.SendNumeric(ERR_NOTREGISTERED, ":You have not registered") + return + } + + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "PART :Not enough parameters") + return + } + + channelNames := strings.Split(parts[1], ",") + reason := "Leaving" + if len(parts) > 2 { + reason = strings.Join(parts[2:], " ") + if len(reason) > 0 && reason[0] == ':' { + reason = reason[1:] + } + } + + for _, channelName := range channelNames { + c.handlePartChannel(channelName, reason) + } +} + +func (c *Client) handlePartChannel(channelName, reason string) { + if !c.IsInChannel(channelName) { + c.SendNumeric(ERR_NOTONCHANNEL, channelName+" :You're not on that channel") + return + } + + channel := c.server.GetChannel(channelName) + if channel == nil { + return + } + + message := fmt.Sprintf(":%s PART %s :%s", c.Prefix(), channelName, reason) + channel.Broadcast(message, nil) + + channel.RemoveClient(c) + c.RemoveChannel(channelName) + + // Remove empty channel + if channel.UserCount() == 0 { + c.server.RemoveChannel(channelName) + } +} + +// handlePrivmsg handles PRIVMSG command +func (c *Client) handlePrivmsg(parts []string) { + if !c.IsRegistered() { + c.SendNumeric(ERR_NOTREGISTERED, ":You have not registered") + return + } + + if len(parts) < 2 { + c.SendNumeric(ERR_NORECIPIENT, ":No recipient given (PRIVMSG)") + return + } + + if len(parts) < 3 { + c.SendNumeric(ERR_NOTEXTTOSEND, ":No text to send") + return + } + + target := parts[1] + message := strings.Join(parts[2:], " ") + if len(message) > 0 && message[0] == ':' { + message = message[1:] + } + + if isChannelName(target) { + // Channel message + channel := c.server.GetChannel(target) + if channel == nil { + c.SendNumeric(ERR_NOSUCHCHANNEL, target+" :No such channel") + return + } + + if !c.IsInChannel(target) { + c.SendNumeric(ERR_CANNOTSENDTOCHAN, target+" :Cannot send to channel") + return + } + + // Check if user can send messages to this channel (moderated mode check) + if !channel.CanSendMessage(c) { + c.SendNumeric(ERR_CANNOTSENDTOCHAN, target+" :Cannot send to channel (+m)") + return + } + + msg := fmt.Sprintf(":%s PRIVMSG %s :%s", c.Prefix(), target, message) + channel.Broadcast(msg, c) + } else { + // Private message + targetClient := c.server.GetClient(target) + if targetClient == nil { + c.SendNumeric(ERR_NOSUCHNICK, target+" :No such nick/channel") + return + } + + if targetClient.Away() != "" { + c.SendNumeric(RPL_AWAY, fmt.Sprintf("%s :%s", target, targetClient.Away())) + } + + msg := fmt.Sprintf(":%s PRIVMSG %s :%s", c.Prefix(), target, message) + targetClient.SendMessage(msg) + } +} + +// handleNotice handles NOTICE command +func (c *Client) handleNotice(parts []string) { + if !c.IsRegistered() { + return // NOTICE should not generate error responses + } + + if len(parts) < 3 { + return + } + + target := parts[1] + message := strings.Join(parts[2:], " ") + if len(message) > 0 && message[0] == ':' { + message = message[1:] + } + + if isChannelName(target) { + // Channel notice + channel := c.server.GetChannel(target) + if channel == nil || !c.IsInChannel(target) { + return + } + + msg := fmt.Sprintf(":%s NOTICE %s :%s", c.Prefix(), target, message) + channel.Broadcast(msg, c) + } else { + // Private notice + targetClient := c.server.GetClient(target) + if targetClient == nil { + return + } + + msg := fmt.Sprintf(":%s NOTICE %s :%s", c.Prefix(), target, message) + targetClient.SendMessage(msg) + } +} + +// handleWho handles WHO command +func (c *Client) handleWho(parts []string) { + if !c.IsRegistered() { + c.SendNumeric(ERR_NOTREGISTERED, ":You have not registered") + return + } + + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "WHO :Not enough parameters") + return + } + + target := parts[1] + + if isChannelName(target) { + channel := c.server.GetChannel(target) + if channel == nil { + c.SendNumeric(ERR_NOSUCHCHANNEL, target+" :No such channel") + return + } + + for _, client := range channel.GetClients() { + flags := "" + if client.IsOper() { + flags += "*" + } + if client.Away() != "" { + flags += "G" + } else { + flags += "H" + } + if channel.IsOperator(client) { + flags += "@" + } else if channel.IsVoice(client) { + flags += "+" + } + + c.SendNumeric(352, fmt.Sprintf("%s %s %s %s %s %s :0 %s", + target, client.User(), client.Host(), c.server.config.Server.Name, + client.Nick(), flags, client.Realname())) + } + } + + c.SendNumeric(315, target+" :End of /WHO list") +} + +// handleWhois handles WHOIS command +func (c *Client) handleWhois(parts []string) { + if !c.IsRegistered() { + c.SendNumeric(ERR_NOTREGISTERED, ":You have not registered") + return + } + + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "WHOIS :Not enough parameters") + return + } + + nick := parts[1] + target := c.server.GetClient(nick) + if target == nil { + c.SendNumeric(ERR_NOSUCHNICK, nick+" :No such nick") + return + } + + c.SendNumeric(RPL_WHOISUSER, fmt.Sprintf("%s %s %s * :%s", + target.Nick(), target.User(), target.Host(), target.Realname())) + + c.SendNumeric(RPL_WHOISSERVER, fmt.Sprintf("%s %s :%s", + target.Nick(), c.server.config.Server.Name, c.server.config.Server.Description)) + + if target.IsOper() { + c.SendNumeric(RPL_WHOISOPERATOR, target.Nick()+" :is an IRC operator") + } + + if target.Away() != "" { + c.SendNumeric(RPL_AWAY, fmt.Sprintf("%s :%s", target.Nick(), target.Away())) + } + + // Send channels + var channels []string + for _, channel := range target.GetChannels() { + channelName := channel.Name() + if channel.IsOperator(target) { + channelName = "@" + channelName + } else if channel.IsVoice(target) { + channelName = "+" + channelName + } + channels = append(channels, channelName) + } + if len(channels) > 0 { + c.SendNumeric(RPL_WHOISCHANNELS, fmt.Sprintf("%s :%s", target.Nick(), strings.Join(channels, " "))) + } + + // Show user modes if the requester is an operator or the target user + if c.IsOper() || c.Nick() == target.Nick() { + modes := target.GetModes() + if modes != "" { + c.SendMessage(fmt.Sprintf(":%s 379 %s %s :is using modes %s", + c.server.config.Server.Name, c.Nick(), target.Nick(), modes)) + } + } + + // Show SSL status + if target.IsSSL() { + c.SendMessage(fmt.Sprintf(":%s 671 %s %s :is using a secure connection", + c.server.config.Server.Name, c.Nick(), target.Nick())) + } + + c.SendNumeric(RPL_ENDOFWHOIS, target.Nick()+" :End of /WHOIS list") +} + +// handleNames handles NAMES command +func (c *Client) handleNames(parts []string) { + if !c.IsRegistered() { + c.SendNumeric(ERR_NOTREGISTERED, ":You have not registered") + return + } + + if len(parts) < 2 { + // Send names for all channels + for _, channel := range c.server.GetChannels() { + if c.IsInChannel(channel.Name()) { + c.sendNames(channel) + } + } + return + } + + channelNames := strings.Split(parts[1], ",") + for _, channelName := range channelNames { + channel := c.server.GetChannel(channelName) + if channel != nil && c.IsInChannel(channelName) { + c.sendNames(channel) + } + } +} + +func (c *Client) sendNames(channel *Channel) { + var names []string + for _, client := range channel.GetClients() { + name := client.Nick() + if channel.IsOwner(client) { + name = "~" + name + } else if channel.IsOperator(client) { + name = "@" + name + } else if channel.IsHalfop(client) { + name = "%" + name + } else if channel.IsVoice(client) { + name = "+" + name + } + names = append(names, name) + } + + symbol := "=" + if channel.HasMode('s') { + symbol = "@" + } else if channel.HasMode('p') { + symbol = "*" + } + + c.SendNumeric(RPL_NAMREPLY, fmt.Sprintf("%s %s :%s", symbol, channel.Name(), strings.Join(names, " "))) + c.SendNumeric(RPL_ENDOFNAMES, channel.Name()+" :End of /NAMES list") +} + +// handleQuit handles QUIT command +func (c *Client) handleQuit(parts []string) { + reason := "Client quit" + if len(parts) > 1 { + reason = strings.Join(parts[1:], " ") + if len(reason) > 0 && reason[0] == ':' { + reason = reason[1:] + } + } + + c.server.RemoveClient(c) +} + +// handleMode handles MODE command +func (c *Client) handleMode(parts []string) { + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "MODE :Not enough parameters") + return + } + + target := parts[1] + + // Handle user mode requests + if !isChannelName(target) { + if target != c.Nick() { + c.SendNumeric(ERR_USERSDONTMATCH, ":Cannot change mode for other users") + return + } + + // If no mode changes specified, return current user modes + if len(parts) == 2 { + modes := c.GetModes() + if modes == "" { + modes = "+" + } + c.SendNumeric(RPL_UMODEIS, modes) + return + } + + // Parse user mode changes + modeString := parts[2] + adding := true + var appliedModes []string + + for _, char := range modeString { + switch char { + case '+': + adding = true + case '-': + adding = false + case 'i': // invisible + c.SetMode('i', adding) + if adding { + appliedModes = append(appliedModes, "+i") + } else { + appliedModes = append(appliedModes, "-i") + } + case 'w': // wallops + c.SetMode('w', adding) + if adding { + appliedModes = append(appliedModes, "+w") + } else { + appliedModes = append(appliedModes, "-w") + } + case 's': // server notices (requires oper) + if !c.IsOper() && adding { + continue // silently ignore for non-opers + } + c.SetMode('s', adding) + if adding { + appliedModes = append(appliedModes, "+s") + } else { + appliedModes = append(appliedModes, "-s") + } + case 'o': // operator (cannot be set manually) + if adding { + c.SendNumeric(ERR_UMODEUNKNOWNFLAG, ":Unknown MODE flag") + } else { + // Allow de-opering + c.SetOper(false) + c.SetMode('o', false) + appliedModes = append(appliedModes, "-o") + // Clear snomasks when de-opering + c.snomasks = make(map[rune]bool) + c.sendSnomask('o', fmt.Sprintf("%s is no longer an IRC operator", c.Nick())) + } + case 'r': // registered (cannot be set manually, services only) + c.SendNumeric(ERR_UMODEUNKNOWNFLAG, ":Unknown MODE flag") + case 'x': // host masking (TechIRCd special) + c.SetMode('x', adding) + if adding { + appliedModes = append(appliedModes, "+x") + // TODO: Implement host masking + } else { + appliedModes = append(appliedModes, "-x") + } + case 'z': // SSL/TLS (automatic, cannot be manually set) + if c.IsSSL() { + c.SetMode('z', true) + } + // Ignore attempts to manually set/unset + case 'B': // bot flag (TechIRCd special) + c.SetMode('B', adding) + if adding { + appliedModes = append(appliedModes, "+B") + } else { + appliedModes = append(appliedModes, "-B") + } + default: + c.SendNumeric(ERR_UMODEUNKNOWNFLAG, ":Unknown MODE flag") + } + } + + // Send mode changes back to user + if len(appliedModes) > 0 { + modeStr := strings.Join(appliedModes, "") + c.SendMessage(fmt.Sprintf(":%s MODE %s :%s", c.Nick(), c.Nick(), modeStr)) + } + return + } + + // Handle channel mode requests + channel := c.server.GetChannel(target) + if channel == nil { + c.SendNumeric(ERR_NOSUCHCHANNEL, target+" :No such channel") + return + } + + if !c.IsInChannel(target) { + c.SendNumeric(ERR_NOTONCHANNEL, target+" :You're not on that channel") + return + } + + // If no mode changes specified, return current channel modes + if len(parts) == 2 { + modes := channel.GetModes() + if modes == "" { + modes = "+" + } + c.SendNumeric(RPL_CHANNELMODEIS, fmt.Sprintf("%s %s", target, modes)) + return + } + + // Parse mode changes + modeString := parts[2] + args := parts[3:] + argIndex := 0 + + // Check if user has operator privileges (required for most mode changes) + if !channel.IsOwner(c) && !channel.IsOperator(c) && !channel.IsHalfop(c) && !c.IsOper() { + c.SendNumeric(ERR_CHANOPRIVSNEEDED, target+" :You're not channel operator") + return + } + + adding := true + var appliedModes []string + var appliedArgs []string + + for _, char := range modeString { + switch char { + case '+': + adding = true + case '-': + adding = false + case 'o': // operator + if argIndex >= len(args) { + continue + } + targetNick := args[argIndex] + argIndex++ + + targetClient := c.server.GetClient(targetNick) + if targetClient == nil { + c.SendNumeric(ERR_NOSUCHNICK, targetNick+" :No such nick/channel") + continue + } + + if !targetClient.IsInChannel(target) { + c.SendNumeric(ERR_USERNOTINCHANNEL, fmt.Sprintf("%s %s :They aren't on that channel", targetNick, target)) + continue + } + + channel.SetOperator(targetClient, adding) + if adding { + appliedModes = append(appliedModes, "+o") + } else { + appliedModes = append(appliedModes, "-o") + } + appliedArgs = append(appliedArgs, targetNick) + + case 'v': // voice + if argIndex >= len(args) { + continue + } + targetNick := args[argIndex] + argIndex++ + + targetClient := c.server.GetClient(targetNick) + if targetClient == nil { + c.SendNumeric(ERR_NOSUCHNICK, targetNick+" :No such nick/channel") + continue + } + + if !targetClient.IsInChannel(target) { + c.SendNumeric(ERR_USERNOTINCHANNEL, fmt.Sprintf("%s %s :They aren't on that channel", targetNick, target)) + continue + } + + channel.SetVoice(targetClient, adding) + if adding { + appliedModes = append(appliedModes, "+v") + } else { + appliedModes = append(appliedModes, "-v") + } + appliedArgs = append(appliedArgs, targetNick) + + case 'h': // halfop + if argIndex >= len(args) { + continue + } + targetNick := args[argIndex] + argIndex++ + + targetClient := c.server.GetClient(targetNick) + if targetClient == nil { + c.SendNumeric(ERR_NOSUCHNICK, targetNick+" :No such nick/channel") + continue + } + + if !targetClient.IsInChannel(target) { + c.SendNumeric(ERR_USERNOTINCHANNEL, fmt.Sprintf("%s %s :They aren't on that channel", targetNick, target)) + continue + } + + channel.SetHalfop(targetClient, adding) + if adding { + appliedModes = append(appliedModes, "+h") + } else { + appliedModes = append(appliedModes, "-h") + } + appliedArgs = append(appliedArgs, targetNick) + + case 'q': // owner/founder + if argIndex >= len(args) { + continue + } + targetNick := args[argIndex] + argIndex++ + + // Only existing owners can grant/remove owner status + if !channel.IsOwner(c) && !c.IsOper() { + c.SendNumeric(ERR_CHANOPRIVSNEEDED, target+" :You're not channel owner") + continue + } + + targetClient := c.server.GetClient(targetNick) + if targetClient == nil { + c.SendNumeric(ERR_NOSUCHNICK, targetNick+" :No such nick/channel") + continue + } + + if !targetClient.IsInChannel(target) { + c.SendNumeric(ERR_USERNOTINCHANNEL, fmt.Sprintf("%s %s :They aren't on that channel", targetNick, target)) + continue + } + + channel.SetOwner(targetClient, adding) + if adding { + appliedModes = append(appliedModes, "+q") + } else { + appliedModes = append(appliedModes, "-q") + } + appliedArgs = append(appliedArgs, targetNick) + + case 'm': // moderated + channel.SetMode('m', adding) + if adding { + appliedModes = append(appliedModes, "+m") + } else { + appliedModes = append(appliedModes, "-m") + } + + case 'n': // no external messages + channel.SetMode('n', adding) + if adding { + appliedModes = append(appliedModes, "+n") + } else { + appliedModes = append(appliedModes, "-n") + } + + case 't': // topic restriction + channel.SetMode('t', adding) + if adding { + appliedModes = append(appliedModes, "+t") + } else { + appliedModes = append(appliedModes, "-t") + } + + case 'i': // invite only + channel.SetMode('i', adding) + if adding { + appliedModes = append(appliedModes, "+i") + } else { + appliedModes = append(appliedModes, "-i") + } + + case 's': // secret + channel.SetMode('s', adding) + if adding { + appliedModes = append(appliedModes, "+s") + } else { + appliedModes = append(appliedModes, "-s") + } + + case 'p': // private + channel.SetMode('p', adding) + if adding { + appliedModes = append(appliedModes, "+p") + } else { + appliedModes = append(appliedModes, "-p") + } + + case 'k': // key (password) + if adding { + if argIndex >= len(args) { + continue + } + key := args[argIndex] + argIndex++ + channel.SetKey(key) + channel.SetMode('k', true) + appliedModes = append(appliedModes, "+k") + appliedArgs = append(appliedArgs, key) + } else { + channel.SetKey("") + channel.SetMode('k', false) + appliedModes = append(appliedModes, "-k") + } + + case 'l': // limit + if adding { + if argIndex >= len(args) { + continue + } + limitStr := args[argIndex] + argIndex++ + // Parse limit (simplified - should validate it's a number) + limit := 0 + fmt.Sscanf(limitStr, "%d", &limit) + if limit > 0 { + channel.SetLimit(limit) + channel.SetMode('l', true) + appliedModes = append(appliedModes, "+l") + appliedArgs = append(appliedArgs, limitStr) + } + } else { + channel.SetLimit(0) + channel.SetMode('l', false) + appliedModes = append(appliedModes, "-l") + } + + case 'b': // ban (enhanced with extended ban types) + if argIndex >= len(args) { + // List bans (TODO: implement ban list display) + continue + } + mask := args[argIndex] + argIndex++ + + // Check for extended ban types (e.g., ~q:nick!user@host for quiet) + if strings.HasPrefix(mask, "~") && len(mask) > 2 && mask[2] == ':' { + banType := mask[1] // The character after ~ + banMask := mask[3:] // The mask after ~x: + + switch banType { + case 'q': // Quiet ban + if adding { + // Add to quiet list + channel.quietList = append(channel.quietList, banMask) + appliedModes = append(appliedModes, "+b") + appliedArgs = append(appliedArgs, mask) + + // Send snomask to opers + if c.IsOper() { + c.server.sendSnomask('x', fmt.Sprintf("%s set quiet ban %s on %s", c.Nick(), banMask, target)) + } + } else { + // Remove from quiet list + for i, quiet := range channel.quietList { + if quiet == banMask { + channel.quietList = append(channel.quietList[:i], channel.quietList[i+1:]...) + appliedModes = append(appliedModes, "-b") + appliedArgs = append(appliedArgs, mask) + + // Send snomask to opers + if c.IsOper() { + c.server.sendSnomask('x', fmt.Sprintf("%s removed quiet ban %s on %s", c.Nick(), banMask, target)) + } + break + } + } + } + default: + // Unknown extended ban type - treat as regular ban for now + if adding { + channel.banList = append(channel.banList, mask) + appliedModes = append(appliedModes, "+b") + } else { + for i, ban := range channel.banList { + if ban == mask { + channel.banList = append(channel.banList[:i], channel.banList[i+1:]...) + appliedModes = append(appliedModes, "-b") + break + } + } + } + appliedArgs = append(appliedArgs, mask) + } + } else { + // Regular ban + if adding { + channel.banList = append(channel.banList, mask) + appliedModes = append(appliedModes, "+b") + } else { + for i, ban := range channel.banList { + if ban == mask { + channel.banList = append(channel.banList[:i], channel.banList[i+1:]...) + appliedModes = append(appliedModes, "-b") + break + } + } + } + appliedArgs = append(appliedArgs, mask) + } + + default: + // Unknown mode - ignore for now + } + } + + // Broadcast mode changes to all channel members + if len(appliedModes) > 0 { + modeChangeMsg := fmt.Sprintf("MODE %s %s", target, strings.Join(appliedModes, "")) + if len(appliedArgs) > 0 { + modeChangeMsg += " " + strings.Join(appliedArgs, " ") + } + + for _, client := range channel.GetClients() { + client.SendFrom(c.Prefix(), modeChangeMsg) + } + } +} + +// handleTopic handles TOPIC command +func (c *Client) handleTopic(parts []string) { + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "TOPIC :Not enough parameters") + return + } + + channelName := parts[1] + if !isChannelName(channelName) { + c.SendNumeric(ERR_NOSUCHCHANNEL, channelName+" :No such channel") + return + } + + channel := c.server.GetChannel(channelName) + if channel == nil { + c.SendNumeric(ERR_NOSUCHCHANNEL, channelName+" :No such channel") + return + } + + if !c.IsInChannel(channelName) { + c.SendNumeric(ERR_NOTONCHANNEL, channelName+" :You're not on that channel") + return + } + + // If no topic provided, return current topic + if len(parts) == 2 { + topic := channel.Topic() + if topic == "" { + c.SendNumeric(RPL_NOTOPIC, channelName+" :No topic is set") + } else { + c.SendNumeric(RPL_TOPIC, fmt.Sprintf("%s :%s", channelName, topic)) + } + return + } + + // Check if user can set topic (for now, anyone in channel can) + // TODO: Add proper +t mode checking + newTopic := strings.Join(parts[2:], " ") + if len(newTopic) > 0 && newTopic[0] == ':' { + newTopic = newTopic[1:] + } + + channel.SetTopic(newTopic, c.Nick()) + + // Broadcast topic change to all channel members + for _, client := range channel.GetClients() { + client.SendFrom(c.Prefix(), fmt.Sprintf("TOPIC %s :%s", channelName, newTopic)) + } +} + +// handleAway handles AWAY command +func (c *Client) handleAway(parts []string) { + if len(parts) == 1 { + // Remove away status + c.SetAway("") + c.SendNumeric(RPL_UNAWAY, ":You are no longer marked as being away") + return + } + + // Set away message + awayMsg := strings.Join(parts[1:], " ") + if len(awayMsg) > 0 && awayMsg[0] == ':' { + awayMsg = awayMsg[1:] + } + + c.SetAway(awayMsg) + c.SendNumeric(RPL_NOWAWAY, ":You have been marked as being away") +} + +// handleList handles LIST command +func (c *Client) handleList(parts []string) { + c.SendNumeric(RPL_LISTSTART, "Channel :Users Name") + + for _, channel := range c.server.GetChannels() { + // For now, show all channels (TODO: Add proper mode checking for secret channels) + userCount := len(channel.GetClients()) + topic := channel.Topic() + if topic == "" { + topic = "" + } + c.SendNumeric(RPL_LIST, fmt.Sprintf("%s %d :%s", channel.Name(), userCount, topic)) + } + + c.SendNumeric(RPL_LISTEND, ":End of /LIST") +} + +// handleInvite handles INVITE command +func (c *Client) handleInvite(parts []string) { + if len(parts) < 3 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "INVITE :Not enough parameters") + return + } + + nick := parts[1] + channelName := parts[2] + + target := c.server.GetClient(nick) + if target == nil { + c.SendNumeric(ERR_NOSUCHNICK, nick+" :No such nick/channel") + return + } + + if !isChannelName(channelName) { + c.SendNumeric(ERR_NOSUCHCHANNEL, channelName+" :No such channel") + return + } + + channel := c.server.GetChannel(channelName) + if channel == nil { + c.SendNumeric(ERR_NOSUCHCHANNEL, channelName+" :No such channel") + return + } + + if !c.IsInChannel(channelName) { + c.SendNumeric(ERR_NOTONCHANNEL, channelName+" :You're not on that channel") + return + } + + if target.IsInChannel(channelName) { + c.SendNumeric(ERR_USERONCHANNEL, fmt.Sprintf("%s %s :is already on channel", nick, channelName)) + return + } + + // TODO: Check if user has operator privileges for invite-only channels + + // Send invite to target + target.SendFrom(c.Prefix(), fmt.Sprintf("INVITE %s %s", target.Nick(), channelName)) + c.SendNumeric(RPL_INVITING, fmt.Sprintf("%s %s", target.Nick(), channelName)) +} + +// handleKick handles KICK command +func (c *Client) handleKick(parts []string) { + if len(parts) < 3 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "KICK :Not enough parameters") + return + } + + channelName := parts[1] + nick := parts[2] + reason := "No reason given" + if len(parts) > 3 { + reason = strings.Join(parts[3:], " ") + if len(reason) > 0 && reason[0] == ':' { + reason = reason[1:] + } + } + + if !isChannelName(channelName) { + c.SendNumeric(ERR_NOSUCHCHANNEL, channelName+" :No such channel") + return + } + + channel := c.server.GetChannel(channelName) + if channel == nil { + c.SendNumeric(ERR_NOSUCHCHANNEL, channelName+" :No such channel") + return + } + + if !c.IsInChannel(channelName) { + c.SendNumeric(ERR_NOTONCHANNEL, channelName+" :You're not on that channel") + return + } + + target := c.server.GetClient(nick) + if target == nil { + c.SendNumeric(ERR_NOSUCHNICK, nick+" :No such nick/channel") + return + } + + if !target.IsInChannel(channelName) { + c.SendNumeric(ERR_USERNOTINCHANNEL, fmt.Sprintf("%s %s :They aren't on that channel", nick, channelName)) + return + } + + // TODO: Check if user has operator privileges + // For now, allow anyone to kick (will fix with proper channel modes) + + // Broadcast kick to all channel members + kickMsg := fmt.Sprintf("KICK %s %s :%s", channelName, target.Nick(), reason) + for _, client := range channel.GetClients() { + client.SendFrom(c.Prefix(), kickMsg) + } + + // Remove target from channel + channel.RemoveClient(target) + target.RemoveChannel(channelName) +} + +// handleKill handles KILL command (operator only) +func (c *Client) handleKill(parts []string) { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "KILL :Not enough parameters") + return + } + + nick := parts[1] + reason := "Killed by operator" + if len(parts) > 2 { + reason = strings.Join(parts[2:], " ") + if len(reason) > 0 && reason[0] == ':' { + reason = reason[1:] + } + } + + target := c.server.GetClient(nick) + if target == nil { + c.SendNumeric(ERR_NOSUCHNICK, nick+" :No such nick/channel") + return + } + + // Can't kill other operators + if target.IsOper() { + c.SendNumeric(ERR_CANTKILLSERVER, ":You can't kill other operators") + return + } + + // Send kill message to target and disconnect + target.SendMessage(fmt.Sprintf("ERROR :Killed (%s (%s))", c.Nick(), reason)) + + // Broadcast to other operators + for _, client := range c.server.GetClients() { + if client.IsOper() && client != c { + client.SendMessage(fmt.Sprintf(":%s WALLOPS :%s killed %s (%s)", + c.server.config.Server.Name, c.Nick(), target.Nick(), reason)) + } + } + + // Disconnect the target + target.conn.Close() +} + +// handleOper handles OPER command +func (c *Client) handleOper(parts []string) { + if len(parts) < 3 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "OPER :Not enough parameters") + return + } + + if c.server == nil || c.server.config == nil { + c.SendNumeric(ERR_NOOPERHOST, ":No O-lines for your host") + return + } + + name := parts[1] + password := parts[2] + + // Check if opers are enabled + if !c.server.config.Features.EnableOper { + c.SendNumeric(ERR_NOOPERHOST, ":O-lines are disabled") + return + } + + // Find matching oper configuration + for _, oper := range c.server.config.Opers { + if oper.Name == name && oper.Password == password { + // Check host mask (simplified - just check if it matches *@localhost for now) + if oper.Host == "*@localhost" || oper.Host == "*@*" { + c.SetOper(true) + + // Set operator user mode + c.SetMode('o', true) + c.SetMode('s', true) // Enable server notices by default + c.SetMode('w', true) // Enable wallops by default + + // Set default snomasks for new operators + c.SetSnomask('c', true) // Client connects/disconnects + c.SetSnomask('o', true) // Oper-up messages + c.SetSnomask('s', true) // Server messages + + c.SendNumeric(RPL_YOUREOPER, ":You are now an IRC operator") + c.SendNumeric(RPL_SNOMASK, fmt.Sprintf("%s :Server notice mask", c.GetSnomasks())) + + // Send mode change notification + c.SendMessage(fmt.Sprintf(":%s MODE %s :+osw", c.Nick(), c.Nick())) + + // Send snomask to other operators + c.sendSnomask('o', fmt.Sprintf("%s (%s@%s) is now an IRC operator", c.Nick(), c.User(), c.Host())) + return + } + } + } + + c.SendNumeric(ERR_PASSWDMISMATCH, ":Password incorrect") +} + +// handleSnomask handles SNOMASK command (server notice masks for operators) +func (c *Client) handleSnomask(parts []string) { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + if len(parts) < 2 { + // Show current snomasks + current := c.GetSnomasks() + if current == "" { + current = "+" + } + c.SendNumeric(RPL_SNOMASK, fmt.Sprintf("%s :Server notice mask", current)) + return + } + + modeString := parts[1] + adding := true + changed := false + + for _, char := range modeString { + switch char { + case '+': + adding = true + case '-': + adding = false + case 'c': // Client connects/disconnects + c.SetSnomask('c', adding) + changed = true + case 'k': // Kill messages + c.SetSnomask('k', adding) + changed = true + case 'o': // Oper-up messages + c.SetSnomask('o', adding) + changed = true + case 'x': // X-line (ban) messages + c.SetSnomask('x', adding) + changed = true + case 'f': // Flood messages + c.SetSnomask('f', adding) + changed = true + case 'n': // Nick changes + c.SetSnomask('n', adding) + changed = true + case 's': // Server messages + c.SetSnomask('s', adding) + changed = true + case 'd': // Debug messages (TechIRCd special) + c.SetSnomask('d', adding) + changed = true + } + } + + if changed { + current := c.GetSnomasks() + if current == "" { + current = "+" + } + c.SendNumeric(RPL_SNOMASK, fmt.Sprintf("%s :Server notice mask", current)) + } +} + +// handleGlobalNotice handles GLOBALNOTICE command (TechIRCd special oper command) +func (c *Client) handleGlobalNotice(parts []string) { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "GLOBALNOTICE :Not enough parameters") + return + } + + message := strings.Join(parts[1:], " ") + if len(message) > 0 && message[0] == ':' { + message = message[1:] + } + + // Send global notice to all users + for _, client := range c.server.GetClients() { + client.SendMessage(fmt.Sprintf(":%s NOTICE %s :[GLOBAL] %s", + c.server.config.Server.Name, client.Nick(), message)) + } + + // Send snomask to operators watching global notices + c.sendSnomask('s', fmt.Sprintf("Global notice from %s: %s", c.Nick(), message)) +} + +// handleWallops handles WALLOPS command (send to users with +w mode) +func (c *Client) handleWallops(parts []string) { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "WALLOPS :Not enough parameters") + return + } + + message := strings.Join(parts[1:], " ") + if len(message) > 0 && message[0] == ':' { + message = message[1:] + } + + // Send to all users with +w mode + for _, client := range c.server.GetClients() { + if client.HasMode('w') { + client.SendMessage(fmt.Sprintf(":%s WALLOPS :%s", c.Nick(), message)) + } + } +} + +// handleOperWall handles OPERWALL command (message to all operators) +func (c *Client) handleOperWall(parts []string) { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + if len(parts) < 2 { + c.SendNumeric(ERR_NEEDMOREPARAMS, "OPERWALL :Not enough parameters") + return + } + + message := strings.Join(parts[1:], " ") + if len(message) > 0 && message[0] == ':' { + message = message[1:] + } + + // Send to all operators + for _, client := range c.server.GetClients() { + if client.IsOper() { + client.SendMessage(fmt.Sprintf(":%s WALLOPS :%s", c.Nick(), message)) + } + } +} + +// handleRehash handles REHASH command (reload configuration) +func (c *Client) handleRehash(parts []string) { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + // Reload configuration + if c.server != nil { + err := c.server.ReloadConfig() + if err != nil { + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** REHASH failed: %s", + c.server.config.Server.Name, c.Nick(), err.Error())) + c.sendSnomask('s', fmt.Sprintf("REHASH failed by %s: %s", c.Nick(), err.Error())) + } else { + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Configuration reloaded successfully", + c.server.config.Server.Name, c.Nick())) + c.sendSnomask('s', fmt.Sprintf("Configuration reloaded by %s", c.Nick())) + } + } +} + +// handleTrace handles TRACE command (show server connection tree) +func (c *Client) handleTrace(parts []string) { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + // Show basic server info (simplified implementation) + c.SendMessage(fmt.Sprintf(":%s 200 %s Link %s %s %s", + c.server.config.Server.Name, c.Nick(), + c.server.config.Server.Version, + c.server.config.Server.Name, + "TechIRCd")) + + clientCount := len(c.server.GetClients()) + c.SendMessage(fmt.Sprintf(":%s 262 %s %s :End of TRACE with %d clients", + c.server.config.Server.Name, c.Nick(), + c.server.config.Server.Name, clientCount)) +} + +// handleSpy handles SPY command - covert surveillance and stealth operations +func (c *Client) handleSpy(parts []string) { + if !c.IsOper() { + c.SendNumeric(ERR_NOPRIVILEGES, ":Permission Denied- You're not an IRC operator") + return + } + + if len(parts) < 2 { + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** SPY Usage: SPY ", + c.server.config.Server.Name, c.Nick())) + return + } + + command := strings.ToLower(parts[1]) + + switch command { + case "hide": + c.handleSpyHide(parts[2:]) + case "watch": + c.handleSpyWatch(parts[2:]) + case "track": + c.handleSpyTrack(parts[2:]) + case "listen": + c.handleSpyListen(parts[2:]) + case "cloak": + c.handleSpyCloak(parts[2:]) + case "ghost": + c.handleSpyGhost(parts[2:]) + case "shadow": + c.handleSpyShadow(parts[2:]) + case "status": + c.handleSpyStatus() + default: + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Unknown SPY command: %s", + c.server.config.Server.Name, c.Nick(), command)) + } +} + +// handleSpyHide - become invisible to most commands and lists +func (c *Client) handleSpyHide(args []string) { + if len(args) == 0 || strings.ToLower(args[0]) == "on" { + c.SetMode('H', true) // Hidden mode + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** You are now HIDDEN from WHO, WHOIS, and NAMES", + c.server.config.Server.Name, c.Nick())) + c.sendSnomask('d', fmt.Sprintf("Operator %s has entered STEALTH mode", c.Nick())) + } else if strings.ToLower(args[0]) == "off" { + c.SetMode('H', false) + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** You are now VISIBLE again", + c.server.config.Server.Name, c.Nick())) + c.sendSnomask('d', fmt.Sprintf("Operator %s has left STEALTH mode", c.Nick())) + } +} + +// handleSpyWatch - monitor a specific user's activities +func (c *Client) handleSpyWatch(args []string) { + if len(args) < 1 { + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Usage: SPY WATCH ", + c.server.config.Server.Name, c.Nick())) + return + } + + target := args[0] + if strings.ToLower(target) == "off" { + // TODO: Remove from watch list + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Surveillance disabled", + c.server.config.Server.Name, c.Nick())) + return + } + + targetClient := c.server.GetClient(target) + if targetClient == nil { + c.SendNumeric(ERR_NOSUCHNICK, target+" :No such nick/channel") + return + } + + // TODO: Add to watch list + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Now watching %s (%s@%s)", + c.server.config.Server.Name, c.Nick(), target, targetClient.User(), targetClient.Host())) + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Target is in channels: %s", + c.server.config.Server.Name, c.Nick(), c.getChannelList(targetClient))) +} + +// handleSpyTrack - get real-time location and movement tracking +func (c *Client) handleSpyTrack(args []string) { + if len(args) < 1 { + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Usage: SPY TRACK ", + c.server.config.Server.Name, c.Nick())) + return + } + + target := args[0] + targetClient := c.server.GetClient(target) + if targetClient == nil { + c.SendNumeric(ERR_NOSUCHNICK, target+" :No such nick/channel") + return + } + + // Show detailed tracking info + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** TRACKING %s", + c.server.config.Server.Name, c.Nick(), target)) + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Location: %s@%s", + c.server.config.Server.Name, c.Nick(), targetClient.User(), targetClient.Host())) + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Status: %s", + c.server.config.Server.Name, c.Nick(), c.getUserStatus(targetClient))) + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Channels: %s", + c.server.config.Server.Name, c.Nick(), c.getChannelList(targetClient))) + + if targetClient.Away() != "" { + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Away: %s", + c.server.config.Server.Name, c.Nick(), targetClient.Away())) + } +} + +// handleSpyListen - tap into channel conversations invisibly +func (c *Client) handleSpyListen(args []string) { + if len(args) < 1 { + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Usage: SPY LISTEN <#channel|off>", + c.server.config.Server.Name, c.Nick())) + return + } + + target := args[0] + if strings.ToLower(target) == "off" { + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Wiretaps disabled", + c.server.config.Server.Name, c.Nick())) + return + } + + if !isChannelName(target) { + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Invalid channel name", + c.server.config.Server.Name, c.Nick())) + return + } + + channel := c.server.GetChannel(target) + if channel == nil { + c.SendNumeric(ERR_NOSUCHCHANNEL, target+" :No such channel") + return + } + + // TODO: Add to wiretap list + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Now listening to %s (%d users)", + c.server.config.Server.Name, c.Nick(), target, channel.UserCount())) + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Wiretap established - you will receive covert copies of all messages", + c.server.config.Server.Name, c.Nick())) +} + +// handleSpyCloak - disguise your identity +func (c *Client) handleSpyCloak(args []string) { + if len(args) < 1 { + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Usage: SPY CLOAK ", + c.server.config.Server.Name, c.Nick())) + return + } + + identity := args[0] + if strings.ToLower(identity) == "off" { + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Identity cloak removed", + c.server.config.Server.Name, c.Nick())) + return + } + + // TODO: Implement identity cloaking + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Identity cloaked as: %s", + c.server.config.Server.Name, c.Nick(), identity)) + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Your true identity is hidden from WHOIS and other commands", + c.server.config.Server.Name, c.Nick())) +} + +// handleSpyGhost - become completely invisible in a channel +func (c *Client) handleSpyGhost(args []string) { + if len(args) < 1 { + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Usage: SPY GHOST <#channel|off>", + c.server.config.Server.Name, c.Nick())) + return + } + + target := args[0] + if strings.ToLower(target) == "off" { + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Ghost mode disabled", + c.server.config.Server.Name, c.Nick())) + return + } + + if !isChannelName(target) { + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Invalid channel name", + c.server.config.Server.Name, c.Nick())) + return + } + + channel := c.server.GetChannel(target) + if channel == nil { + c.SendNumeric(ERR_NOSUCHCHANNEL, target+" :No such channel") + return + } + + // Join channel invisibly + if !c.IsInChannel(target) { + channel.AddClient(c) + } + + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** You are now a GHOST in %s", + c.server.config.Server.Name, c.Nick(), target)) + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** You can see everything but are invisible to users", + c.server.config.Server.Name, c.Nick())) +} + +// handleSpyShadow - follow a user invisibly across channels +func (c *Client) handleSpyShadow(args []string) { + if len(args) < 1 { + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Usage: SPY SHADOW ", + c.server.config.Server.Name, c.Nick())) + return + } + + target := args[0] + if strings.ToLower(target) == "off" { + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Shadow mode disabled", + c.server.config.Server.Name, c.Nick())) + return + } + + targetClient := c.server.GetClient(target) + if targetClient == nil { + c.SendNumeric(ERR_NOSUCHNICK, target+" :No such nick/channel") + return + } + + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** Now shadowing %s", + c.server.config.Server.Name, c.Nick(), target)) + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** You will automatically follow them to any channel they join", + c.server.config.Server.Name, c.Nick())) +} + +// handleSpyStatus - show current spy operations +func (c *Client) handleSpyStatus() { + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** === SPY STATUS ===", + c.server.config.Server.Name, c.Nick())) + + if c.HasMode('H') { + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** STEALTH: Active (Hidden from WHO/WHOIS/NAMES)", + c.server.config.Server.Name, c.Nick())) + } else { + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** STEALTH: Inactive", + c.server.config.Server.Name, c.Nick())) + } + + // TODO: Show other active spy operations + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** WATCH: None active", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** WIRETAPS: None active", + c.server.config.Server.Name, c.Nick())) + c.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** SHADOWS: None active", + c.server.config.Server.Name, c.Nick())) +} + +// Helper functions for spy operations +func (c *Client) getChannelList(target *Client) string { + channels := target.GetChannels() + var channelNames []string + for _, channel := range channels { + channelNames = append(channelNames, channel.Name()) + } + if len(channelNames) == 0 { + return "None" + } + return strings.Join(channelNames, " ") +} + +func (c *Client) getUserStatus(target *Client) string { + status := "Online" + if target.Away() != "" { + status = "Away" + } + if target.IsOper() { + status += " (Operator)" + } + if target.HasMode('i') { + status += " (Invisible)" + } + if target.HasMode('B') { + status += " (Bot)" + } + return status +} + +// sendSnomask sends a server notice to operators watching a specific snomask +func (c *Client) sendSnomask(snomask rune, message string) { + if c.server == nil { + return + } + + for _, client := range c.server.GetClients() { + if client.IsOper() && client.HasSnomask(snomask) { + client.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** %s", + c.server.config.Server.Name, client.Nick(), message)) + } + } +} + +// isValidNickname checks if a nickname is valid +func isValidNickname(nick string) bool { + if len(nick) == 0 || len(nick) > 30 { + return false + } + + // First character must be a letter or special char + first := nick[0] + if !((first >= 'A' && first <= 'Z') || (first >= 'a' && first <= 'z') || + first == '[' || first == ']' || first == '\\' || first == '`' || + first == '_' || first == '^' || first == '{' || first == '|' || first == '}') { + return false + } + + // Rest can be letters, digits, or special chars + for i := 1; i < len(nick); i++ { + c := nick[i] + if !((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || + c == '[' || c == ']' || c == '\\' || c == '`' || + c == '_' || c == '^' || c == '{' || c == '|' || c == '}' || c == '-') { + return false + } + } + + return true +} + +// isValidChannelName checks if a channel name is valid +func isValidChannelName(name string) bool { + if len(name) == 0 || len(name) > 50 { + return false + } + + return name[0] == '#' || name[0] == '&' || name[0] == '!' || name[0] == '+' +} + +// isChannelName checks if a name is a channel name +func isChannelName(name string) bool { + if len(name) == 0 { + return false + } + return name[0] == '#' || name[0] == '&' || name[0] == '!' || name[0] == '+' +} diff --git a/linking.go b/linking.go new file mode 100644 index 0000000..578b138 --- /dev/null +++ b/linking.go @@ -0,0 +1,397 @@ +package main + +import ( + "fmt" + "log" + "net" + "strings" + "sync" + "time" +) + +// LinkedServer represents a connection to another IRC server +type LinkedServer struct { + name string + conn net.Conn + host string + port int + password string + hub bool + description string + connected bool + lastPing time.Time + server *Server + mu sync.RWMutex +} + +// LinkConfig represents configuration for server linking +type LinkConfig struct { + Enable bool `json:"enable"` + ServerPort int `json:"server_port"` + Password string `json:"password"` + Hub bool `json:"hub"` + AutoConnect bool `json:"auto_connect"` + Links []struct { + Name string `json:"name"` + Host string `json:"host"` + Port int `json:"port"` + Password string `json:"password"` + AutoConnect bool `json:"auto_connect"` + Hub bool `json:"hub"` + Description string `json:"description"` + } `json:"links"` +} + +// ServerMessage represents a server-to-server message +type ServerMessage struct { + Prefix string + Command string + Params []string + Source string + Target string +} + +// NewLinkedServer creates a new linked server connection +func NewLinkedServer(name, host string, port int, password string, hub bool, description string, server *Server) *LinkedServer { + return &LinkedServer{ + name: name, + host: host, + port: port, + password: password, + hub: hub, + description: description, + server: server, + connected: false, + lastPing: time.Now(), + } +} + +// Connect establishes a connection to the remote server +func (ls *LinkedServer) Connect() error { + ls.mu.Lock() + defer ls.mu.Unlock() + + if ls.connected { + return fmt.Errorf("server %s is already connected", ls.name) + } + + // Connect to remote server + var addr string + if strings.Contains(ls.host, ":") { + // IPv6 address, enclose in brackets + addr = fmt.Sprintf("[%s]:%d", ls.host, ls.port) + } else { + addr = fmt.Sprintf("%s:%d", ls.host, ls.port) + } + conn, err := net.DialTimeout("tcp", addr, 30*time.Second) + if err != nil { + return fmt.Errorf("failed to connect to %s: %v", ls.name, err) + } + + ls.conn = conn + ls.connected = true + + log.Printf("Connected to server %s at %s", ls.name, addr) + + // Start handling the connection + go ls.Handle() + + // Send server introduction + ls.SendServerAuth() + + return nil +} + +// Disconnect closes the connection to the remote server +func (ls *LinkedServer) Disconnect() { + ls.mu.Lock() + defer ls.mu.Unlock() + + if !ls.connected { + return + } + + if ls.conn != nil { + ls.conn.Close() + ls.conn = nil + } + ls.connected = false + + log.Printf("Disconnected from server %s", ls.name) +} + +// SendServerAuth sends authentication to the remote server +func (ls *LinkedServer) SendServerAuth() { + // Send PASS command + ls.SendMessage(fmt.Sprintf("PASS %s", ls.password)) + + // Send SERVER command + ls.SendMessage(fmt.Sprintf("SERVER %s 1 :%s", ls.server.config.Server.Name, ls.server.config.Server.Description)) + + log.Printf("Sent authentication to server %s", ls.name) +} + +// SendMessage sends a raw message to the linked server +func (ls *LinkedServer) SendMessage(message string) error { + ls.mu.RLock() + defer ls.mu.RUnlock() + + if !ls.connected || ls.conn == nil { + return fmt.Errorf("server %s is not connected", ls.name) + } + + _, err := fmt.Fprintf(ls.conn, "%s\r\n", message) + if err != nil { + log.Printf("Error sending message to server %s: %v", ls.name, err) + ls.connected = false + } + + return err +} + +// Handle processes messages from the linked server +func (ls *LinkedServer) Handle() { + defer func() { + if r := recover(); r != nil { + log.Printf("Panic in server link handler for %s: %v", ls.name, r) + } + ls.Disconnect() + }() + + scanner := net.Conn(ls.conn) + buffer := make([]byte, 4096) + + for ls.connected { + // Set read deadline + ls.conn.SetReadDeadline(time.Now().Add(5 * time.Minute)) + + n, err := scanner.Read(buffer) + if err != nil { + if ls.connected { + log.Printf("Read error from server %s: %v", ls.name, err) + } + break + } + + data := string(buffer[:n]) + lines := strings.Split(strings.TrimSpace(data), "\n") + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + ls.handleServerMessage(line) + } + } +} + +// handleServerMessage processes a single message from the linked server +func (ls *LinkedServer) handleServerMessage(line string) { + log.Printf("Received from server %s: %s", ls.name, line) + + parts := strings.Fields(line) + if len(parts) == 0 { + return + } + + var command string + var params []string + + // Parse message prefix + if parts[0][0] == ':' { + parts = parts[1:] + } + + if len(parts) == 0 { + return + } + + command = strings.ToUpper(parts[0]) + params = parts[1:] + + // Handle server-to-server commands + switch command { + case "PASS": + ls.handleServerPass(params) + case "SERVER": + ls.handleServerIntro(params) + case "PING": + ls.handleServerPing(params) + case "PONG": + ls.handleServerPong() + case "SQUIT": + ls.handleServerQuit(params) + case "NICK": + ls.handleRemoteNick(params) + case "USER": + ls.handleRemoteUser(params) + case "JOIN": + ls.handleRemoteJoin(params) + case "PART": + ls.handleRemotePart(params) + case "QUIT": + ls.handleRemoteQuit(params) + case "PRIVMSG": + ls.handleRemotePrivmsg(params) + default: + log.Printf("Unknown server command from %s: %s", ls.name, command) + } +} + +// handleServerPass handles PASS command from remote server +func (ls *LinkedServer) handleServerPass(params []string) { + if len(params) < 1 { + log.Printf("Invalid PASS from server %s", ls.name) + ls.Disconnect() + return + } + + password := params[0] + if password != ls.password { + log.Printf("Invalid password from server %s", ls.name) + ls.Disconnect() + return + } + + log.Printf("Server %s authenticated successfully", ls.name) +} + +// handleServerIntro handles SERVER command from remote server +func (ls *LinkedServer) handleServerIntro(params []string) { + if len(params) < 3 { + log.Printf("Invalid SERVER from server %s", ls.name) + ls.Disconnect() + return + } + + serverName := params[0] + hopCount := params[1] + description := strings.Join(params[2:], " ") + + if description[0] == ':' { + description = description[1:] + } + + log.Printf("Server introduced: %s (hops: %s) - %s", serverName, hopCount, description) + + // Send our user list and channel list to the remote server + ls.sendBurst() +} + +// handleServerPing handles PING from remote server +func (ls *LinkedServer) handleServerPing(params []string) { + if len(params) < 1 { + return + } + + token := params[0] + ls.SendMessage(fmt.Sprintf("PONG %s %s", ls.server.config.Server.Name, token)) +} + +// handleServerPong handles PONG from remote server +func (ls *LinkedServer) handleServerPong() { + ls.mu.Lock() + ls.lastPing = time.Now() + ls.mu.Unlock() +} + +// handleServerQuit handles SQUIT from remote server +func (ls *LinkedServer) handleServerQuit(params []string) { + if len(params) >= 1 { + reason := strings.Join(params, " ") + log.Printf("Server %s quit: %s", ls.name, reason) + } + ls.Disconnect() +} + +// sendBurst sends initial data to newly connected server +func (ls *LinkedServer) sendBurst() { + // Send all local users + for _, client := range ls.server.GetClients() { + if client.IsRegistered() { + ls.SendMessage(fmt.Sprintf("NICK %s 1 %d %s %s %s %s :%s", + client.Nick(), + time.Now().Unix(), + client.User(), + client.Host(), + ls.server.config.Server.Name, + client.Nick(), + client.Realname())) + } + } + + // Send all channels and their members + for _, channel := range ls.server.GetChannels() { + // Send channel creation + ls.SendMessage(fmt.Sprintf("NJOIN %s :", channel.name)) + + // Send channel members + var members []string + for _, client := range channel.GetClients() { + members = append(members, client.Nick()) + } + + if len(members) > 0 { + ls.SendMessage(fmt.Sprintf("NJOIN %s :%s", channel.name, strings.Join(members, " "))) + } + + // Send channel topic if exists + if channel.topic != "" { + ls.SendMessage(fmt.Sprintf("TOPIC %s :%s", channel.name, channel.topic)) + } + } + + // End of burst + ls.SendMessage("EOB") + log.Printf("Sent burst to server %s", ls.name) +} + +// Remote user handling functions +func (ls *LinkedServer) handleRemoteNick(params []string) { + // Handle remote NICK command + log.Printf("Remote NICK from %s: %s", ls.name, strings.Join(params, " ")) +} + +func (ls *LinkedServer) handleRemoteUser(params []string) { + // Handle remote USER command + log.Printf("Remote USER from %s: %s", ls.name, strings.Join(params, " ")) +} + +func (ls *LinkedServer) handleRemoteJoin(params []string) { + // Handle remote JOIN command + log.Printf("Remote JOIN from %s: %s", ls.name, strings.Join(params, " ")) +} + +func (ls *LinkedServer) handleRemotePart(params []string) { + // Handle remote PART command + log.Printf("Remote PART from %s: %s", ls.name, strings.Join(params, " ")) +} + +func (ls *LinkedServer) handleRemoteQuit(params []string) { + // Handle remote QUIT command + log.Printf("Remote QUIT from %s: %s", ls.name, strings.Join(params, " ")) +} + +func (ls *LinkedServer) handleRemotePrivmsg(params []string) { + // Handle remote PRIVMSG command + log.Printf("Remote PRIVMSG from %s: %s", ls.name, strings.Join(params, " ")) +} + +// IsConnected returns whether the server is currently connected +func (ls *LinkedServer) IsConnected() bool { + ls.mu.RLock() + defer ls.mu.RUnlock() + return ls.connected +} + +// Name returns the server name +func (ls *LinkedServer) Name() string { + return ls.name +} + +// Description returns the server description +func (ls *LinkedServer) Description() string { + return ls.description +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..15062d6 --- /dev/null +++ b/main.go @@ -0,0 +1,334 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "os/exec" + "os/signal" + "strconv" + "strings" + "syscall" + "time" +) + +var DebugMode bool + +const ( + VERSION = "1.0.0" + PIDFILE = "techircd.pid" + CONFIGFILE = "config.json" +) + +func main() { + // Parse command line arguments + if len(os.Args) < 2 { + showUsage() + os.Exit(1) + } + + command := strings.ToLower(os.Args[1]) + + // Parse flags for the remaining arguments + flagSet := flag.NewFlagSet("techircd", flag.ExitOnError) + configFile := flagSet.String("config", CONFIGFILE, "Path to configuration file") + daemon := flagSet.Bool("daemon", false, "Run as daemon (background)") + verbose := flagSet.Bool("verbose", false, "Enable verbose logging") + debug := flagSet.Bool("debug", false, "Enable extremely detailed debug logging (shows all IRC messages)") + port := flagSet.Int("port", 0, "Override port from config") + + // Parse remaining arguments + flagSet.Parse(os.Args[2:]) + + switch command { + case "start": + startServer(*configFile, *daemon, *verbose, *debug, *port) + case "stop": + stopServer() + case "restart": + stopServer() + time.Sleep(2 * time.Second) + startServer(*configFile, *daemon, *verbose, *debug, *port) + case "status": + showStatus() + case "reload": + reloadConfig() + case "version", "-v", "--version": + fmt.Printf("TechIRCd version %s\n", VERSION) + case "help", "-h", "--help": + showUsage() + default: + fmt.Printf("Unknown command: %s\n", command) + showUsage() + os.Exit(1) + } +} + +func showUsage() { + fmt.Printf(`TechIRCd %s - Modern IRC Server + +Usage: %s [options] + +Commands: + start Start the IRC server + stop Stop the IRC server + restart Restart the IRC server + status Show server status + reload Reload configuration + version Show version information + help Show this help message + +Options: + -config Path to configuration file (default: config.json) + -daemon Run as daemon (background process) + -verbose Enable verbose logging + -debug Enable extremely detailed debug logging (shows all IRC messages) + -port Override port from configuration + +Examples: + %s start # Start with default config + %s start -config custom.json # Start with custom config + %s start -daemon # Start as background daemon + %s stop # Stop the server + %s status # Check if server is running + +`, VERSION, os.Args[0], os.Args[0], os.Args[0], os.Args[0], os.Args[0], os.Args[0]) +} + +func startServer(configFile string, daemon, verbose, debug bool, port int) { + // Check if server is already running + if isRunning() { + fmt.Println("TechIRCd is already running") + os.Exit(1) + } + + // Load configuration + config, err := LoadConfig(configFile) + if err != nil { + log.Printf("Failed to load config from %s, using defaults: %v", configFile, err) + config = DefaultConfig() + if err := SaveConfig(config, configFile); err != nil { + log.Printf("Failed to save default config: %v", err) + } + } + + // Override port if specified + if port > 0 { + config.Server.Listen.Port = port + fmt.Printf("Port overridden to %d\n", port) + } + + // Validate configuration + if err := config.Validate(); err != nil { + log.Fatalf("Configuration validation failed: %v", err) + } + config.SanitizeConfig() + + if verbose { + log.Println("Configuration validated successfully") + } + + // Set global debug mode + DebugMode = debug + if debug { + log.Println("Debug mode enabled - will log all IRC messages") + } + + // Start as daemon if requested + if daemon { + startDaemon(configFile, verbose, debug, port) + return + } + + // Write PID file + if err := writePidFile(); err != nil { + log.Fatalf("Failed to write PID file: %v", err) + } + defer removePidFile() + + // Create server + server := NewServer(config) + + // Setup signal handling for graceful shutdown with forced shutdown capability + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) + + shutdownInProgress := false + go func() { + for sig := range c { + switch sig { + case syscall.SIGHUP: + log.Println("Received SIGHUP, reloading configuration...") + // Reload config here if needed + case os.Interrupt, syscall.SIGTERM: + if shutdownInProgress { + log.Println("Received second interrupt signal, forcing immediate shutdown...") + removePidFile() + os.Exit(1) // Force exit + } + + shutdownInProgress = true + log.Println("Shutting down server...") + + // Start shutdown with timeout + shutdownComplete := make(chan bool, 1) + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("Panic during shutdown: %v", r) + } + shutdownComplete <- true + }() + server.Shutdown() + }() + + // Wait for graceful shutdown or force after timeout + select { + case <-shutdownComplete: + log.Println("Graceful shutdown completed") + case <-time.After(10 * time.Second): + log.Println("Shutdown timeout reached, forcing exit...") + } + + removePidFile() + os.Exit(0) + } + } + }() + + // Start the server + fmt.Printf("Starting TechIRCd %s on %s:%d\n", VERSION, config.Server.Listen.Host, config.Server.Listen.Port) + if config.Server.Listen.EnableSSL { + fmt.Printf("SSL enabled on port %d\n", config.Server.Listen.SSLPort) + } + + if err := server.Start(); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} + +func startDaemon(configFile string, verbose, debug bool, port int) { + // Build command arguments + args := []string{os.Args[0], "start", "-config", configFile} + if verbose { + args = append(args, "-verbose") + } + if debug { + args = append(args, "-debug") + } + if port > 0 { + args = append(args, "-port", strconv.Itoa(port)) + } + + // Start as background process + cmd := exec.Command(args[0], args[1:]...) + cmd.Stdout = nil + cmd.Stderr = nil + cmd.Stdin = nil + + if err := cmd.Start(); err != nil { + log.Fatalf("Failed to start daemon: %v", err) + } + + fmt.Printf("TechIRCd started as daemon (PID: %d)\n", cmd.Process.Pid) +} + +func stopServer() { + pid, err := readPidFile() + if err != nil { + fmt.Println("TechIRCd is not running") + return + } + + process, err := os.FindProcess(pid) + if err != nil { + fmt.Println("TechIRCd is not running") + removePidFile() + return + } + + // Send SIGTERM for graceful shutdown + if err := process.Signal(syscall.SIGTERM); err != nil { + fmt.Println("TechIRCd is not running") + removePidFile() + return + } + + // Wait for process to stop + for i := 0; i < 10; i++ { + if !isRunning() { + fmt.Println("TechIRCd stopped") + return + } + time.Sleep(500 * time.Millisecond) + } + + // Force kill if still running + process.Signal(syscall.SIGKILL) + fmt.Println("TechIRCd force stopped") + removePidFile() +} + +func showStatus() { + if isRunning() { + pid, _ := readPidFile() + fmt.Printf("TechIRCd is running (PID: %d)\n", pid) + } else { + fmt.Println("TechIRCd is not running") + } +} + +func reloadConfig() { + pid, err := readPidFile() + if err != nil { + fmt.Println("TechIRCd is not running") + return + } + + process, err := os.FindProcess(pid) + if err != nil { + fmt.Println("TechIRCd is not running") + return + } + + if err := process.Signal(syscall.SIGHUP); err != nil { + fmt.Println("Failed to reload configuration") + return + } + + fmt.Println("Configuration reload signal sent") +} + +func isRunning() bool { + pid, err := readPidFile() + if err != nil { + return false + } + + process, err := os.FindProcess(pid) + if err != nil { + return false + } + + // Send signal 0 to check if process exists + err = process.Signal(syscall.Signal(0)) + return err == nil +} + +func writePidFile() error { + pid := os.Getpid() + return os.WriteFile(PIDFILE, []byte(strconv.Itoa(pid)), 0644) +} + +func readPidFile() (int, error) { + data, err := os.ReadFile(PIDFILE) + if err != nil { + return 0, err + } + return strconv.Atoi(strings.TrimSpace(string(data))) +} + +func removePidFile() { + os.Remove(PIDFILE) +} diff --git a/monitoring_analytics.go b/monitoring_analytics.go new file mode 100644 index 0000000..590b9b1 --- /dev/null +++ b/monitoring_analytics.go @@ -0,0 +1,138 @@ +package main + +import ( + "time" +) + +// Advanced monitoring and analytics +type MonitoringConfig struct { + Prometheus struct { + Enable bool `json:"enable"` + Port int `json:"port"` + Path string `json:"path"` + } `json:"prometheus"` + + Grafana struct { + Enable bool `json:"enable"` + Dashboard string `json:"dashboard_url"` + } `json:"grafana"` + + Logging struct { + Level string `json:"level"` + Format string `json:"format"` // json, text + Output string `json:"output"` // file, stdout, syslog + Structured bool `json:"structured"` + } `json:"logging"` +} + +// Metrics collection +type ServerMetrics struct { + // Connection metrics + TotalConnections int64 `metric:"total_connections"` + ActiveConnections int `metric:"active_connections"` + ConnectionsPerSecond float64 `metric:"connections_per_second"` + FailedConnections int64 `metric:"failed_connections"` + + // Message metrics + MessagesPerSecond float64 `metric:"messages_per_second"` + TotalMessages int64 `metric:"total_messages"` + PrivateMessages int64 `metric:"private_messages"` + ChannelMessages int64 `metric:"channel_messages"` + + // Channel metrics + TotalChannels int `metric:"total_channels"` + AverageChannelSize float64 `metric:"average_channel_size"` + LargestChannel int `metric:"largest_channel"` + + // Performance metrics + CPUUsage float64 `metric:"cpu_usage"` + MemoryUsage int64 `metric:"memory_usage"` + GoroutineCount int `metric:"goroutine_count"` + ResponseTime time.Duration `metric:"response_time"` + + // Network metrics + BytesSent int64 `metric:"bytes_sent"` + BytesReceived int64 `metric:"bytes_received"` + NetworkLatency time.Duration `metric:"network_latency"` +} + +// Real-time analytics +type AnalyticsEngine struct { + UserActivity map[string]*UserActivityMetrics + ChannelAnalytics map[string]*ChannelAnalytics + NetworkHealth *NetworkHealthMetrics +} + +type UserActivityMetrics struct { + MessagesPerHour []int `json:"messages_per_hour"` + ChannelsJoined []string `json:"channels_joined"` + CommandsUsed map[string]int `json:"commands_used"` + ConnectionTime time.Duration `json:"connection_time"` + LastActivity time.Time `json:"last_activity"` +} + +type ChannelAnalytics struct { + MessageCount int64 `json:"message_count"` + UniqueUsers int `json:"unique_users"` + AverageUsers float64 `json:"average_users"` + PeakUsers int `json:"peak_users"` + MostActiveUsers []string `json:"most_active_users"` + TopicChanges int `json:"topic_changes"` +} + +type NetworkHealthMetrics struct { + Uptime time.Duration `json:"uptime"` + ServerLoad float64 `json:"server_load"` + ErrorRate float64 `json:"error_rate"` + ResponseTime time.Duration `json:"response_time"` + LinkedServerCount int `json:"linked_servers"` + NetworkSplit bool `json:"network_split"` +} + +// Alert system +type AlertManager struct { + Rules []AlertRule `json:"rules"` + Channels []AlertChannel `json:"channels"` +} + +type AlertRule struct { + Name string `json:"name"` + Condition string `json:"condition"` // "cpu_usage > 80" + Threshold float64 `json:"threshold"` + Duration int `json:"duration"` // seconds + Severity string `json:"severity"` // info, warning, critical + Actions []string `json:"actions"` +} + +type AlertChannel struct { + Type string `json:"type"` // email, slack, discord, webhook + Config map[string]string `json:"config"` +} + +// Performance profiling +func (s *Server) StartProfiling() { + // Enable CPU and memory profiling +} + +func (s *Server) GeneratePerformanceReport() *PerformanceReport { + // Generate detailed performance report + return &PerformanceReport{} +} + +type PerformanceReport struct { + Timestamp time.Time `json:"timestamp"` + CPUProfile []CPUSample `json:"cpu_profile"` + MemoryProfile []MemorySample `json:"memory_profile"` + Bottlenecks []string `json:"bottlenecks"` + Recommendations []string `json:"recommendations"` +} + +type CPUSample struct { + Function string `json:"function"` + Usage float64 `json:"usage"` +} + +type MemorySample struct { + Object string `json:"object"` + Size int64 `json:"size"` +} diff --git a/oper_config.go b/oper_config.go new file mode 100644 index 0000000..86f49ce --- /dev/null +++ b/oper_config.go @@ -0,0 +1,339 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" +) + +// OperClass defines an operator class with specific permissions +type OperClass struct { + Name string `json:"name"` + Rank int `json:"rank"` // Higher number = higher rank + Description string `json:"description"` + Permissions []string `json:"permissions"` + Inherits string `json:"inherits"` // Inherit permissions from another class + Color string `json:"color"` // Color for display purposes + Symbol string `json:"symbol"` // Symbol to display (*, @, &, etc.) +} + +// Oper defines an individual operator +type Oper struct { + Name string `json:"name"` + Password string `json:"password"` + Host string `json:"host"` + Class string `json:"class"` + Flags []string `json:"flags"` // Additional per-user flags + MaxClients int `json:"max_clients"` // Max clients this oper can handle + Expires string `json:"expires"` // Expiration date (optional) + Contact string `json:"contact"` // Contact information + LastSeen string `json:"last_seen"` // Last time this oper was online +} + +// RankNames defines custom names for rank levels +type RankNames struct { + Rank1 string `json:"rank_1"` // Default: Helper + Rank2 string `json:"rank_2"` // Default: Moderator + Rank3 string `json:"rank_3"` // Default: Operator + Rank4 string `json:"rank_4"` // Default: Administrator + Rank5 string `json:"rank_5"` // Default: Owner + // Support for custom ranks beyond 5 + CustomRanks map[string]int `json:"custom_ranks"` // "CustomName": 6 +} + +// OperConfig holds the complete operator configuration +type OperConfig struct { + Classes []OperClass `json:"classes"` + Opers []Oper `json:"opers"` + RankNames RankNames `json:"rank_names"` + Settings struct { + RequireSSL bool `json:"require_ssl"` + MaxFailedAttempts int `json:"max_failed_attempts"` + LockoutDuration int `json:"lockout_duration_minutes"` + AllowedCommands []string `json:"allowed_commands"` + LogOperActions bool `json:"log_oper_actions"` + NotifyOnOperUp bool `json:"notify_on_oper_up"` + AutoExpireInactive int `json:"auto_expire_inactive_days"` + RequireTwoFactor bool `json:"require_two_factor"` + } `json:"settings"` +} + +// LoadOperConfig loads operator configuration from file +func LoadOperConfig(filename string) (*OperConfig, error) { + data, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("failed to read oper config file: %v", err) + } + + var config OperConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse oper config file: %v", err) + } + + return &config, nil +} + +// SaveOperConfig saves operator configuration to file +func SaveOperConfig(config *OperConfig, filename string) error { + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal oper config: %v", err) + } + + if err := os.WriteFile(filename, data, 0644); err != nil { + return fmt.Errorf("failed to write oper config file: %v", err) + } + + return nil +} + +// GetOperClass returns the operator class by name +func (oc *OperConfig) GetOperClass(className string) *OperClass { + for i := range oc.Classes { + if oc.Classes[i].Name == className { + return &oc.Classes[i] + } + } + return nil +} + +// GetOper returns the operator by name +func (oc *OperConfig) GetOper(operName string) *Oper { + for i := range oc.Opers { + if oc.Opers[i].Name == operName { + return &oc.Opers[i] + } + } + return nil +} + +// GetOperPermissions returns all permissions for an operator (including inherited) +func (oc *OperConfig) GetOperPermissions(operName string) []string { + oper := oc.GetOper(operName) + if oper == nil { + return nil + } + + class := oc.GetOperClass(oper.Class) + if class == nil { + return oper.Flags + } + + permissions := make(map[string]bool) + + // Add class permissions + for _, perm := range class.Permissions { + permissions[perm] = true + } + + // Add inherited permissions + if class.Inherits != "" { + inherited := oc.GetOperClass(class.Inherits) + if inherited != nil { + for _, perm := range inherited.Permissions { + permissions[perm] = true + } + } + } + + // Add individual flags + for _, flag := range oper.Flags { + permissions[flag] = true + } + + // Convert back to slice + result := make([]string, 0, len(permissions)) + for perm := range permissions { + result = append(result, perm) + } + + return result +} + +// GetRankName returns the custom name for a rank level +func (oc *OperConfig) GetRankName(rank int) string { + switch rank { + case 1: + if oc.RankNames.Rank1 != "" { + return oc.RankNames.Rank1 + } + return "Helper" + case 2: + if oc.RankNames.Rank2 != "" { + return oc.RankNames.Rank2 + } + return "Moderator" + case 3: + if oc.RankNames.Rank3 != "" { + return oc.RankNames.Rank3 + } + return "Operator" + case 4: + if oc.RankNames.Rank4 != "" { + return oc.RankNames.Rank4 + } + return "Administrator" + case 5: + if oc.RankNames.Rank5 != "" { + return oc.RankNames.Rank5 + } + return "Owner" + default: + // Check custom ranks + for name, rankNum := range oc.RankNames.CustomRanks { + if rankNum == rank { + return name + } + } + return fmt.Sprintf("Rank %d", rank) + } +} + +// GetOperRankName returns the rank name for an operator +func (oc *OperConfig) GetOperRankName(operName string) string { + oper := oc.GetOper(operName) + if oper == nil { + return "Unknown" + } + + class := oc.GetOperClass(oper.Class) + if class == nil { + return "Unknown" + } + + return oc.GetRankName(class.Rank) +} + +// SetCustomRankName allows adding custom rank names at runtime +func (oc *OperConfig) SetCustomRankName(name string, rank int) { + if oc.RankNames.CustomRanks == nil { + oc.RankNames.CustomRanks = make(map[string]int) + } + oc.RankNames.CustomRanks[name] = rank +} + +// HasPermission checks if an operator has a specific permission +func (oc *OperConfig) HasPermission(operName, permission string) bool { + permissions := oc.GetOperPermissions(operName) + for _, perm := range permissions { + if perm == permission || perm == "*" { // * grants all permissions + return true + } + } + return false +} + +// GetOperRank returns the rank of an operator +func (oc *OperConfig) GetOperRank(operName string) int { + oper := oc.GetOper(operName) + if oper == nil { + return 0 + } + + class := oc.GetOperClass(oper.Class) + if class == nil { + return 0 + } + + return class.Rank +} + +// CanOperateOn checks if oper1 can perform actions on oper2 (based on rank) +func (oc *OperConfig) CanOperateOn(oper1Name, oper2Name string) bool { + rank1 := oc.GetOperRank(oper1Name) + rank2 := oc.GetOperRank(oper2Name) + + // Higher rank can operate on lower rank + // Same rank cannot operate on each other (unless they have override permission) + return rank1 > rank2 || oc.HasPermission(oper1Name, "override_rank") +} + +// DefaultOperConfig returns a default operator configuration +func DefaultOperConfig() *OperConfig { + return &OperConfig{ + Classes: []OperClass{ + { + Name: "helper", + Rank: 1, + Description: "Helper - Basic moderation commands", + Permissions: []string{"kick", "topic", "mode_channel"}, + Color: "green", + Symbol: "%", + }, + { + Name: "moderator", + Rank: 2, + Description: "Moderator - Channel and user management", + Permissions: []string{"ban", "unban", "kick", "mute", "topic", "mode_channel", "mode_user", "who_override"}, + Inherits: "helper", + Color: "blue", + Symbol: "@", + }, + { + Name: "operator", + Rank: 3, + Description: "Operator - Server management commands", + Permissions: []string{"kill", "gline", "rehash", "connect", "squit", "wallops", "operwall"}, + Inherits: "moderator", + Color: "red", + Symbol: "*", + }, + { + Name: "admin", + Rank: 4, + Description: "Administrator - Full server control", + Permissions: []string{"*"}, // All permissions + Color: "purple", + Symbol: "&", + }, + { + Name: "owner", + Rank: 5, + Description: "Server Owner - Ultimate authority", + Permissions: []string{"*", "override_rank", "shutdown", "restart"}, + Color: "gold", + Symbol: "~", + }, + }, + Opers: []Oper{ + { + Name: "admin", + Password: "changeme", + Host: "*@localhost", + Class: "admin", + MaxClients: 1000, + Contact: "admin@example.com", + }, + }, + RankNames: RankNames{ + Rank1: "Helper", // Customizable: "Support Staff", "Junior Mod", etc. + Rank2: "Moderator", // Customizable: "Channel Mod", "Guard", etc. + Rank3: "Operator", // Customizable: "IRC Operator", "Senior Staff", etc. + Rank4: "Administrator", // Customizable: "Admin", "Server Admin", etc. + Rank5: "Owner", // Customizable: "Root", "Founder", etc. + CustomRanks: map[string]int{ + // Example: "Super Admin": 6, + // Example: "Network Founder": 10, + }, + }, + Settings: struct { + RequireSSL bool `json:"require_ssl"` + MaxFailedAttempts int `json:"max_failed_attempts"` + LockoutDuration int `json:"lockout_duration_minutes"` + AllowedCommands []string `json:"allowed_commands"` + LogOperActions bool `json:"log_oper_actions"` + NotifyOnOperUp bool `json:"notify_on_oper_up"` + AutoExpireInactive int `json:"auto_expire_inactive_days"` + RequireTwoFactor bool `json:"require_two_factor"` + }{ + RequireSSL: false, + MaxFailedAttempts: 3, + LockoutDuration: 30, + AllowedCommands: []string{"OPER", "KILL", "GLINE", "REHASH", "WALLOPS"}, + LogOperActions: true, + NotifyOnOperUp: true, + AutoExpireInactive: 365, + RequireTwoFactor: false, + }, + } +} diff --git a/rate_limiter.go b/rate_limiter.go new file mode 100644 index 0000000..ec884c3 --- /dev/null +++ b/rate_limiter.go @@ -0,0 +1,192 @@ +package main + +import ( + "sync" + "time" +) + +// AdvancedRateLimiter implements token bucket rate limiting for client connections +type AdvancedRateLimiter struct { + maxTokens int + refillRate time.Duration + tokens int + lastRefill time.Time + mu sync.Mutex +} + +// ClientRateLimiter manages per-client rate limiting +type ClientRateLimiter struct { + limiters map[string]*AdvancedRateLimiter + mu sync.RWMutex + cleanup chan string +} + +// NewAdvancedRateLimiter creates a new rate limiter +func NewAdvancedRateLimiter(maxTokens int, refillRate time.Duration) *AdvancedRateLimiter { + return &AdvancedRateLimiter{ + maxTokens: maxTokens, + refillRate: refillRate, + tokens: maxTokens, + lastRefill: time.Now(), + } +} + +// Allow checks if an action is allowed (consumes a token) +func (rl *AdvancedRateLimiter) Allow() bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + rl.refill() + + if rl.tokens > 0 { + rl.tokens-- + return true + } + return false +} + +// refill adds tokens based on elapsed time +func (rl *AdvancedRateLimiter) refill() { + now := time.Now() + elapsed := now.Sub(rl.lastRefill) + + tokensToAdd := int(elapsed / rl.refillRate) + if tokensToAdd > 0 { + rl.tokens += tokensToAdd + if rl.tokens > rl.maxTokens { + rl.tokens = rl.maxTokens + } + rl.lastRefill = now + } +} + +// GetTokens returns current token count (for monitoring) +func (rl *AdvancedRateLimiter) GetTokens() int { + rl.mu.Lock() + defer rl.mu.Unlock() + rl.refill() + return rl.tokens +} + +// NewClientRateLimiter creates a new client rate limiter +func NewClientRateLimiter() *ClientRateLimiter { + crl := &ClientRateLimiter{ + limiters: make(map[string]*AdvancedRateLimiter), + cleanup: make(chan string, 1000), + } + + // Start cleanup routine + go crl.cleanupRoutine() + + return crl +} + +// Allow checks if a client action is allowed +func (crl *ClientRateLimiter) Allow(clientID string, maxTokens int, refillRate time.Duration) bool { + crl.mu.RLock() + limiter, exists := crl.limiters[clientID] + crl.mu.RUnlock() + + if !exists { + crl.mu.Lock() + // Double-check after acquiring write lock + limiter, exists = crl.limiters[clientID] + if !exists { + limiter = NewAdvancedRateLimiter(maxTokens, refillRate) + crl.limiters[clientID] = limiter + } + crl.mu.Unlock() + } + + return limiter.Allow() +} + +// RemoveClient removes a client's rate limiter +func (crl *ClientRateLimiter) RemoveClient(clientID string) { + select { + case crl.cleanup <- clientID: + // Queued for cleanup + default: + // Cleanup queue full, clean directly + crl.mu.Lock() + delete(crl.limiters, clientID) + crl.mu.Unlock() + } +} + +// cleanupRoutine processes client cleanup requests +func (crl *ClientRateLimiter) cleanupRoutine() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for { + select { + case clientID := <-crl.cleanup: + crl.mu.Lock() + delete(crl.limiters, clientID) + crl.mu.Unlock() + + case <-ticker.C: + // Periodic cleanup of old rate limiters + crl.mu.Lock() + for clientID, limiter := range crl.limiters { + // Remove limiters that haven't been used recently + if time.Since(limiter.lastRefill) > 10*time.Minute { + delete(crl.limiters, clientID) + } + } + crl.mu.Unlock() + } + } +} + +// GetStats returns rate limiter statistics +func (crl *ClientRateLimiter) GetStats() map[string]interface{} { + crl.mu.RLock() + defer crl.mu.RUnlock() + + stats := make(map[string]interface{}) + stats["active_limiters"] = len(crl.limiters) + + totalTokens := 0 + for _, limiter := range crl.limiters { + totalTokens += limiter.GetTokens() + } + stats["total_tokens"] = totalTokens + + return stats +} + +// GlobalRateLimiter implements server-wide rate limiting +type GlobalRateLimiter struct { + connectionLimiter *AdvancedRateLimiter + messageLimiter *AdvancedRateLimiter +} + +// NewGlobalRateLimiter creates a new global rate limiter +func NewGlobalRateLimiter(connPerSecond, msgPerSecond int) *GlobalRateLimiter { + return &GlobalRateLimiter{ + connectionLimiter: NewAdvancedRateLimiter(connPerSecond*10, time.Second/time.Duration(connPerSecond)), + messageLimiter: NewAdvancedRateLimiter(msgPerSecond*10, time.Second/time.Duration(msgPerSecond)), + } +} + +// AllowConnection checks if a new connection is allowed +func (grl *GlobalRateLimiter) AllowConnection() bool { + return grl.connectionLimiter.Allow() +} + +// AllowMessage checks if a message is allowed +func (grl *GlobalRateLimiter) AllowMessage() bool { + return grl.messageLimiter.Allow() +} + +// GetConnectionTokens returns available connection tokens +func (grl *GlobalRateLimiter) GetConnectionTokens() int { + return grl.connectionLimiter.GetTokens() +} + +// GetMessageTokens returns available message tokens +func (grl *GlobalRateLimiter) GetMessageTokens() int { + return grl.messageLimiter.GetTokens() +} diff --git a/scripts/demo_user_modes.sh b/scripts/demo_user_modes.sh new file mode 100755 index 0000000..5636ecb --- /dev/null +++ b/scripts/demo_user_modes.sh @@ -0,0 +1,60 @@ +#!/bin/bash + +# God Mode and Stealth Mode User Mode Demo +# This script demonstrates the new user mode implementation + +echo "=== God Mode and Stealth Mode User Mode Demo ===" +echo "" +echo "God Mode and Stealth Mode are now implemented as proper IRC user modes:" +echo "" + +echo "🔱 GOD MODE (+G):" +echo " /mode mynick +G # Enable God Mode - Ultimate channel override powers" +echo " /mode mynick -G # Disable God Mode" +echo "" +echo " Capabilities:" +echo " • Join banned/invite-only/password/full channels" +echo " • Cannot be kicked by anyone" +echo " • Override all channel restrictions" +echo "" + +echo "👻 STEALTH MODE (+S):" +echo " /mode mynick +S # Enable Stealth Mode - Invisible to regular users" +echo " /mode mynick -S # Disable Stealth Mode" +echo "" +echo " Effects:" +echo " • Hidden from /who and /names for regular users" +echo " • Still visible to other operators" +echo " • Invisible channel presence" +echo "" + +echo "⚡ COMBINED USAGE:" +echo " /mode mynick +GS # Enable both modes simultaneously" +echo " /mode mynick -GS # Disable both modes" +echo "" + +echo "📋 REQUIREMENTS:" +echo " • Must be an IRC operator (/oper)" +echo " • Operator class must have 'god_mode' and/or 'stealth_mode' permissions" +echo " • Can only set modes on yourself" +echo "" + +echo "🛡️ PERMISSIONS:" +echo " Add to your operator class in configs/opers.conf:" +echo ' "permissions": ["*", "god_mode", "stealth_mode"]' +echo "" + +echo "🔍 CHECKING CURRENT MODES:" +echo " /mode mynick # Show your current user modes" +echo "" + +echo "Example operator class with both permissions:" +echo '{' +echo ' "name": "admin",' +echo ' "rank": 4,' +echo ' "permissions": ["*", "god_mode", "stealth_mode"]' +echo '}' +echo "" + +echo "This follows proper IRC conventions - user modes instead of custom commands!" +echo "Use /mode +G and /mode +S just like any other IRC user mode." diff --git a/scripts/test_irc_connection.sh b/scripts/test_irc_connection.sh new file mode 100755 index 0000000..83dd9ad --- /dev/null +++ b/scripts/test_irc_connection.sh @@ -0,0 +1,59 @@ +#!/bin/bash + +# Simple IRC client test to debug connection issues +# This script connects to the IRC server and sends basic registration commands + +echo "=== TechIRCd Connection Test ===" +echo "Testing IRC registration sequence..." +echo "" + +# Create a temporary file for the test +TEST_FILE="/tmp/irc_test.txt" + +# Connect and send basic registration commands +{ + echo "NICK testuser" + echo "USER testuser 0 * :Test User" + sleep 2 + echo "QUIT :Test complete" +} | nc -q 3 127.0.0.1 6667 | tee "$TEST_FILE" + +echo "" +echo "=== Raw IRC Server Response ===" +cat "$TEST_FILE" +echo "" + +echo "=== Analysis ===" +if grep -q "001" "$TEST_FILE"; then + echo "✅ RPL_WELCOME (001) found - Server sent welcome message" +else + echo "❌ RPL_WELCOME (001) NOT found - Registration may have failed" +fi + +if grep -q "002" "$TEST_FILE"; then + echo "✅ RPL_YOURHOST (002) found" +else + echo "❌ RPL_YOURHOST (002) NOT found" +fi + +if grep -q "003" "$TEST_FILE"; then + echo "✅ RPL_CREATED (003) found" +else + echo "❌ RPL_CREATED (003) NOT found" +fi + +if grep -q "004" "$TEST_FILE"; then + echo "✅ RPL_MYINFO (004) found" +else + echo "❌ RPL_MYINFO (004) NOT found" +fi + +echo "" +echo "If the pyechat client connects but shows nothing, it might be:" +echo "1. Not sending NICK/USER commands automatically" +echo "2. Expecting different IRC numeric responses" +echo "3. Not parsing the IRC protocol properly" +echo "4. Waiting for specific server capabilities" + +# Cleanup +rm -f "$TEST_FILE" diff --git a/security_enhancements.go b/security_enhancements.go new file mode 100644 index 0000000..000ac35 --- /dev/null +++ b/security_enhancements.go @@ -0,0 +1,106 @@ +package main + +import ( + "crypto/tls" + "net" + "time" +) + +// Advanced security and authentication +type SecurityConfig struct { + RateLimit struct { + Enable bool `json:"enable"` + MaxRequests int `json:"max_requests"` + Window int `json:"window_seconds"` + BanDuration int `json:"ban_duration"` + } `json:"rate_limit"` + + GeoBlocking struct { + Enable bool `json:"enable"` + Whitelist []string `json:"whitelist_countries"` + Blacklist []string `json:"blacklist_countries"` + } `json:"geo_blocking"` + + TwoFactor struct { + Enable bool `json:"enable"` + Methods []string `json:"methods"` // totp, sms, email + Required bool `json:"required_for_opers"` + } `json:"two_factor"` + + SASL struct { + Enable bool `json:"enable"` + Mechanisms []string `json:"mechanisms"` // PLAIN, EXTERNAL, SCRAM-SHA-256 + Required bool `json:"required"` + } `json:"sasl"` +} + +// Rate limiting per IP/user +type RateLimiter struct { + connections map[string]*ConnectionLimit + messages map[string]*MessageLimit +} + +type ConnectionLimit struct { + IP net.IP + Count int + LastSeen time.Time + Banned bool + BanUntil time.Time +} + +type MessageLimit struct { + Count int + LastReset time.Time + Violations int +} + +// Certificate-based authentication +type CertAuth struct { + Enable bool `json:"enable"` + RequiredCAs []string `json:"required_cas"` + UserMapping map[string]string `json:"user_mapping"` // cert fingerprint -> username + AutoOper bool `json:"auto_oper"` +} + +// OAuth integration +type OAuthConfig struct { + Providers map[string]OAuthProvider `json:"providers"` +} + +type OAuthProvider struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + AuthURL string `json:"auth_url"` + TokenURL string `json:"token_url"` + UserInfoURL string `json:"user_info_url"` +} + +// DDoS protection +type DDoSProtection struct { + Enable bool `json:"enable"` + MaxConnections int `json:"max_connections_per_ip"` + ConnectionRate int `json:"max_connections_per_minute"` + SynFloodProtection bool `json:"syn_flood_protection"` +} + +// Implement security features +func (s *Server) CheckRateLimit(ip net.IP) bool { + // Check if IP is rate limited + return true +} + +func (s *Server) ValidateCertificate(cert *tls.Certificate) bool { + // Validate client certificate + return true +} + +func (s *Server) AuthenticateOAuth(provider, token string) (*UserInfo, error) { + // OAuth authentication + return nil, nil +} + +type UserInfo struct { + Username string + Email string + Verified bool +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..a90e7b3 --- /dev/null +++ b/server.go @@ -0,0 +1,1016 @@ +package main + +import ( + "crypto/tls" + "fmt" + "log" + "net" + "strings" + "sync" + "time" +) + +type Server struct { + config *Config + clients map[string]*Client + channels map[string]*Channel + listener net.Listener + sslListener net.Listener + serverListener net.Listener + linkedServers map[string]*LinkedServer + pingMessage string + mu sync.RWMutex + shutdown chan bool + healthMonitor *HealthMonitor +} + +func NewServer(config *Config) *Server { + server := &Server{ + config: config, + clients: make(map[string]*Client, config.Limits.MaxClients), + channels: make(map[string]*Channel, config.Limits.MaxChannels), + shutdown: make(chan bool), + } + server.healthMonitor = NewHealthMonitor(server) + return server +} + +func (s *Server) Start() error { + // Start regular listener + addr := fmt.Sprintf("%s:%d", s.config.Server.Listen.Host, s.config.Server.Listen.Port) + listener, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("failed to listen on %s: %v", addr, err) + } + s.listener = listener + + log.Printf("IRC server listening on %s", addr) + + // Start health monitoring + s.healthMonitor.Start() + + // Start SSL listener if enabled + if s.config.Server.Listen.EnableSSL { + go s.startSSLListener() + } + + // Start server linking if enabled + if s.config.Linking.Enable { + go s.startServerListener() + go s.startAutoConnections() + } + + // Auto-create configured channels + for _, channelName := range s.config.Channels.AutoJoin { + channel := NewChannel(channelName) + // Set default modes + for _, mode := range s.config.Channels.DefaultModes { + if mode != '+' { + channel.SetMode(rune(mode), true) + } + } + s.channels[strings.ToLower(channelName)] = channel + } + + // Start ping routine + go s.pingRoutine() + + // Accept connections + for { + select { + case <-s.shutdown: + return nil + default: + conn, err := listener.Accept() + if err != nil { + continue + } + + client := NewClient(conn, s) + s.AddClient(client) + go client.Handle() + } + } +} + +func (s *Server) startSSLListener() { + // Load SSL certificates + cert, err := tls.LoadX509KeyPair(s.config.Server.SSL.CertFile, s.config.Server.SSL.KeyFile) + if err != nil { + log.Printf("Failed to load SSL certificates: %v", err) + return + } + + tlsConfig := &tls.Config{Certificates: []tls.Certificate{cert}} + + addr := fmt.Sprintf("%s:%d", s.config.Server.Listen.Host, s.config.Server.Listen.SSLPort) + listener, err := tls.Listen("tcp", addr, tlsConfig) + if err != nil { + log.Printf("Failed to start SSL listener on %s: %v", addr, err) + return + } + s.sslListener = listener + + log.Printf("IRC SSL server listening on %s", addr) + + for { + select { + case <-s.shutdown: + return + default: + conn, err := listener.Accept() + if err != nil { + continue + } + + client := NewClient(conn, s) + s.AddClient(client) + go client.Handle() + } + } +} + +func (s *Server) pingRoutine() { + ticker := time.NewTicker(60 * time.Second) // Reduced frequency to prevent contention + defer ticker.Stop() + + // Add a health check ticker that runs less frequently + healthTicker := time.NewTicker(5 * time.Minute) // Much less frequent health checks + defer healthTicker.Stop() + + for { + select { + case <-s.shutdown: + return + case <-ticker.C: + // Run ping check in a goroutine to prevent blocking + go s.performPingCheck() + case <-healthTicker.C: + // Run health check in a goroutine to prevent blocking + go s.performHealthCheck() + } + } +} + +// performPingCheck handles the periodic ping checking +func (s *Server) performPingCheck() { + defer func() { + if r := recover(); r != nil { + log.Printf("Panic in performPingCheck: %v", r) + } + }() + + // Get a snapshot of clients without holding the lock for long + s.mu.RLock() + clientIDs := make([]string, 0, len(s.clients)) + for clientID := range s.clients { + clientIDs = append(clientIDs, clientID) + } + s.mu.RUnlock() + + // Process clients individually to prevent blocking + for _, clientID := range clientIDs { + func() { + // Get client safely + s.mu.RLock() + client := s.clients[clientID] + s.mu.RUnlock() + + if client == nil || !client.IsRegistered() || !client.IsConnected() { + return + } + + // Check activity without holding client lock for long + client.mu.RLock() + lastActivity := client.lastActivity + client.mu.RUnlock() + + // Only send ping if client hasn't been active recently + if time.Since(lastActivity) > 120*time.Second { + // Send ping in a non-blocking way + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("Panic sending ping to %s: %v", client.getClientInfo(), r) + } + }() + client.SendMessage(s.pingMessage) // Use pre-computed message + }() + } + }() + } +} + +// performHealthCheck checks all clients for health issues +func (s *Server) performHealthCheck() { + defer func() { + if r := recover(); r != nil { + log.Printf("Panic in performHealthCheck: %v", r) + } + }() + + // Get a snapshot of clients without holding the lock for long + s.mu.RLock() + clientIDs := make([]string, 0, len(s.clients)) + for clientID := range s.clients { + clientIDs = append(clientIDs, clientID) + } + totalClients := len(s.clients) + s.mu.RUnlock() + + unhealthyClients := 0 + disconnectedClients := []string{} // Store client IDs instead of pointers + + // Process clients in batches to prevent overwhelming the system + batchSize := 50 + for i := 0; i < len(clientIDs); i += batchSize { + end := i + batchSize + if end > len(clientIDs) { + end = len(clientIDs) + } + + batch := clientIDs[i:end] + for _, clientID := range batch { + func() { + // Get client safely + s.mu.RLock() + client := s.clients[clientID] + s.mu.RUnlock() + + if client == nil { + return + } + + healthy, reason := client.HealthCheck() + if !healthy { + unhealthyClients++ + + // Force disconnect clients that are definitely problematic + if strings.Contains(reason, "disconnected") || + strings.Contains(reason, "nil") || + strings.Contains(reason, "write errors") || + strings.Contains(reason, "registration timeout") { + disconnectedClients = append(disconnectedClients, clientID) + log.Printf("Marking client %s for disconnection: %s", client.getClientInfo(), reason) + } + } + }() + } + + // Small delay between batches to prevent overwhelming + time.Sleep(10 * time.Millisecond) + } + + // Disconnect problematic clients in a separate goroutine + if len(disconnectedClients) > 0 { + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("Panic during client disconnection: %v", r) + } + }() + + for _, clientID := range disconnectedClients { + s.mu.RLock() + client := s.clients[clientID] + s.mu.RUnlock() + + if client != nil { + client.ForceDisconnect("Connection health check failed") + } + } + }() + } + + // Log health statistics + if totalClients > 0 { + healthyPercentage := float64(totalClients-unhealthyClients) / float64(totalClients) * 100 + log.Printf("Client health check: %d total, %d healthy (%.1f%%), %d marked for disconnection", + totalClients, totalClients-unhealthyClients, healthyPercentage, len(disconnectedClients)) + } + + // Log detailed server statistics every 5 minutes for monitoring + s.LogServerStats() +} + +func (s *Server) AddClient(client *Client) { + s.mu.Lock() + defer s.mu.Unlock() + + // Enhanced validation before adding client + if client == nil { + log.Printf("Attempted to add nil client") + return + } + + if client.conn == nil { + log.Printf("Attempted to add client with nil connection") + return + } + + // Check client limit + if len(s.clients) >= s.config.Limits.MaxClients { + log.Printf("Server full, rejecting client from %s", client.getClientInfo()) + client.SendMessage("ERROR :Server full") + if client.conn != nil { + client.conn.Close() + } + return + } + + // Check for duplicate client IDs (shouldn't happen but defensive programming) + if _, exists := s.clients[client.clientID]; exists { + log.Printf("Duplicate client ID detected: %s, generating new ID", client.clientID) + // Generate a new unique ID + client.clientID = fmt.Sprintf("%s_%d_%d", client.host, time.Now().Unix(), len(s.clients)) + } + + s.clients[client.clientID] = client + log.Printf("Added client %s (total clients: %d/%d)", client.getClientInfo(), len(s.clients), s.config.Limits.MaxClients) +} + +func (s *Server) RemoveClient(client *Client) { + s.mu.Lock() + delete(s.clients, client.clientID) + s.mu.Unlock() + + // Send snomask notification for client disconnect (after releasing the lock) + if client.IsRegistered() { + s.sendSnomask('c', fmt.Sprintf("Client disconnect: %s (%s@%s)", + client.Nick(), client.User(), client.Host())) + } +} + +// GetServerStats returns detailed server statistics for monitoring +func (s *Server) GetServerStats() map[string]interface{} { + s.mu.RLock() + defer s.mu.RUnlock() + + stats := make(map[string]interface{}) + + // Basic counts + stats["total_clients"] = len(s.clients) + stats["total_channels"] = len(s.channels) + + // Client statistics + registeredClients := 0 + operatorClients := 0 + sslClients := 0 + unhealthyClients := 0 + + for _, client := range s.clients { + if client.IsRegistered() { + registeredClients++ + } + if client.IsOper() { + operatorClients++ + } + if client.IsSSL() { + sslClients++ + } + + // Quick health check + if healthy, _ := client.HealthCheck(); !healthy { + unhealthyClients++ + } + } + + stats["registered_clients"] = registeredClients + stats["operator_clients"] = operatorClients + stats["ssl_clients"] = sslClients + stats["unhealthy_clients"] = unhealthyClients + + // Channel statistics + totalChannelUsers := 0 + for _, channel := range s.channels { + totalChannelUsers += len(channel.GetClients()) + } + stats["total_channel_users"] = totalChannelUsers + + // Server linking statistics + stats["linked_servers"] = len(s.linkedServers) + + return stats +} + +// LogServerStats logs current server statistics +func (s *Server) LogServerStats() { + stats := s.GetServerStats() + log.Printf("Server Statistics: %d clients (%d registered, %d operators, %d SSL, %d unhealthy), %d channels, %d linked servers", + stats["total_clients"], stats["registered_clients"], stats["operator_clients"], + stats["ssl_clients"], stats["unhealthy_clients"], stats["total_channels"], stats["linked_servers"]) +} + +// sendSnomask sends a server notice to operators watching a specific snomask +func (s *Server) sendSnomask(snomask rune, message string) { + s.mu.RLock() + defer s.mu.RUnlock() + + for _, client := range s.clients { + if client.IsOper() && client.HasSnomask(snomask) { + client.SendMessage(fmt.Sprintf(":%s NOTICE %s :*** %s", + s.config.Server.Name, client.Nick(), message)) + } + } +} + +// ReloadConfig reloads the server configuration +func (s *Server) ReloadConfig() error { + config, err := LoadConfig("config.json") + if err != nil { + return fmt.Errorf("failed to load config: %v", err) + } + + s.mu.Lock() + s.config = config + s.mu.Unlock() + + return nil +} + +func (s *Server) GetClient(nick string) *Client { + s.mu.RLock() + defer s.mu.RUnlock() + + for _, client := range s.clients { + if strings.EqualFold(client.Nick(), nick) { + return client + } + } + return nil +} + +func (s *Server) GetClientByHost(host string) *Client { + s.mu.RLock() + defer s.mu.RUnlock() + + for _, client := range s.clients { + if client.Host() == host { + return client + } + } + return nil +} + +func (s *Server) GetClientByID(clientID string) *Client { + s.mu.RLock() + defer s.mu.RUnlock() + return s.clients[clientID] +} + +func (s *Server) GetClients() map[string]*Client { + s.mu.RLock() + defer s.mu.RUnlock() + + clients := make(map[string]*Client) + for clientID, client := range s.clients { + clients[clientID] = client + } + return clients +} + +func (s *Server) GetChannel(name string) *Channel { + s.mu.RLock() + defer s.mu.RUnlock() + return s.channels[strings.ToLower(name)] +} + +func (s *Server) GetOrCreateChannel(name string) *Channel { + s.mu.Lock() + defer s.mu.Unlock() + + channelName := strings.ToLower(name) + if channel, exists := s.channels[channelName]; exists { + return channel + } + + // Create new channel + channel := NewChannel(name) + // Set default modes + for _, mode := range s.config.Channels.DefaultModes { + if mode != '+' { + channel.SetMode(rune(mode), true) + } + } + s.channels[channelName] = channel + return channel +} + +func (s *Server) RemoveChannel(name string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.channels, strings.ToLower(name)) +} + +func (s *Server) GetChannels() map[string]*Channel { + s.mu.RLock() + defer s.mu.RUnlock() + + channels := make(map[string]*Channel) + for name, channel := range s.channels { + channels[name] = channel + } + return channels +} + +func (s *Server) CreateChannel(name string) *Channel { + s.mu.Lock() + defer s.mu.Unlock() + + if len(s.channels) >= s.config.Limits.MaxChannels { + return nil + } + + channel := NewChannel(name) + s.channels[strings.ToLower(name)] = channel + return channel +} + +func (s *Server) GetClientCount() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.clients) +} + +func (s *Server) GetChannelCount() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.channels) +} + +func (s *Server) IsNickInUse(nick string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + + for _, client := range s.clients { + if strings.EqualFold(client.Nick(), nick) { + return true + } + } + return false +} + +func (s *Server) HandleMessage(client *Client, message string) { + // Parse IRCv3 message tags if present + var tags map[string]string + var actualMessage string + + if strings.HasPrefix(message, "@") { + // Message has IRCv3 tags + spaceIndex := strings.Index(message, " ") + if spaceIndex == -1 { + // Message is only tags (like typing indicators), this is valid - just ignore + if DebugMode { + log.Printf("<<< RECV from %s: %s (tags-only message, ignoring)", client.Host(), message) + } + return + } + + tagString := message[1:spaceIndex] // Remove @ prefix + actualMessage = strings.TrimSpace(message[spaceIndex+1:]) + + // Parse tags + tags = make(map[string]string) + tagPairs := strings.Split(tagString, ";") + for _, pair := range tagPairs { + if strings.Contains(pair, "=") { + kv := strings.SplitN(pair, "=", 2) + tags[kv[0]] = kv[1] + } else { + tags[pair] = "" + } + } + + // If actualMessage is empty or just a colon, it's a tags-only message + if actualMessage == "" || actualMessage == ":" { + if DebugMode { + log.Printf("<<< RECV from %s: %s (tags-only message, ignoring)", client.Host(), message) + } + return + } + } else { + actualMessage = message + } + + parts := strings.Fields(actualMessage) + if len(parts) == 0 { + return + } + + command := strings.ToUpper(parts[0]) + + // Log the command for debugging + if DebugMode { + log.Printf("<<< RECV from %s: %s", client.Host(), message) + } else { + log.Printf("Client %s: %s", client.Host(), message) + } + + switch command { + case "CAP": + client.handleCap(parts) + case "NICK": + client.handleNick(parts) + case "USER": + client.handleUser(parts) + case "PING": + client.handlePing(parts) + case "PONG": + client.handlePong(parts) + case "JOIN": + client.handleJoin(parts) + case "PART": + client.handlePart(parts) + case "PRIVMSG": + client.handlePrivmsg(parts) + case "NOTICE": + client.handleNotice(parts) + case "TAGMSG": + client.handleTagmsg(parts, tags) + case "WHO": + client.handleWho(parts) + case "WHOIS": + client.handleWhois(parts) + case "NAMES": + client.handleNames(parts) + case "MODE": + client.handleMode(parts) + case "OPER": + client.handleOper(parts) + case "SNOMASK": + client.handleSnomask(parts) + case "GLOBALNOTICE": + client.handleGlobalNotice(parts) + case "OPERWALL": + client.handleOperWall(parts) + case "WALLOPS": + client.handleWallops(parts) + case "REHASH": + client.handleRehash() + case "TRACE": + client.handleTrace(parts) + case "HELPOP": + client.handleHelpop(parts) + case "TOPIC": + client.handleTopic(parts) + case "KICK": + client.handleKick(parts) + case "INVITE": + client.handleInvite(parts) + case "AWAY": + client.handleAway(parts) + case "LIST": + client.handleList() + case "KILL": + client.handleKill(parts) + case "QUIT": + client.handleQuit(parts) + case "CONNECT": + client.handleConnect(parts) + case "SQUIT": + client.handleSquit(parts) + case "LINKS": + client.handleLinks() + case "USERHOST": + client.handleUserhost(parts) + case "ISON": + client.handleIson(parts) + case "TIME": + client.handleTime() + case "VERSION": + client.handleVersion() + case "ADMIN": + client.handleAdmin() + case "INFO": + client.handleInfo() + case "LUSERS": + client.handleLusers() + case "STATS": + client.handleStats(parts) + case "SILENCE": + client.handleSilence(parts) + case "MONITOR": + client.handleMonitor(parts) + case "AUTHENTICATE": + client.handleAuthenticate(parts) + // Services/Admin Commands + case "CHGHOST": + client.handleChghost(parts) + case "SVSNICK": + client.handleSvsnick(parts) + case "SVSMODE": + client.handleSvsmode(parts) + case "SAMODE": + client.handleSamode(parts) + case "SANICK": + client.handleSanick(parts) + case "SAKICK": + client.handleSakick(parts) + case "SAPART": + client.handleSapart(parts) + case "SAJOIN": + client.handleSajoin(parts) + case "WHOWAS": + client.handleWhowas(parts) + case "MOTD": + client.handleMotd() + case "RULES": + client.handleRules() + case "MAP": + client.handleMap() + case "KNOCK": + client.handleKnock(parts) + case "SETNAME": + client.handleSetname(parts) + case "DIE": + client.handleDie() + default: + client.SendNumeric(ERR_UNKNOWNCOMMAND, command+" :Unknown command") + } +} + +// Shutdown gracefully shuts down the server +func (s *Server) Shutdown() { + log.Println("Initiating graceful shutdown...") + + // Stop health monitoring first to prevent interference + if s.healthMonitor != nil { + s.healthMonitor.Stop() + } + + // Close listeners immediately to stop accepting new connections + go func() { + if s.listener != nil { + s.listener.Close() + } + if s.sslListener != nil { + s.sslListener.Close() + } + if s.serverListener != nil { + s.serverListener.Close() + } + }() + + // Signal shutdown to all goroutines (non-blocking) + select { + case <-s.shutdown: + // Already closed + default: + close(s.shutdown) + } + + // Disconnect all linked servers in background + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("Panic during server disconnection: %v", r) + } + }() + + s.mu.RLock() + linkedServers := make([]*LinkedServer, 0, len(s.linkedServers)) + for _, server := range s.linkedServers { + linkedServers = append(linkedServers, server) + } + s.mu.RUnlock() + + for _, linkedServer := range linkedServers { + linkedServer.Disconnect() + } + }() + + // Notify and disconnect all clients with timeout + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("Panic during client disconnection: %v", r) + } + }() + + // Get client IDs without holding lock for long + s.mu.RLock() + clientIDs := make([]string, 0, len(s.clients)) + for clientID := range s.clients { + clientIDs = append(clientIDs, clientID) + } + s.mu.RUnlock() + + // Disconnect clients in batches to prevent overwhelming + batchSize := 10 + for i := 0; i < len(clientIDs); i += batchSize { + end := i + batchSize + if end > len(clientIDs) { + end = len(clientIDs) + } + + batch := clientIDs[i:end] + for _, clientID := range batch { + s.mu.RLock() + client := s.clients[clientID] + s.mu.RUnlock() + + if client != nil { + // Send shutdown message with timeout + go func(c *Client) { + defer func() { + if r := recover(); r != nil { + log.Printf("Panic notifying client during shutdown: %v", r) + } + }() + c.SendMessage("ERROR :Server shutting down") + // Give client time to process message + time.Sleep(100 * time.Millisecond) + c.ForceDisconnect("Server shutdown") + }(client) + } + } + + // Small delay between batches + time.Sleep(50 * time.Millisecond) + } + }() + + // Give everything time to shut down gracefully + time.Sleep(2 * time.Second) + + log.Println("Server shutdown complete") +} + +// Server linking methods + +// startServerListener starts listening for incoming server connections +func (s *Server) startServerListener() { + if !s.config.Linking.Enable { + return + } + + addr := fmt.Sprintf("%s:%d", s.config.Server.Listen.Host, s.config.Linking.ServerPort) + listener, err := net.Listen("tcp", addr) + if err != nil { + log.Printf("Failed to start server listener on %s: %v", addr, err) + return + } + + s.serverListener = listener + log.Printf("IRC server listening for server links on %s", addr) + + for { + select { + case <-s.shutdown: + return + default: + conn, err := listener.Accept() + if err != nil { + continue + } + + log.Printf("Incoming server connection from %s", conn.RemoteAddr()) + go s.handleIncomingServer(conn) + } + } +} + +// startAutoConnections attempts to connect to configured auto-connect servers +func (s *Server) startAutoConnections() { + if !s.config.Linking.Enable { + return + } + + // Wait a bit before starting auto-connections + time.Sleep(5 * time.Second) + + for _, link := range s.config.Linking.Links { + if link.AutoConnect { + go s.connectToServer(link.Name, link.Host, link.Port, link.Password, link.Hub, link.Description) + } + } +} + +// connectToServer connects to a remote server +func (s *Server) connectToServer(name, host string, port int, password string, hub bool, description string) { + linkedServer := NewLinkedServer(name, host, port, password, hub, description, s) + + s.mu.Lock() + s.linkedServers[name] = linkedServer + s.mu.Unlock() + + for { + select { + case <-s.shutdown: + return + default: + if !linkedServer.IsConnected() { + log.Printf("Attempting to connect to server %s at %s:%d", name, host, port) + if err := linkedServer.Connect(); err != nil { + log.Printf("Failed to connect to server %s: %v", name, err) + time.Sleep(30 * time.Second) // Wait before retry + continue + } + log.Printf("Successfully connected to server %s", name) + } + + // Sleep before checking again + time.Sleep(60 * time.Second) + } + } +} + +// handleIncomingServer handles an incoming server connection +func (s *Server) handleIncomingServer(conn net.Conn) { + defer conn.Close() + + log.Printf("Handling incoming server connection from %s", conn.RemoteAddr()) + + // Create a temporary linked server for authentication + tempServer := &LinkedServer{ + conn: conn, + server: s, + connected: true, + } + + // Handle the connection (this will process authentication) + tempServer.Handle() +} + +// AddLinkedServer adds a linked server to the server +func (s *Server) AddLinkedServer(linkedServer *LinkedServer) { + s.mu.Lock() + defer s.mu.Unlock() + s.linkedServers[linkedServer.Name()] = linkedServer +} + +// RemoveLinkedServer removes a linked server +func (s *Server) RemoveLinkedServer(name string) { + s.mu.Lock() + defer s.mu.Unlock() + if linkedServer, exists := s.linkedServers[name]; exists { + linkedServer.Disconnect() + delete(s.linkedServers, name) + } +} + +// GetLinkedServer returns a linked server by name +func (s *Server) GetLinkedServer(name string) *LinkedServer { + s.mu.RLock() + defer s.mu.RUnlock() + return s.linkedServers[name] +} + +// GetLinkedServers returns all linked servers +func (s *Server) GetLinkedServers() map[string]*LinkedServer { + s.mu.RLock() + defer s.mu.RUnlock() + + servers := make(map[string]*LinkedServer) + for name, server := range s.linkedServers { + servers[name] = server + } + return servers +} + +// BroadcastToServers sends a message to all linked servers +func (s *Server) BroadcastToServers(message string) { + s.mu.RLock() + defer s.mu.RUnlock() + + for _, linkedServer := range s.linkedServers { + if linkedServer.IsConnected() { + linkedServer.SendMessage(message) + } + } +} + +// Ban management functions + +// parseDuration parses duration strings like "1d", "2h", "30m", "0" (permanent) +func parseDuration(durationStr string) time.Duration { + if durationStr == "0" { + return 0 // Permanent + } + + if len(durationStr) < 2 { + return 24 * time.Hour // Default to 1 day + } + + unit := durationStr[len(durationStr)-1] + valueStr := durationStr[:len(durationStr)-1] + + var value int + fmt.Sscanf(valueStr, "%d", &value) + + switch unit { + case 's': + return time.Duration(value) * time.Second + case 'm': + return time.Duration(value) * time.Minute + case 'h': + return time.Duration(value) * time.Hour + case 'd': + return time.Duration(value) * 24 * time.Hour + case 'w': + return time.Duration(value) * 7 * 24 * time.Hour + default: + return 24 * time.Hour // Default to 1 day + } +} + diff --git a/services.go b/services.go new file mode 100644 index 0000000..8bdb0f1 --- /dev/null +++ b/services.go @@ -0,0 +1,509 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "log" + "strings" + "time" +) + +// ServicesManager handles all network services +type ServicesManager struct { + server *Server + config *ServicesConfig + nickServ *NickServ + chanServ *ChanServ + operServ *OperServ + memoServ *MemoServ + enabled bool +} + +// NickServ handles nickname registration and authentication +type NickServ struct { + manager *ServicesManager + nick string + accounts map[string]*NickAccount +} + +// ChanServ handles channel registration and management +type ChanServ struct { + manager *ServicesManager + nick string + channels map[string]*RegisteredChannel +} + +// OperServ handles network operator services +type OperServ struct { + manager *ServicesManager + nick string +} + +// MemoServ handles user-to-user messages +type MemoServ struct { + manager *ServicesManager + nick string + memos map[string][]*Memo +} + +// NickAccount represents a registered nickname +type NickAccount struct { + Nick string `json:"nick"` + PasswordHash string `json:"password_hash"` + Email string `json:"email"` + RegisterTime time.Time `json:"register_time"` + LastSeen time.Time `json:"last_seen"` + Settings map[string]string `json:"settings"` + AccessList []string `json:"access_list"` + Flags []string `json:"flags"` +} + +// RegisteredChannel represents a registered channel +type RegisteredChannelServices struct { + Name string `json:"name"` + Founder string `json:"founder"` + RegisterTime time.Time `json:"register_time"` + Topic string `json:"topic"` + Modes string `json:"modes"` + AccessList map[string]*ChannelAccess `json:"access_list"` + Settings map[string]string `json:"settings"` + AutoModes map[string]string `json:"auto_modes"` +} + +// ChannelAccess represents access levels for channels - using the one from database.go +// type ChannelAccess struct { +// Nick string `json:"nick"` +// Level int `json:"level"` // 0=banned, 1=voice, 2=halfop, 3=op, 4=sop, 5=founder +// SetBy string `json:"set_by"` +// SetTime time.Time `json:"set_time"` +// LastUsed time.Time `json:"last_used"` +// } + +// Memo represents a user memo +type Memo struct { + From string `json:"from"` + To string `json:"to"` + Message string `json:"message"` + Time time.Time `json:"time"` + Read bool `json:"read"` +} + +// ServicesConfig configuration for services +type ServicesConfig struct { + Enable bool `json:"enable"` + NickServ NickServConfig `json:"nickserv"` + ChanServ ChanServConfig `json:"chanserv"` + OperServ OperServConfig `json:"operserv"` + MemoServ MemoServConfig `json:"memoserv"` + Database DatabaseConfig `json:"database"` +} + +type NickServConfig struct { + Enable bool `json:"enable"` + Nick string `json:"nick"` + User string `json:"user"` + Host string `json:"host"` + Realname string `json:"realname"` + ExpireTime int `json:"expire_time"` // Days until unused nicks expire + IdentifyTimeout int `json:"identify_timeout"` // Seconds to identify before kill + EmailVerify bool `json:"email_verify"` + RestrictReg bool `json:"restrict_registration"` +} + +type ChanServConfig struct { + Enable bool `json:"enable"` + Nick string `json:"nick"` + User string `json:"user"` + Host string `json:"host"` + Realname string `json:"realname"` + ExpireTime int `json:"expire_time"` // Days until unused channels expire + MaxChannels int `json:"max_channels"` // Max channels per user + AutoDeop bool `json:"auto_deop"` // Auto-deop users without access +} + +type OperServConfig struct { + Enable bool `json:"enable"` + Nick string `json:"nick"` + User string `json:"user"` + Host string `json:"host"` + Realname string `json:"realname"` +} + +type MemoServConfig struct { + Enable bool `json:"enable"` + Nick string `json:"nick"` + User string `json:"user"` + Host string `json:"host"` + Realname string `json:"realname"` + MaxMemos int `json:"max_memos"` + MemoExpire int `json:"memo_expire"` // Days until memos expire +} + +// NewServicesManager creates a new services manager +func NewServicesManager(server *Server, config *ServicesConfig) *ServicesManager { + sm := &ServicesManager{ + server: server, + config: config, + enabled: config.Enable, + } + + if config.NickServ.Enable { + sm.nickServ = &NickServ{ + manager: sm, + nick: config.NickServ.Nick, + accounts: make(map[string]*NickAccount), + } + } + + if config.ChanServ.Enable { + sm.chanServ = &ChanServ{ + manager: sm, + nick: config.ChanServ.Nick, + channels: make(map[string]*RegisteredChannel), + } + } + + if config.OperServ.Enable { + sm.operServ = &OperServ{ + manager: sm, + nick: config.OperServ.Nick, + } + } + + if config.MemoServ.Enable { + sm.memoServ = &MemoServ{ + manager: sm, + nick: config.MemoServ.Nick, + memos: make(map[string][]*Memo), + } + } + + return sm +} + +// Start initializes and starts all enabled services +func (sm *ServicesManager) Start() error { + if !sm.enabled { + return nil + } + + log.Println("Starting TechIRCd Services...") + + // Create service clients + if sm.nickServ != nil { + sm.createServiceClient(sm.config.NickServ.Nick, sm.config.NickServ.User, + sm.config.NickServ.Host, sm.config.NickServ.Realname) + } + + if sm.chanServ != nil { + sm.createServiceClient(sm.config.ChanServ.Nick, sm.config.ChanServ.User, + sm.config.ChanServ.Host, sm.config.ChanServ.Realname) + } + + if sm.operServ != nil { + sm.createServiceClient(sm.config.OperServ.Nick, sm.config.OperServ.User, + sm.config.OperServ.Host, sm.config.OperServ.Realname) + } + + if sm.memoServ != nil { + sm.createServiceClient(sm.config.MemoServ.Nick, sm.config.MemoServ.User, + sm.config.MemoServ.Host, sm.config.MemoServ.Realname) + } + + log.Println("Services started successfully") + return nil +} + +// createServiceClient creates a virtual client for a service +func (sm *ServicesManager) createServiceClient(nick, user, host, realname string) { + // Create a virtual service client that appears as a regular user + serviceClient := &Client{ + nick: nick, + user: user, + host: host, + realname: realname, + server: sm.server, + modes: make(map[rune]bool), + channels: make(map[string]*Channel), + capabilities: make(map[string]bool), + } + + // Set service modes + serviceClient.modes['S'] = true // Service mode + serviceClient.modes['o'] = true // Operator mode + + // Add to server + sm.server.clients[strings.ToLower(nick)] = serviceClient + + // Send introduction to network + sm.server.BroadcastToServers(fmt.Sprintf(":%s NICK %s", sm.server.config.Server.Name, nick)) +} + +// HandleMessage processes messages directed to services +func (sm *ServicesManager) HandleMessage(from *Client, target, message string) bool { + if !sm.enabled { + return false + } + + lowerTarget := strings.ToLower(target) + parts := strings.Fields(message) + if len(parts) == 0 { + return false + } + + command := strings.ToUpper(parts[0]) + + switch lowerTarget { + case strings.ToLower(sm.config.NickServ.Nick): + return sm.handleNickServCommand(from, command, parts[1:]) + case strings.ToLower(sm.config.ChanServ.Nick): + return sm.handleChanServCommand(from) + case strings.ToLower(sm.config.OperServ.Nick): + return sm.handleOperServCommand(from) + case strings.ToLower(sm.config.MemoServ.Nick): + return sm.handleMemoServCommand(from) + } + + return false +} + +// NickServ command handlers +func (sm *ServicesManager) handleNickServCommand(from *Client, command string, args []string) bool { + switch command { + case "REGISTER": + return sm.handleNickServRegister(from, args) + case "IDENTIFY": + return sm.handleNickServIdentify(from, args) + case "INFO": + return sm.handleNickServInfo(from, args) + case "DROP": + return sm.handleNickServDrop(from, args) + case "SET": + return sm.handleNickServSet(from) + case "ACCESS": + return sm.handleNickServAccess(from) + case "HELP": + return sm.handleNickServHelp(from) + default: + sm.sendServiceNotice(sm.config.NickServ.Nick, from.nick, + fmt.Sprintf("Unknown command %s. Type /msg %s HELP for help.", command, sm.config.NickServ.Nick)) + return true + } +} + +func (sm *ServicesManager) handleNickServRegister(from *Client, args []string) bool { + if len(args) < 2 { + sm.sendServiceNotice(sm.config.NickServ.Nick, from.nick, + "Syntax: REGISTER ") + return true + } + + nick := strings.ToLower(from.nick) + password := args[0] + email := args[1] + + // Check if nick is already registered + if _, exists := sm.nickServ.accounts[nick]; exists { + sm.sendServiceNotice(sm.config.NickServ.Nick, from.nick, + "This nickname is already registered.") + return true + } + + // Hash password + hash := sha256.Sum256([]byte(password)) + passwordHash := hex.EncodeToString(hash[:]) + + // Create account + account := &NickAccount{ + Nick: from.nick, + PasswordHash: passwordHash, + Email: email, + RegisterTime: time.Now(), + LastSeen: time.Now(), + Settings: make(map[string]string), + AccessList: []string{}, + Flags: []string{}, + } + + sm.nickServ.accounts[nick] = account + + // Set user as identified + from.account = from.nick + + sm.sendServiceNotice(sm.config.NickServ.Nick, from.nick, + fmt.Sprintf("Nickname %s has been registered successfully.", from.nick)) + + return true +} + +func (sm *ServicesManager) handleNickServIdentify(from *Client, args []string) bool { + if len(args) < 1 { + sm.sendServiceNotice(sm.config.NickServ.Nick, from.nick, + "Syntax: IDENTIFY ") + return true + } + + nick := strings.ToLower(from.nick) + password := args[0] + + account, exists := sm.nickServ.accounts[nick] + if !exists { + sm.sendServiceNotice(sm.config.NickServ.Nick, from.nick, + "This nickname is not registered.") + return true + } + + // Check password + hash := sha256.Sum256([]byte(password)) + passwordHash := hex.EncodeToString(hash[:]) + + if account.PasswordHash != passwordHash { + sm.sendServiceNotice(sm.config.NickServ.Nick, from.nick, + "Invalid password.") + return true + } + + // Set user as identified + from.account = from.nick + account.LastSeen = time.Now() + + sm.sendServiceNotice(sm.config.NickServ.Nick, from.nick, + fmt.Sprintf("You are now identified for %s.", from.nick)) + + return true +} + +func (sm *ServicesManager) handleNickServInfo(from *Client, args []string) bool { + var targetNick string + if len(args) > 0 { + targetNick = args[0] + } else { + targetNick = from.nick + } + + nick := strings.ToLower(targetNick) + account, exists := sm.nickServ.accounts[nick] + if !exists { + sm.sendServiceNotice(sm.config.NickServ.Nick, from.nick, + fmt.Sprintf("The nickname %s is not registered.", targetNick)) + return true + } + + sm.sendServiceNotice(sm.config.NickServ.Nick, from.nick, + fmt.Sprintf("Information for %s:", account.Nick)) + sm.sendServiceNotice(sm.config.NickServ.Nick, from.nick, + fmt.Sprintf("Registered: %s", account.RegisterTime.Format("Jan 02, 2006 15:04:05 MST"))) + sm.sendServiceNotice(sm.config.NickServ.Nick, from.nick, + fmt.Sprintf("Last seen: %s", account.LastSeen.Format("Jan 02, 2006 15:04:05 MST"))) + + return true +} + +func (sm *ServicesManager) handleNickServDrop(from *Client, _ []string) bool { + nick := strings.ToLower(from.nick) + + if from.account != from.nick { + sm.sendServiceNotice(sm.config.NickServ.Nick, from.nick, + "You must be identified to drop your nickname.") + return true + } + + delete(sm.nickServ.accounts, nick) + from.account = "" + + sm.sendServiceNotice(sm.config.NickServ.Nick, from.nick, + fmt.Sprintf("Nickname %s has been dropped.", from.nick)) + + return true +} + +func (sm *ServicesManager) handleNickServSet(from *Client) bool { + // Handle SET commands (EMAIL, PASSWORD, etc.) + sm.sendServiceNotice(sm.config.NickServ.Nick, from.nick, + "SET command not yet implemented.") + return true +} + +func (sm *ServicesManager) handleNickServAccess(from *Client) bool { + // Handle ACCESS commands (ADD, DEL, LIST) + sm.sendServiceNotice(sm.config.NickServ.Nick, from.nick, + "ACCESS command not yet implemented.") + return true +} + +func (sm *ServicesManager) handleNickServHelp(from *Client) bool { + help := []string{ + "*** NickServ Help ***", + "REGISTER - Register your nickname", + "IDENTIFY - Identify to your nickname", + "INFO [nick] - Show information about a nickname", + "DROP - Drop your nickname registration", + "SET - Change settings", + "ACCESS - Manage access list", + "HELP - Show this help", + } + + for _, line := range help { + sm.sendServiceNotice(sm.config.NickServ.Nick, from.nick, line) + } + + return true +} + +// ChanServ command handlers (placeholder) +func (sm *ServicesManager) handleChanServCommand(from *Client) bool { + sm.sendServiceNotice(sm.config.ChanServ.Nick, from.nick, + "ChanServ commands not yet implemented.") + return true +} + +// OperServ command handlers (placeholder) +func (sm *ServicesManager) handleOperServCommand(from *Client) bool { + sm.sendServiceNotice(sm.config.OperServ.Nick, from.nick, + "OperServ commands not yet implemented.") + return true +} + +// MemoServ command handlers (placeholder) +func (sm *ServicesManager) handleMemoServCommand(from *Client) bool { + sm.sendServiceNotice(sm.config.MemoServ.Nick, from.nick, + "MemoServ commands not yet implemented.") + return true +} + +// sendServiceNotice sends a notice from a service to a user +func (sm *ServicesManager) sendServiceNotice(from, to, message string) { + if client := sm.server.GetClient(to); client != nil { + client.SendMessage(fmt.Sprintf(":%s NOTICE %s :%s", from, to, message)) + } +} + +// IsServiceNick checks if a nick is a service +func (sm *ServicesManager) IsServiceNick(nick string) bool { + if !sm.enabled { + return false + } + + lowerNick := strings.ToLower(nick) + return lowerNick == strings.ToLower(sm.config.NickServ.Nick) || + lowerNick == strings.ToLower(sm.config.ChanServ.Nick) || + lowerNick == strings.ToLower(sm.config.OperServ.Nick) || + lowerNick == strings.ToLower(sm.config.MemoServ.Nick) +} + +// GetUserAccount returns the account for a nick +func (sm *ServicesManager) GetUserAccount(nick string) *NickAccount { + if sm.nickServ == nil { + return nil + } + return sm.nickServ.accounts[strings.ToLower(nick)] +} + +// IsIdentified checks if a user is identified +func (sm *ServicesManager) IsIdentified(nick string) bool { + if client := sm.server.GetClient(nick); client != nil { + return client.account != "" + } + return false +} diff --git a/stress_config.json b/stress_config.json new file mode 100644 index 0000000..40c20ef --- /dev/null +++ b/stress_config.json @@ -0,0 +1,309 @@ +{ + "server": { + "host": "localhost", + "port": 6667, + "ssl": false, + "ssl_port": 6697, + "nick_prefix": "StressBot", + "auto_join_channels": ["#test", "#general"] + }, + "log_level": "INFO", + "scenarios": [ + { + "name": "Basic Connection Test", + "description": "Test basic connection and registration", + "client_count": 10, + "duration": 30, + "connect_gradually": false, + "disconnect_gradually": true, + "disconnect_delay": 0.2, + "delay_after": 5, + "activities": [] + }, + { + "name": "Mass Connection Stress", + "description": "Stress test with many simultaneous connections", + "client_count": 50, + "duration": 60, + "connect_gradually": false, + "disconnect_gradually": false, + "delay_after": 5, + "activities": [ + { + "type": "channel_flood", + "channel": "#test", + "message_count": 20, + "delay": 2.0 + } + ] + }, + { + "name": "Gradual Connection Test", + "description": "Test gradual connection buildup", + "client_count": 30, + "duration": 45, + "connect_gradually": true, + "connect_delay": 0.1, + "disconnect_gradually": true, + "disconnect_delay": 0.1, + "delay_after": 3, + "activities": [ + { + "type": "private_flood", + "message_count": 15, + "delay": 3.0 + } + ] + }, + { + "name": "Heavy Activity Test", + "description": "Test server under heavy message load", + "client_count": 25, + "duration": 90, + "connect_gradually": false, + "disconnect_gradually": false, + "delay_after": 5, + "activities": [ + { + "type": "channel_flood", + "channel": "#test", + "message_count": 30, + "delay": 1.0 + }, + { + "type": "private_flood", + "message_count": 20, + "delay": 1.5 + }, + { + "type": "join_part_spam", + "iterations": 10, + "channels": ["#spam1", "#spam2", "#spam3"], + "delay": 2.0 + }, + { + "type": "random_commands", + "command_count": 15, + "commands": ["WHO #test", "LIST", "VERSION", "WHOIS StressBot0001"], + "delay": 2.5 + } + ] + }, + { + "name": "Nick Change Spam", + "description": "Test rapid nick changes", + "client_count": 15, + "duration": 30, + "connect_gradually": false, + "disconnect_gradually": false, + "delay_after": 3, + "activities": [ + { + "type": "nick_change_spam", + "iterations": 20, + "delay": 1.0 + } + ] + }, + { + "name": "Channel Chaos Test", + "description": "Rapid JOIN/PART with message flooding", + "client_count": 20, + "duration": 60, + "connect_gradually": true, + "connect_delay": 0.05, + "disconnect_gradually": true, + "disconnect_delay": 0.05, + "delay_after": 5, + "activities": [ + { + "type": "join_part_spam", + "iterations": 25, + "channels": ["#chaos1", "#chaos2", "#chaos3", "#chaos4"], + "delay": 0.5 + }, + { + "type": "channel_flood", + "channel": "#chaos1", + "message_count": 40, + "delay": 1.0 + } + ] + }, + { + "name": "Maximum Load Test", + "description": "Push server to maximum capacity", + "client_count": 100, + "duration": 120, + "connect_gradually": true, + "connect_delay": 0.02, + "disconnect_gradually": true, + "disconnect_delay": 0.01, + "delay_after": 10, + "activities": [ + { + "type": "channel_flood", + "channel": "#maxload", + "message_count": 50, + "delay": 0.8 + }, + { + "type": "private_flood", + "message_count": 30, + "delay": 1.2 + }, + { + "type": "join_part_spam", + "iterations": 15, + "channels": ["#load1", "#load2", "#load3", "#load4", "#load5"], + "delay": 1.5 + }, + { + "type": "random_commands", + "command_count": 25, + "commands": ["WHO #maxload", "LIST", "VERSION", "WHOIS StressBot0050", "NAMES #load1"], + "delay": 2.0 + } + ] + }, + { + "name": "EXTREME: Mass Connection Bomb", + "description": "BRUTAL: 200+ connections at once", + "client_count": 200, + "duration": 120, + "connect_gradually": false, + "disconnect_gradually": false, + "delay_after": 10, + "activities": [ + { + "type": "channel_flood", + "channel": "#massacre", + "message_count": 50, + "delay": 0.1 + }, + { + "type": "private_flood", + "message_count": 30, + "delay": 0.2 + } + ] + }, + { + "name": "EXTREME: Rapid Fire Connections", + "description": "BRUTAL: Connect 150 clients as fast as possible", + "client_count": 150, + "duration": 60, + "connect_gradually": true, + "connect_delay": 0.001, + "disconnect_gradually": true, + "disconnect_delay": 0.001, + "delay_after": 5, + "activities": [ + { + "type": "channel_flood", + "channel": "#rapidfire", + "message_count": 100, + "delay": 0.05 + } + ] + }, + { + "name": "EXTREME: Message Hurricane", + "description": "BRUTAL: Maximum message throughput test", + "client_count": 80, + "duration": 180, + "connect_gradually": false, + "disconnect_gradually": false, + "delay_after": 10, + "activities": [ + { + "type": "channel_flood", + "channel": "#hurricane", + "message_count": 200, + "delay": 0.01 + }, + { + "type": "private_flood", + "message_count": 150, + "delay": 0.02 + }, + { + "type": "join_part_spam", + "iterations": 50, + "channels": ["#chaos1", "#chaos2", "#chaos3", "#chaos4", "#chaos5"], + "delay": 0.1 + }, + { + "type": "random_commands", + "command_count": 100, + "commands": ["WHO #hurricane", "LIST", "VERSION", "TIME", "ADMIN", "INFO"], + "delay": 0.05 + } + ] + }, + { + "name": "EXTREME: Connection Chaos", + "description": "BRUTAL: Rapid connect/disconnect cycles", + "client_count": 100, + "duration": 90, + "connect_gradually": true, + "connect_delay": 0.01, + "disconnect_gradually": true, + "disconnect_delay": 0.01, + "delay_after": 5, + "activities": [ + { + "type": "nick_spam", + "iterations": 20, + "delay": 0.1 + }, + { + "type": "channel_flood", + "channel": "#chaos", + "message_count": 75, + "delay": 0.1 + } + ] + }, + { + "name": "EXTREME: The Nuclear Option", + "description": "ULTIMATE BRUTAL: Everything at maximum intensity", + "client_count": 300, + "duration": 300, + "connect_gradually": true, + "connect_delay": 0.002, + "disconnect_gradually": false, + "delay_after": 15, + "activities": [ + { + "type": "channel_flood", + "channel": "#nuclear", + "message_count": 500, + "delay": 0.005 + }, + { + "type": "private_flood", + "message_count": 300, + "delay": 0.01 + }, + { + "type": "join_part_spam", + "iterations": 100, + "channels": ["#nuke1", "#nuke2", "#nuke3", "#nuke4", "#nuke5", "#nuke6", "#nuke7", "#nuke8"], + "delay": 0.02 + }, + { + "type": "nick_spam", + "iterations": 50, + "delay": 0.03 + }, + { + "type": "random_commands", + "command_count": 200, + "commands": ["WHO #nuclear", "LIST", "VERSION", "TIME", "ADMIN", "INFO", "LUSERS"], + "delay": 0.01 + } + ] + } + ] +} diff --git a/stress_test.py b/stress_test.py new file mode 100644 index 0000000..250288e --- /dev/null +++ b/stress_test.py @@ -0,0 +1,453 @@ +#!/usr/bin/env python3 +""" +TechIRCd Stress Testing Tool +Advanced IRC server stress testing with configurable scenarios +""" + +import asyncio +import json +import random +import string +import time +import logging +import argparse +from typing import List, Dict, Any +import socket +import ssl + +class IRCClient: + def __init__(self, config: Dict[str, Any], client_id: int): + self.config = config + self.client_id = client_id + self.nick = f"{config['nick_prefix']}{client_id:04d}" + self.user = f"user{client_id}" + self.realname = f"Stress Test Client {client_id}" + self.reader = None + self.writer = None + self.connected = False + self.registered = False + self.channels = [] + self.message_count = 0 + self.start_time = None + + async def connect(self): + """Connect to IRC server""" + try: + if self.config.get('ssl', False): + context = ssl.create_default_context() + self.reader, self.writer = await asyncio.open_connection( + self.config['host'], + self.config.get('ssl_port', 6697), + ssl=context + ) + else: + self.reader, self.writer = await asyncio.open_connection( + self.config['host'], + self.config['port'] + ) + + self.connected = True + self.start_time = time.time() + + # Start registration + await self.send(f"NICK {self.nick}") + await self.send(f"USER {self.user} 0 * :{self.realname}") + + # Start message handler + asyncio.create_task(self.message_handler()) + + return True + + except Exception as e: + logging.error(f"Client {self.client_id} connection failed: {e}") + return False + + async def send(self, message: str): + """Send message to server""" + if self.writer and not self.writer.is_closing(): + try: + self.writer.write(f"{message}\r\n".encode()) + await self.writer.drain() + logging.debug(f"Client {self.client_id} sent: {message}") + except Exception as e: + logging.error(f"Client {self.client_id} send error: {e}") + + async def message_handler(self): + """Handle incoming messages""" + try: + while self.connected and self.reader: + line = await self.reader.readline() + if not line: + break + + message = line.decode().strip() + if not message: + continue + + logging.debug(f"Client {self.client_id} received: {message}") + await self.handle_message(message) + + except Exception as e: + logging.error(f"Client {self.client_id} message handler error: {e}") + finally: + self.connected = False + + async def handle_message(self, message: str): + """Process incoming IRC messages""" + parts = message.split() + if len(parts) < 2: + return + + # Handle PING + if parts[0] == "PING": + await self.send(f"PONG {parts[1]}") + return + + # Handle numeric responses + if len(parts) >= 2 and parts[1].isdigit(): + numeric = int(parts[1]) + + # Welcome message - registration complete + if numeric == 1: # RPL_WELCOME + self.registered = True + logging.info(f"Client {self.client_id} ({self.nick}) registered successfully") + + # Auto-join channels if configured + for channel in self.config.get('auto_join_channels', []): + await self.join_channel(channel) + + async def join_channel(self, channel: str): + """Join a channel""" + await self.send(f"JOIN {channel}") + if channel not in self.channels: + self.channels.append(channel) + + async def send_privmsg(self, target: str, message: str): + """Send private message or channel message""" + await self.send(f"PRIVMSG {target} :{message}") + self.message_count += 1 + + async def disconnect(self): + """Disconnect from server""" + self.connected = False + if self.writer and not self.writer.is_closing(): + await self.send("QUIT :Stress test complete") + self.writer.close() + await self.writer.wait_closed() + +class StressTest: + def __init__(self, config_file: str): + with open(config_file, 'r') as f: + self.config = json.load(f) + + self.clients: List[IRCClient] = [] + self.start_time = None + self.stats = { + 'connected': 0, + 'registered': 0, + 'messages_sent': 0, + 'errors': 0 + } + + # Setup logging + log_level = getattr(logging, self.config.get('log_level', 'INFO').upper()) + logging.basicConfig( + level=log_level, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('stress_test.log'), + logging.StreamHandler() + ] + ) + + async def run_scenario(self, scenario: Dict[str, Any]): + """Run a specific test scenario""" + scenario_name = scenario['name'] + client_count = scenario['client_count'] + duration = scenario.get('duration', 60) + + logging.info(f"Starting scenario: {scenario_name}") + logging.info(f"Clients: {client_count}, Duration: {duration}s") + + # Create and connect clients + if scenario.get('connect_gradually', False): + await self.gradual_connect(client_count, scenario.get('connect_delay', 0.1)) + else: + await self.mass_connect(client_count) + + # Run scenario activities + await self.run_activities(scenario, duration) + + # Collect stats + await self.collect_stats() + + # Disconnect clients + if scenario.get('disconnect_gradually', False): + await self.gradual_disconnect(scenario.get('disconnect_delay', 0.1)) + else: + await self.mass_disconnect() + + logging.info(f"Scenario {scenario_name} completed") + + async def mass_connect(self, count: int): + """Connect many clients simultaneously""" + logging.info(f"Mass connecting {count} clients...") + + tasks = [] + for i in range(count): + client = IRCClient(self.config['server'], i + 1) + self.clients.append(client) + tasks.append(client.connect()) + + results = await asyncio.gather(*tasks, return_exceptions=True) + + connected = sum(1 for r in results if r is True) + self.stats['connected'] = connected + + logging.info(f"Connected {connected}/{count} clients") + + # Wait for registration + await asyncio.sleep(2) + + registered = sum(1 for c in self.clients if c.registered) + self.stats['registered'] = registered + logging.info(f"Registered {registered}/{connected} clients") + + async def gradual_connect(self, count: int, delay: float): + """Connect clients gradually with delay""" + logging.info(f"Gradually connecting {count} clients with {delay}s delay...") + + for i in range(count): + client = IRCClient(self.config['server'], i + 1) + self.clients.append(client) + + success = await client.connect() + if success: + self.stats['connected'] += 1 + + if delay > 0: + await asyncio.sleep(delay) + + # Wait for registration + await asyncio.sleep(2) + + registered = sum(1 for c in self.clients if c.registered) + self.stats['registered'] = registered + logging.info(f"Registered {registered}/{self.stats['connected']} clients") + + async def run_activities(self, scenario: Dict[str, Any], duration: int): + """Run scenario activities for specified duration""" + activities = scenario.get('activities', []) + if not activities: + logging.info(f"No activities specified, waiting {duration} seconds...") + await asyncio.sleep(duration) + return + + end_time = time.time() + duration + + while time.time() < end_time: + for activity in activities: + if time.time() >= end_time: + break + + await self.run_activity(activity) + + # Delay between activities + delay = activity.get('delay', 1.0) + await asyncio.sleep(delay) + + async def run_activity(self, activity: Dict[str, Any]): + """Run a single activity""" + activity_type = activity['type'] + + if activity_type == 'channel_flood': + await self.channel_flood_activity(activity) + elif activity_type == 'private_flood': + await self.private_flood_activity(activity) + elif activity_type == 'join_part_spam': + await self.join_part_spam_activity(activity) + elif activity_type == 'nick_change_spam': + await self.nick_change_spam_activity(activity) + elif activity_type == 'random_commands': + await self.random_commands_activity(activity) + else: + logging.warning(f"Unknown activity type: {activity_type}") + + async def channel_flood_activity(self, activity: Dict[str, Any]): + """Flood channels with messages""" + message_count = activity.get('message_count', 10) + channel = activity.get('channel', '#test') + + registered_clients = [c for c in self.clients if c.registered] + if not registered_clients: + return + + tasks = [] + for _ in range(message_count): + client = random.choice(registered_clients) + message = self.generate_random_message() + tasks.append(client.send_privmsg(channel, message)) + + await asyncio.gather(*tasks, return_exceptions=True) + self.stats['messages_sent'] += len(tasks) + + async def private_flood_activity(self, activity: Dict[str, Any]): + """Flood with private messages""" + message_count = activity.get('message_count', 10) + + registered_clients = [c for c in self.clients if c.registered] + if len(registered_clients) < 2: + return + + tasks = [] + for _ in range(message_count): + sender = random.choice(registered_clients) + target = random.choice(registered_clients) + if sender != target: + message = self.generate_random_message() + tasks.append(sender.send_privmsg(target.nick, message)) + + await asyncio.gather(*tasks, return_exceptions=True) + self.stats['messages_sent'] += len(tasks) + + async def join_part_spam_activity(self, activity: Dict[str, Any]): + """Spam JOIN/PART commands""" + iterations = activity.get('iterations', 5) + channels = activity.get('channels', ['#spam1', '#spam2', '#spam3']) + + registered_clients = [c for c in self.clients if c.registered] + if not registered_clients: + return + + for _ in range(iterations): + client = random.choice(registered_clients) + channel = random.choice(channels) + + await client.join_channel(channel) + await asyncio.sleep(0.1) + await client.send(f"PART {channel} :Spam test") + + async def nick_change_spam_activity(self, activity: Dict[str, Any]): + """Spam NICK changes""" + iterations = activity.get('iterations', 5) + + registered_clients = [c for c in self.clients if c.registered] + if not registered_clients: + return + + for _ in range(iterations): + client = random.choice(registered_clients) + new_nick = f"{client.nick}_{''.join(random.choices(string.ascii_lowercase, k=3))}" + await client.send(f"NICK {new_nick}") + await asyncio.sleep(0.1) + + async def random_commands_activity(self, activity: Dict[str, Any]): + """Send random IRC commands""" + command_count = activity.get('command_count', 10) + commands = activity.get('commands', ['WHO #test', 'WHOIS randomnick', 'LIST', 'VERSION']) + + registered_clients = [c for c in self.clients if c.registered] + if not registered_clients: + return + + for _ in range(command_count): + client = random.choice(registered_clients) + command = random.choice(commands) + await client.send(command) + await asyncio.sleep(0.1) + + def generate_random_message(self) -> str: + """Generate random message content""" + words = ['hello', 'world', 'test', 'stress', 'irc', 'server', 'message', 'random', 'data'] + return ' '.join(random.choices(words, k=random.randint(1, 8))) + + async def collect_stats(self): + """Collect and display statistics""" + connected = sum(1 for c in self.clients if c.connected) + registered = sum(1 for c in self.clients if c.registered) + total_messages = sum(c.message_count for c in self.clients) + + self.stats.update({ + 'connected': connected, + 'registered': registered, + 'messages_sent': total_messages + }) + + logging.info(f"Stats: {connected} connected, {registered} registered, {total_messages} messages sent") + + async def mass_disconnect(self): + """Disconnect all clients simultaneously""" + logging.info("Disconnecting all clients...") + + tasks = [client.disconnect() for client in self.clients] + await asyncio.gather(*tasks, return_exceptions=True) + + self.clients.clear() + + async def gradual_disconnect(self, delay: float): + """Disconnect clients gradually""" + logging.info(f"Gradually disconnecting clients with {delay}s delay...") + + for client in self.clients: + await client.disconnect() + if delay > 0: + await asyncio.sleep(delay) + + self.clients.clear() + + async def run_all_scenarios(self): + """Run all configured scenarios""" + self.start_time = time.time() + + for scenario in self.config['scenarios']: + try: + await self.run_scenario(scenario) + + # Delay between scenarios + scenario_delay = scenario.get('delay_after', 2) + if scenario_delay > 0: + logging.info(f"Waiting {scenario_delay}s before next scenario...") + await asyncio.sleep(scenario_delay) + + except Exception as e: + logging.error(f"Scenario {scenario['name']} failed: {e}") + self.stats['errors'] += 1 + + total_time = time.time() - self.start_time + logging.info(f"All scenarios completed in {total_time:.2f} seconds") + logging.info(f"Final stats: {self.stats}") + +def main(): + parser = argparse.ArgumentParser(description='TechIRCd Stress Testing Tool') + parser.add_argument('--config', '-c', default='stress_config.json', + help='Configuration file (default: stress_config.json)') + parser.add_argument('--scenario', '-s', help='Run specific scenario only') + + args = parser.parse_args() + + try: + stress_test = StressTest(args.config) + + if args.scenario: + # Run specific scenario + scenario = next((s for s in stress_test.config['scenarios'] if s['name'] == args.scenario), None) + if scenario: + asyncio.run(stress_test.run_scenario(scenario)) + else: + print(f"Scenario '{args.scenario}' not found") + return 1 + else: + # Run all scenarios + asyncio.run(stress_test.run_all_scenarios()) + + return 0 + + except FileNotFoundError: + print(f"Configuration file '{args.config}' not found") + return 1 + except Exception as e: + print(f"Error: {e}") + return 1 + +if __name__ == "__main__": + exit(main()) diff --git a/test_ban_enforcement.py b/test_ban_enforcement.py new file mode 100644 index 0000000..707a562 --- /dev/null +++ b/test_ban_enforcement.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 + +import socket +import time +import threading + +def connect_client(nickname): + """Connect a client and return the socket""" + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect(('localhost', 6667)) + + # Send initial IRC commands + sock.send(f"NICK {nickname}\r\n".encode()) + sock.send(f"USER {nickname} 0 * :Test User\r\n".encode()) + + # Wait for welcome messages + time.sleep(0.5) + + return sock + except Exception as e: + print(f"Error connecting {nickname}: {e}") + return None + +def read_responses(sock, name, duration=2): + """Read responses from server for a duration""" + try: + sock.settimeout(0.1) + responses = [] + start_time = time.time() + + while time.time() - start_time < duration: + try: + data = sock.recv(4096).decode() + if data: + responses.append(data.strip()) + print(f"[{name}] {data.strip()}") + except socket.timeout: + continue + except: + break + + return responses + except Exception as e: + print(f"Error reading from {name}: {e}") + return [] + +def test_gline_enforcement(): + print("\n=== TESTING GLINE ENFORCEMENT ===") + + # Connect admin client + admin = connect_client("AdminUser") + if not admin: + print("Failed to connect admin") + return + + read_responses(admin, "Admin", 1) + + # Give admin operator privileges (OPER) + admin.send("OPER admin adminpass\r\n".encode()) + read_responses(admin, "Admin", 1) + + # Connect a test user first + print("\n1. Connecting test user before GLINE...") + test_user = connect_client("TestUser") + if test_user: + read_responses(test_user, "TestUser", 1) + print(" ✓ TestUser connected successfully") + + # Issue GLINE command + print("\n2. Issuing GLINE for TestUser...") + admin.send("GLINE TestUser 1h Testing GLINE enforcement\r\n".encode()) + read_responses(admin, "Admin", 1) + + # Try to send a message as the existing user + print("\n3. TestUser trying to send message after GLINE...") + if test_user: + test_user.send("PRIVMSG #test :This should work, GLINE only affects new connections\r\n".encode()) + read_responses(test_user, "TestUser", 1) + + # Try to connect a new user with same nick pattern + print("\n4. Trying to connect new TestUser after GLINE...") + new_test_user = connect_client("TestUser2") + if new_test_user: + read_responses(new_test_user, "TestUser2", 2) + new_test_user.close() + + # Cleanup + if test_user: + test_user.close() + admin.close() + +def test_shun_enforcement(): + print("\n\n=== TESTING SHUN ENFORCEMENT ===") + + # Connect admin client + admin = connect_client("AdminUser") + if not admin: + print("Failed to connect admin") + return + + read_responses(admin, "Admin", 1) + + # Give admin operator privileges + admin.send("OPER admin adminpass\r\n".encode()) + read_responses(admin, "Admin", 1) + + # Connect test user + print("\n1. Connecting test user...") + test_user = connect_client("ShunUser") + if not test_user: + print("Failed to connect test user") + admin.close() + return + + read_responses(test_user, "ShunUser", 1) + + # Test user sends a message before SHUN + print("\n2. ShunUser sending message before SHUN...") + test_user.send("PRIVMSG #test :This message should work\r\n".encode()) + read_responses(test_user, "ShunUser", 1) + + # Issue SHUN command + print("\n3. Issuing SHUN for ShunUser...") + admin.send("SHUN ShunUser 1h Testing SHUN enforcement\r\n".encode()) + read_responses(admin, "Admin", 1) + + # Test user tries to send message after SHUN + print("\n4. ShunUser trying to send message after SHUN (should be ignored)...") + test_user.send("PRIVMSG #test :This message should be silently ignored\r\n".encode()) + read_responses(test_user, "ShunUser", 2) + + # Test user tries other commands + print("\n5. ShunUser trying other commands after SHUN...") + test_user.send("JOIN #testchan\r\n".encode()) + read_responses(test_user, "ShunUser", 1) + + test_user.send("NOTICE #test :This notice should also be ignored\r\n".encode()) + read_responses(test_user, "ShunUser", 1) + + # Cleanup + test_user.close() + admin.close() + +if __name__ == "__main__": + print("Testing TechIRCd Ban Enforcement") + print("Make sure TechIRCd server is running on localhost:6667") + + time.sleep(1) + + test_gline_enforcement() + test_shun_enforcement() + + print("\n=== TEST COMPLETE ===") diff --git a/test_cap_list.sh b/test_cap_list.sh new file mode 100644 index 0000000..b3d151a --- /dev/null +++ b/test_cap_list.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# Test script to verify CAP LIST functionality +echo "Testing CAP LIST functionality..." + +# Connect to server and test CAP negotiation + LIST +{ + echo "CAP LS" + sleep 0.1 + echo "CAP REQ :away-notify chghost multi-prefix" + sleep 0.1 + echo "CAP LIST" + sleep 0.1 + echo "CAP END" + sleep 0.1 + echo "NICK TestUser" + sleep 0.1 + echo "USER test 0 * :Test User" + sleep 0.1 + echo "CAP LIST" + sleep 0.1 + echo "QUIT :Testing complete" +} | telnet localhost 6667 diff --git a/test_shutdown.sh b/test_shutdown.sh new file mode 100644 index 0000000..2730fe9 --- /dev/null +++ b/test_shutdown.sh @@ -0,0 +1,56 @@ +#!/bin/bash + +# TechIRCd Shutdown Test Script +echo "=== TechIRCd Shutdown Test ===" +echo + +# Start the server in background +echo "Starting TechIRCd..." +./techircd start & +SERVER_PID=$! + +echo "Server started with PID: $SERVER_PID" +echo "Waiting 5 seconds for server to initialize..." +sleep 5 + +# Test connection +echo "Testing initial connection..." +timeout 5s bash -c ' + exec 3<>/dev/tcp/localhost/6667 + echo "NICK TestUser" >&3 + echo "USER testuser 0 * :Test User" >&3 + sleep 2 + echo "PING :test" >&3 + read -t 3 response <&3 + echo "Response: $response" + exec 3<&- + exec 3>&- +' && echo "✅ Connection test passed" || echo "❌ Connection test failed" + +echo +echo "Testing graceful shutdown (SIGTERM)..." +echo "Sending SIGTERM to server..." + +# Send SIGTERM and monitor for graceful shutdown +kill -TERM $SERVER_PID + +# Wait up to 15 seconds for graceful shutdown +TIMEOUT=15 +for i in $(seq 1 $TIMEOUT); do + if ! kill -0 $SERVER_PID 2>/dev/null; then + echo "✅ Server shut down gracefully after $i seconds" + echo + echo "=== Shutdown Test Complete ===" + exit 0 + fi + sleep 1 + echo -n "." +done + +echo +echo "⚠️ Server did not shut down gracefully within $TIMEOUT seconds" +echo "Forcing shutdown..." +kill -9 $SERVER_PID 2>/dev/null + +echo +echo "=== Shutdown Test Complete ===" diff --git a/test_stress.sh b/test_stress.sh new file mode 100644 index 0000000..452000a --- /dev/null +++ b/test_stress.sh @@ -0,0 +1,138 @@ +#!/bin/bash + +# TechIRCd Stability Stress Test +echo "=== TechIRCd Stability Stress Test ===" +echo "This test simulates conditions that previously caused freezing" +echo + +# Start the server +echo "Starting TechIRCd..." +./techircd start & +SERVER_PID=$! + +echo "Server started with PID: $SERVER_PID" +echo "Waiting 3 seconds for server to initialize..." +sleep 3 + +# Function to create a client connection +create_client() { + local client_id=$1 + local duration=$2 + + timeout ${duration}s bash -c " + exec 3<>/dev/tcp/localhost/6667 + echo 'NICK TestUser$client_id' >&3 + echo 'USER testuser$client_id 0 * :Test User $client_id' >&3 + sleep 2 + echo 'JOIN #test' >&3 + + # Send periodic messages to keep connection alive + for i in {1..20}; do + echo 'PING :client$client_id' >&3 + sleep 1 + echo 'PRIVMSG #test :Message \$i from client $client_id' >&3 + sleep 1 + done + + echo 'QUIT :Test complete' >&3 + exec 3<&- + exec 3>&- + " 2>/dev/null & +} + +# Function to create unstable client (connects and disconnects quickly) +create_unstable_client() { + local client_id=$1 + + timeout 5s bash -c " + exec 3<>/dev/tcp/localhost/6667 + echo 'NICK Unstable$client_id' >&3 + echo 'USER unstable$client_id 0 * :Unstable User $client_id' >&3 + sleep 1 + # Abruptly close connection without QUIT + exec 3<&- + exec 3>&- + " 2>/dev/null & +} + +echo "Phase 1: Testing multiple stable connections..." +# Create 10 stable clients +for i in {1..10}; do + create_client $i 30 + echo -n "." + sleep 0.5 +done +echo " (10 stable clients created)" + +echo "Phase 2: Testing unstable connections (simulating network issues)..." +# Create 20 unstable clients that disconnect abruptly +for i in {1..20}; do + create_unstable_client $i + echo -n "." + sleep 0.2 +done +echo " (20 unstable clients created)" + +echo "Phase 3: Testing rapid connection attempts..." +# Create many short-lived connections +for i in {1..30}; do + timeout 2s bash -c " + exec 3<>/dev/tcp/localhost/6667 + echo 'NICK Rapid$i' >&3 + exec 3<&- + exec 3>&- + " 2>/dev/null & + sleep 0.1 +done +echo "30 rapid connections created" + +echo +echo "Monitoring server health for 45 seconds..." +echo "Press Ctrl+C to interrupt test early" + +# Monitor server for 45 seconds +for i in {1..45}; do + if ! kill -0 $SERVER_PID 2>/dev/null; then + echo "❌ Server died during stress test!" + echo "=== Stress Test Failed ===" + exit 1 + fi + + # Test server responsiveness every 10 seconds + if [ $((i % 10)) -eq 0 ]; then + echo "Testing server responsiveness at ${i}s..." + timeout 3s bash -c ' + exec 3<>/dev/tcp/localhost/6667 + echo "NICK HealthCheck$RANDOM" >&3 + echo "USER healthcheck 0 * :Health Check" >&3 + sleep 1 + echo "QUIT :Health check complete" >&3 + exec 3<&- + exec 3>&- + ' 2>/dev/null && echo "✅ Server responsive" || echo "⚠️ Server response slow/failed" + fi + + echo -n "." + sleep 1 +done + +echo +echo "✅ Server survived 45-second stress test!" + +echo "Testing graceful shutdown after stress..." +kill -TERM $SERVER_PID + +# Wait for shutdown +TIMEOUT=15 +for i in $(seq 1 $TIMEOUT); do + if ! kill -0 $SERVER_PID 2>/dev/null; then + echo "✅ Server shut down gracefully after stress test" + echo "=== Stress Test Passed ===" + exit 0 + fi + sleep 1 +done + +echo "⚠️ Server did not shut down gracefully, forcing..." +kill -9 $SERVER_PID 2>/dev/null +echo "=== Stress Test Completed with Warning ===" diff --git a/tools/build.go b/tools/build.go new file mode 100644 index 0000000..90a74f9 --- /dev/null +++ b/tools/build.go @@ -0,0 +1,272 @@ +package main + +import ( + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const ( + binaryName = "techircd" + version = "1.0.0" +) + +func main() { + var ( + buildFlag = flag.Bool("build", false, "Build the binary") + runFlag = flag.Bool("run", false, "Build and run the server") + testFlag = flag.Bool("test", false, "Run all tests") + cleanFlag = flag.Bool("clean", false, "Clean build artifacts") + fmtFlag = flag.Bool("fmt", false, "Format Go code") + lintFlag = flag.Bool("lint", false, "Run linters") + buildAllFlag = flag.Bool("build-all", false, "Build for multiple platforms") + releaseFlag = flag.Bool("release", false, "Create optimized release build") + helpFlag = flag.Bool("help", false, "Show help message") + ) + flag.Parse() + + if *helpFlag || flag.NFlag() == 0 { + showHelp() + return + } + + switch { + case *buildFlag: + build() + case *runFlag: + build() + run() + case *testFlag: + test() + case *cleanFlag: + clean() + case *fmtFlag: + format() + case *lintFlag: + lint() + case *buildAllFlag: + buildAll() + case *releaseFlag: + release() + } +} + +func showHelp() { + fmt.Println("TechIRCd Build Tool") + fmt.Println("") + fmt.Println("Usage:") + fmt.Println(" go run build.go [options]") + fmt.Println("") + fmt.Println("Options:") + fmt.Println(" -build Build the binary") + fmt.Println(" -run Build and run the server") + fmt.Println(" -test Run all tests") + fmt.Println(" -clean Clean build artifacts") + fmt.Println(" -fmt Format Go code") + fmt.Println(" -lint Run linters") + fmt.Println(" -build-all Build for multiple platforms") + fmt.Println(" -release Create optimized release build") + fmt.Println(" -help Show this help message") +} + +func build() { + fmt.Println("Building TechIRCd...") + + gitVersion, err := exec.Command("git", "describe", "--tags", "--always", "--dirty").Output() + var versionStr string + if err != nil { + versionStr = version + } else { + versionStr = strings.TrimSpace(string(gitVersion)) + } + + ldflags := fmt.Sprintf("-ldflags=-X main.version=%s", versionStr) + + cmd := exec.Command("go", "build", ldflags, "-o", binaryName, ".") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + fmt.Printf("Build failed: %v\n", err) + os.Exit(1) + } + + fmt.Println("Build completed successfully!") +} + +func run() { + fmt.Println("Starting TechIRCd...") + + cmd := exec.Command("./" + binaryName) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + if err := cmd.Run(); err != nil { + fmt.Printf("Run failed: %v\n", err) + os.Exit(1) + } +} + +func test() { + fmt.Println("Running tests...") + + cmd := exec.Command("go", "test", "-v", "-race", "./...") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + fmt.Printf("Tests failed: %v\n", err) + os.Exit(1) + } + + fmt.Println("All tests passed!") +} + +func clean() { + fmt.Println("Cleaning build artifacts...") + + // Remove binary files + patterns := []string{ + binaryName + "*", + "coverage.out", + "coverage.html", + } + + for _, pattern := range patterns { + matches, err := filepath.Glob(pattern) + if err != nil { + continue + } + + for _, match := range matches { + if err := os.Remove(match); err != nil { + fmt.Printf("Failed to remove %s: %v\n", match, err) + } else { + fmt.Printf("Removed %s\n", match) + } + } + } + + fmt.Println("Clean completed!") +} + +func format() { + fmt.Println("Formatting Go code...") + + cmd := exec.Command("go", "fmt", "./...") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + fmt.Printf("Format failed: %v\n", err) + os.Exit(1) + } + + // Try to run goimports if available + if _, err := exec.LookPath("goimports"); err == nil { + fmt.Println("Running goimports...") + cmd := exec.Command("goimports", "-w", "-local", "github.com/ComputerTech312/TechIRCd", ".") + cmd.Run() // Don't fail if this doesn't work + } + + fmt.Println("Format completed!") +} + +func lint() { + fmt.Println("Running linters...") + + if _, err := exec.LookPath("golangci-lint"); err != nil { + fmt.Println("golangci-lint not found, skipping...") + return + } + + cmd := exec.Command("golangci-lint", "run") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + fmt.Printf("Linting found issues: %v\n", err) + // Don't exit on lint errors, just report them + } else { + fmt.Println("No linting issues found!") + } +} + +func buildAll() { + fmt.Println("Building for multiple platforms...") + + platforms := []struct { + goos string + goarch string + ext string + }{ + {"linux", "amd64", ""}, + {"windows", "amd64", ".exe"}, + {"darwin", "amd64", ""}, + {"darwin", "arm64", ""}, + } + + gitVersion, err := exec.Command("git", "describe", "--tags", "--always", "--dirty").Output() + var versionStr string + if err != nil { + versionStr = version + } else { + versionStr = strings.TrimSpace(string(gitVersion)) + } + + for _, platform := range platforms { + outputName := fmt.Sprintf("%s-%s-%s%s", binaryName, platform.goos, platform.goarch, platform.ext) + fmt.Printf("Building %s...\n", outputName) + + ldflags := fmt.Sprintf("-ldflags=-X main.version=%s", versionStr) + + cmd := exec.Command("go", "build", ldflags, "-o", outputName, ".") + cmd.Env = append(os.Environ(), + "GOOS="+platform.goos, + "GOARCH="+platform.goarch, + ) + + if err := cmd.Run(); err != nil { + fmt.Printf("Failed to build %s: %v\n", outputName, err) + } else { + fmt.Printf("Built %s successfully!\n", outputName) + } + } + + fmt.Println("Cross-platform build completed!") +} + +func release() { + fmt.Println("Creating optimized release build...") + + gitVersion, err := exec.Command("git", "describe", "--tags", "--always", "--dirty").Output() + var versionStr string + if err != nil { + versionStr = version + } else { + versionStr = strings.TrimSpace(string(gitVersion)) + } + + ldflags := fmt.Sprintf("-ldflags=-X main.version=%s", versionStr) + + cmd := exec.Command("go", "build", ldflags, "-a", "-installsuffix", "cgo", "-o", binaryName, ".") + cmd.Env = append(os.Environ(), "CGO_ENABLED=0") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + fmt.Printf("Release build failed: %v\n", err) + os.Exit(1) + } + + // Get file info to show size + if info, err := os.Stat(binaryName); err == nil { + fmt.Printf("Release build completed! Binary size: %.2f MB\n", float64(info.Size())/1024/1024) + } else { + fmt.Println("Release build completed!") + } +} diff --git a/tools/go_stress_test.go b/tools/go_stress_test.go new file mode 100644 index 0000000..4da5222 --- /dev/null +++ b/tools/go_stress_test.go @@ -0,0 +1,525 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "log" + "math/rand" + "net" + "os" + "sync" + "time" +) + +// StressConfig defines the configuration for stress testing +type StressConfig struct { + Server struct { + Host string `json:"host"` + Port int `json:"port"` + } `json:"server"` + + Test struct { + MaxClients int `json:"max_clients"` + ConnectDelay int `json:"connect_delay_ms"` + ActionInterval int `json:"action_interval_ms"` + TestDuration int `json:"test_duration_seconds"` + RandomSeed int `json:"random_seed"` + } `json:"test"` + + Behavior struct { + JoinChannels bool `json:"join_channels"` + SendMessages bool `json:"send_messages"` + ChangeNicks bool `json:"change_nicks"` + UseNewCommands bool `json:"use_new_commands"` + RandomQuit bool `json:"random_quit"` + + MessageRate float64 `json:"message_rate"` + ChannelJoinRate float64 `json:"channel_join_rate"` + CommandRate float64 `json:"command_rate"` + } `json:"behavior"` + + Channels []string `json:"channels"` + Messages []string `json:"messages"` + Commands []string `json:"commands"` +} + +// IRCClient represents a single IRC client connection +type IRCClient struct { + ID int + Nick string + Conn net.Conn + Reader *bufio.Reader + Writer *bufio.Writer + Channels []string + Active bool + mu sync.Mutex +} + +// StressTest manages the entire stress testing operation +type StressTest struct { + Config *StressConfig + Clients []*IRCClient + Stats *TestStats + quit chan bool +} + +// TestStats tracks testing statistics +type TestStats struct { + ConnectedClients int + MessagesSent int + CommandsSent int + ChannelsJoined int + Errors int + StartTime time.Time + mu sync.Mutex +} + +// LoadConfig loads configuration from JSON file +func LoadConfig(filename string) (*StressConfig, error) { + file, err := os.Open(filename) + if err != nil { + return nil, fmt.Errorf("error opening config file: %v", err) + } + defer file.Close() + + config := &StressConfig{} + decoder := json.NewDecoder(file) + if err := decoder.Decode(config); err != nil { + return nil, fmt.Errorf("error parsing config: %v", err) + } + + return config, nil +} + +// CreateDefaultConfig creates a default configuration file +func CreateDefaultConfig(filename string) error { + config := &StressConfig{ + Server: struct { + Host string `json:"host"` + Port int `json:"port"` + }{ + Host: "localhost", + Port: 6667, + }, + Test: struct { + MaxClients int `json:"max_clients"` + ConnectDelay int `json:"connect_delay_ms"` + ActionInterval int `json:"action_interval_ms"` + TestDuration int `json:"test_duration_seconds"` + RandomSeed int `json:"random_seed"` + }{ + MaxClients: 500, + ConnectDelay: 50, + ActionInterval: 1000, + TestDuration: 300, + RandomSeed: 42, + }, + Behavior: struct { + JoinChannels bool `json:"join_channels"` + SendMessages bool `json:"send_messages"` + ChangeNicks bool `json:"change_nicks"` + UseNewCommands bool `json:"use_new_commands"` + RandomQuit bool `json:"random_quit"` + MessageRate float64 `json:"message_rate"` + ChannelJoinRate float64 `json:"channel_join_rate"` + CommandRate float64 `json:"command_rate"` + }{ + JoinChannels: true, + SendMessages: true, + ChangeNicks: true, + UseNewCommands: true, + RandomQuit: false, + MessageRate: 0.3, + ChannelJoinRate: 0.2, + CommandRate: 0.1, + }, + Channels: []string{ + "#test", "#stress", "#general", "#random", "#chaos", + "#lobby", "#gaming", "#tech", "#chat", "#help", + }, + Messages: []string{ + "Hello everyone!", + "This is a stress test message", + "How is everyone doing?", + "Testing the server stability", + "Random message from client", + "IRC is awesome!", + "TechIRCd rocks!", + "Can you see this message?", + "Stress testing in progress", + "Everything working fine here", + }, + Commands: []string{ + "MOTD", "RULES", "MAP", "TIME", "VERSION", "LUSERS", + "WHO #test", "WHOIS testuser", "LIST", + }, + } + + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("error creating config file: %v", err) + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + return encoder.Encode(config) +} + +// NewStressTest creates a new stress test instance +func NewStressTest(config *StressConfig) *StressTest { + return &StressTest{ + Config: config, + Clients: make([]*IRCClient, 0, config.Test.MaxClients), + Stats: &TestStats{ + StartTime: time.Now(), + }, + quit: make(chan bool), + } +} + +// Connect establishes connection to IRC server +func (c *IRCClient) Connect(host string, port int) error { + conn, err := net.Dial("tcp", fmt.Sprintf("%s:%d", host, port)) + if err != nil { + return fmt.Errorf("failed to connect: %v", err) + } + + c.Conn = conn + c.Reader = bufio.NewReader(conn) + c.Writer = bufio.NewWriter(conn) + c.Active = true + + return nil +} + +// Register performs IRC client registration +func (c *IRCClient) Register() error { + commands := []string{ + fmt.Sprintf("NICK %s", c.Nick), + fmt.Sprintf("USER %s 0 * :Stress Test Client %d", c.Nick, c.ID), + } + + for _, cmd := range commands { + if err := c.SendCommand(cmd); err != nil { + return fmt.Errorf("registration failed: %v", err) + } + } + + return nil +} + +// SendCommand sends a command to the IRC server +func (c *IRCClient) SendCommand(command string) error { + c.mu.Lock() + defer c.mu.Unlock() + + if !c.Active || c.Writer == nil { + return fmt.Errorf("client not active") + } + + _, err := c.Writer.WriteString(command + "\r\n") + if err != nil { + return err + } + + return c.Writer.Flush() +} + +// ReadMessages continuously reads messages from server +func (c *IRCClient) ReadMessages(stats *TestStats) { + defer func() { + c.Active = false + if c.Conn != nil { + c.Conn.Close() + } + }() + + for c.Active { + if c.Reader == nil { + break + } + + line, err := c.Reader.ReadString('\n') + if err != nil { + stats.mu.Lock() + stats.Errors++ + stats.mu.Unlock() + break + } + + // Handle PING responses + if len(line) > 4 && line[:4] == "PING" { + pong := "PONG" + line[4:] + c.SendCommand(pong[:len(pong)-2]) // Remove \r\n + } + } +} + +// PerformRandomAction performs a random IRC action +func (c *IRCClient) PerformRandomAction(config *StressConfig, stats *TestStats) { + if !c.Active { + return + } + + action := rand.Float64() + + switch { + case action < config.Behavior.MessageRate && config.Behavior.SendMessages: + c.SendRandomMessage(config, stats) + case action < config.Behavior.MessageRate+config.Behavior.ChannelJoinRate && config.Behavior.JoinChannels: + c.JoinRandomChannel(config, stats) + case action < config.Behavior.MessageRate+config.Behavior.ChannelJoinRate+config.Behavior.CommandRate && config.Behavior.UseNewCommands: + c.SendRandomCommand(config, stats) + case config.Behavior.ChangeNicks && rand.Float64() < 0.05: + c.ChangeNick(stats) + } +} + +// SendRandomMessage sends a random message to a random channel +func (c *IRCClient) SendRandomMessage(config *StressConfig, stats *TestStats) { + if len(c.Channels) == 0 { + return + } + + channel := c.Channels[rand.Intn(len(c.Channels))] + message := config.Messages[rand.Intn(len(config.Messages))] + + cmd := fmt.Sprintf("PRIVMSG %s :%s", channel, message) + if err := c.SendCommand(cmd); err == nil { + stats.mu.Lock() + stats.MessagesSent++ + stats.mu.Unlock() + } else { + stats.mu.Lock() + stats.Errors++ + stats.mu.Unlock() + } +} + +// JoinRandomChannel joins a random channel +func (c *IRCClient) JoinRandomChannel(config *StressConfig, stats *TestStats) { + channel := config.Channels[rand.Intn(len(config.Channels))] + + // Check if already in channel + for _, ch := range c.Channels { + if ch == channel { + return + } + } + + cmd := fmt.Sprintf("JOIN %s", channel) + if err := c.SendCommand(cmd); err == nil { + c.Channels = append(c.Channels, channel) + stats.mu.Lock() + stats.ChannelsJoined++ + stats.mu.Unlock() + } else { + stats.mu.Lock() + stats.Errors++ + stats.mu.Unlock() + } +} + +// SendRandomCommand sends a random IRC command +func (c *IRCClient) SendRandomCommand(config *StressConfig, stats *TestStats) { + command := config.Commands[rand.Intn(len(config.Commands))] + + if err := c.SendCommand(command); err == nil { + stats.mu.Lock() + stats.CommandsSent++ + stats.mu.Unlock() + } else { + stats.mu.Lock() + stats.Errors++ + stats.mu.Unlock() + } +} + +// ChangeNick changes the client's nickname +func (c *IRCClient) ChangeNick(stats *TestStats) { + newNick := fmt.Sprintf("User%d_%d", c.ID, rand.Intn(1000)) + cmd := fmt.Sprintf("NICK %s", newNick) + + if err := c.SendCommand(cmd); err == nil { + c.Nick = newNick + } else { + stats.mu.Lock() + stats.Errors++ + stats.mu.Unlock() + } +} + +// CreateClient creates and connects a new IRC client +func (st *StressTest) CreateClient(id int) error { + client := &IRCClient{ + ID: id, + Nick: fmt.Sprintf("StressUser%d", id), + Channels: make([]string, 0), + } + + // Connect to server + if err := client.Connect(st.Config.Server.Host, st.Config.Server.Port); err != nil { + return fmt.Errorf("client %d connection failed: %v", id, err) + } + + // Register with IRC server + if err := client.Register(); err != nil { + client.Conn.Close() + return fmt.Errorf("client %d registration failed: %v", id, err) + } + + st.Clients = append(st.Clients, client) + + st.Stats.mu.Lock() + st.Stats.ConnectedClients++ + st.Stats.mu.Unlock() + + // Start message reader goroutine + go client.ReadMessages(st.Stats) + + return nil +} + +// RunStressTest executes the complete stress test +func (st *StressTest) RunStressTest() error { + log.Printf("Starting stress test with %d clients", st.Config.Test.MaxClients) + + // Set random seed + rand.Seed(int64(st.Config.Test.RandomSeed)) + + // Connect clients gradually + connectDelay := time.Duration(st.Config.Test.ConnectDelay) * time.Millisecond + for i := 0; i < st.Config.Test.MaxClients; i++ { + if err := st.CreateClient(i); err != nil { + log.Printf("Failed to create client %d: %v", i, err) + st.Stats.mu.Lock() + st.Stats.Errors++ + st.Stats.mu.Unlock() + continue + } + + if i%10 == 0 { + log.Printf("Connected %d/%d clients", i+1, st.Config.Test.MaxClients) + } + + time.Sleep(connectDelay) + } + + log.Printf("All clients connected. Starting activity simulation...") + + // Start activity simulation + actionInterval := time.Duration(st.Config.Test.ActionInterval) * time.Millisecond + testDuration := time.Duration(st.Config.Test.TestDuration) * time.Second + + actionTicker := time.NewTicker(actionInterval) + defer actionTicker.Stop() + + statsTicker := time.NewTicker(10 * time.Second) + defer statsTicker.Stop() + + testTimer := time.NewTimer(testDuration) + defer testTimer.Stop() + + for { + select { + case <-actionTicker.C: + // Perform random actions for random clients + numActions := rand.Intn(len(st.Clients)/4) + 1 + for i := 0; i < numActions; i++ { + if len(st.Clients) > 0 { + clientIndex := rand.Intn(len(st.Clients)) + go st.Clients[clientIndex].PerformRandomAction(st.Config, st.Stats) + } + } + + case <-statsTicker.C: + st.PrintStats() + + case <-testTimer.C: + log.Println("Test duration completed. Shutting down...") + st.Shutdown() + return nil + + case <-st.quit: + log.Println("Test interrupted. Shutting down...") + st.Shutdown() + return nil + } + } +} + +// PrintStats prints current test statistics +func (st *StressTest) PrintStats() { + st.Stats.mu.Lock() + defer st.Stats.mu.Unlock() + + elapsed := time.Since(st.Stats.StartTime) + + log.Printf("=== STRESS TEST STATS ===") + log.Printf("Runtime: %v", elapsed.Round(time.Second)) + log.Printf("Connected Clients: %d", st.Stats.ConnectedClients) + log.Printf("Messages Sent: %d", st.Stats.MessagesSent) + log.Printf("Commands Sent: %d", st.Stats.CommandsSent) + log.Printf("Channels Joined: %d", st.Stats.ChannelsJoined) + log.Printf("Errors: %d", st.Stats.Errors) + + if elapsed.Seconds() > 0 { + log.Printf("Messages/sec: %.2f", float64(st.Stats.MessagesSent)/elapsed.Seconds()) + log.Printf("Commands/sec: %.2f", float64(st.Stats.CommandsSent)/elapsed.Seconds()) + } + log.Printf("========================") +} + +// Shutdown gracefully shuts down all clients +func (st *StressTest) Shutdown() { + log.Println("Shutting down all clients...") + + for i, client := range st.Clients { + if client.Active { + client.SendCommand("QUIT :Stress test completed") + client.Active = false + if client.Conn != nil { + client.Conn.Close() + } + } + + if i%50 == 0 { + log.Printf("Disconnected %d/%d clients", i+1, len(st.Clients)) + } + } + + st.PrintStats() + log.Println("Stress test completed!") +} + +func main() { + configFile := "stress_test_config.json" + + // Check if config file exists, create default if not + if _, err := os.Stat(configFile); os.IsNotExist(err) { + log.Printf("Config file %s not found. Creating default...", configFile) + if err := CreateDefaultConfig(configFile); err != nil { + log.Fatalf("Failed to create default config: %v", err) + } + log.Printf("Default config created at %s. Please review and modify as needed.", configFile) + log.Println("Run the command again to start the stress test.") + return + } + + // Load configuration + config, err := LoadConfig(configFile) + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + log.Printf("Loaded config: %d clients, %d second test", config.Test.MaxClients, config.Test.TestDuration) + + // Create and run stress test + stressTest := NewStressTest(config) + + if err := stressTest.RunStressTest(); err != nil { + log.Fatalf("Stress test failed: %v", err) + } +} diff --git a/tools/run_stress_test.sh b/tools/run_stress_test.sh new file mode 100644 index 0000000..4e00bf3 --- /dev/null +++ b/tools/run_stress_test.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Build and run the Go IRC Stress Tester + +echo "Building Go IRC Stress Tester..." +cd "$(dirname "$0")" + +# Build the stress tester +go build -o irc_stress_test go_stress_test.go + +if [ $? -eq 0 ]; then + echo "Build successful!" + echo "Running stress test..." + echo "========================" + ./irc_stress_test +else + echo "Build failed!" + exit 1 +fi diff --git a/tools/stress_test.go b/tools/stress_test.go new file mode 100644 index 0000000..f660039 --- /dev/null +++ b/tools/stress_test.go @@ -0,0 +1,528 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "log" + "math/rand" + "net" + "os" + "sync" + "time" +) + +// StressConfig defines the configuration for stress testing +type StressConfig struct { + Server struct { + Host string `json:"host"` + Port int `json:"port"` + } `json:"server"` + + Test struct { + MaxClients int `json:"max_clients"` + ConnectDelay int `json:"connect_delay_ms"` + ActionInterval int `json:"action_interval_ms"` + TestDuration int `json:"test_duration_seconds"` + RandomSeed int `json:"random_seed"` + } `json:"test"` + + Behavior struct { + JoinChannels bool `json:"join_channels"` + SendMessages bool `json:"send_messages"` + ChangeNicks bool `json:"change_nicks"` + UseNewCommands bool `json:"use_new_commands"` + RandomQuit bool `json:"random_quit"` + + MessageRate float64 `json:"message_rate"` + ChannelJoinRate float64 `json:"channel_join_rate"` + CommandRate float64 `json:"command_rate"` + } `json:"behavior"` + + Channels []string `json:"channels"` + + Messages []string `json:"messages"` + + Commands []string `json:"commands"` +} + +// IRCClient represents a single IRC client connection +type IRCClient struct { + ID int + Nick string + Conn net.Conn + Reader *bufio.Reader + Writer *bufio.Writer + Channels []string + Active bool + mu sync.Mutex +} + +// StressTest manages the entire stress testing operation +type StressTest struct { + Config *StressConfig + Clients []*IRCClient + Stats *TestStats + wg sync.WaitGroup + quit chan bool +} + +// TestStats tracks testing statistics +type TestStats struct { + ConnectedClients int + MessagesSent int + CommandsSent int + ChannelsJoined int + Errors int + StartTime time.Time + mu sync.Mutex +} + +// LoadConfig loads configuration from JSON file +func LoadConfig(filename string) (*StressConfig, error) { + file, err := os.Open(filename) + if err != nil { + return nil, fmt.Errorf("error opening config file: %v", err) + } + defer file.Close() + + config := &StressConfig{} + decoder := json.NewDecoder(file) + if err := decoder.Decode(config); err != nil { + return nil, fmt.Errorf("error parsing config: %v", err) + } + + return config, nil +} + +// CreateDefaultConfig creates a default configuration file +func CreateDefaultConfig(filename string) error { + config := &StressConfig{ + Server: struct { + Host string `json:"host"` + Port int `json:"port"` + }{ + Host: "localhost", + Port: 6667, + }, + Test: struct { + MaxClients int `json:"max_clients"` + ConnectDelay int `json:"connect_delay_ms"` + ActionInterval int `json:"action_interval_ms"` + TestDuration int `json:"test_duration_seconds"` + RandomSeed int `json:"random_seed"` + }{ + MaxClients: 500, + ConnectDelay: 50, + ActionInterval: 1000, + TestDuration: 300, + RandomSeed: 42, + }, + Behavior: struct { + JoinChannels bool `json:"join_channels"` + SendMessages bool `json:"send_messages"` + ChangeNicks bool `json:"change_nicks"` + UseNewCommands bool `json:"use_new_commands"` + RandomQuit bool `json:"random_quit"` + MessageRate float64 `json:"message_rate"` + ChannelJoinRate float64 `json:"channel_join_rate"` + CommandRate float64 `json:"command_rate"` + }{ + JoinChannels: true, + SendMessages: true, + ChangeNicks: true, + UseNewCommands: true, + RandomQuit: false, + MessageRate: 0.3, + ChannelJoinRate: 0.2, + CommandRate: 0.1, + }, + Channels: []string{ + "#test", "#stress", "#general", "#random", "#chaos", + "#lobby", "#gaming", "#tech", "#chat", "#help", + }, + Messages: []string{ + "Hello everyone!", + "This is a stress test message", + "How is everyone doing?", + "Testing the server stability", + "Random message from client", + "IRC is awesome!", + "TechIRCd rocks!", + "Can you see this message?", + "Stress testing in progress", + "Everything working fine here", + }, + Commands: []string{ + "MOTD", "RULES", "MAP", "TIME", "VERSION", "LUSERS", + "WHO #test", "WHOIS testuser", "LIST", + }, + } + + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("error creating config file: %v", err) + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + return encoder.Encode(config) +} + +// NewStressTest creates a new stress test instance +func NewStressTest(config *StressConfig) *StressTest { + return &StressTest{ + Config: config, + Clients: make([]*IRCClient, 0, config.Test.MaxClients), + Stats: &TestStats{ + StartTime: time.Now(), + }, + quit: make(chan bool), + } +} + +// Connect establishes connection to IRC server +func (c *IRCClient) Connect(host string, port int) error { + conn, err := net.Dial("tcp", fmt.Sprintf("%s:%d", host, port)) + if err != nil { + return fmt.Errorf("failed to connect: %v", err) + } + + c.Conn = conn + c.Reader = bufio.NewReader(conn) + c.Writer = bufio.NewWriter(conn) + c.Active = true + + return nil +} + +// Register performs IRC client registration +func (c *IRCClient) Register() error { + commands := []string{ + fmt.Sprintf("NICK %s", c.Nick), + fmt.Sprintf("USER %s 0 * :Stress Test Client %d", c.Nick, c.ID), + } + + for _, cmd := range commands { + if err := c.SendCommand(cmd); err != nil { + return fmt.Errorf("registration failed: %v", err) + } + } + + return nil +} + +// SendCommand sends a command to the IRC server +func (c *IRCClient) SendCommand(command string) error { + c.mu.Lock() + defer c.mu.Unlock() + + if !c.Active || c.Writer == nil { + return fmt.Errorf("client not active") + } + + _, err := c.Writer.WriteString(command + "\r\n") + if err != nil { + return err + } + + return c.Writer.Flush() +} + +// ReadMessages continuously reads messages from server +func (c *IRCClient) ReadMessages(stats *TestStats) { + defer func() { + c.Active = false + if c.Conn != nil { + c.Conn.Close() + } + }() + + for c.Active { + if c.Reader == nil { + break + } + + line, err := c.Reader.ReadString('\n') + if err != nil { + stats.mu.Lock() + stats.Errors++ + stats.mu.Unlock() + break + } + + // Handle PING responses + if len(line) > 4 && line[:4] == "PING" { + pong := "PONG" + line[4:] + c.SendCommand(pong[:len(pong)-2]) // Remove \r\n + } + } +} + +// PerformRandomAction performs a random IRC action +func (c *IRCClient) PerformRandomAction(config *StressConfig, stats *TestStats) { + if !c.Active { + return + } + + action := rand.Float64() + + switch { + case action < config.Behavior.MessageRate && config.Behavior.SendMessages: + c.SendRandomMessage(config, stats) + case action < config.Behavior.MessageRate+config.Behavior.ChannelJoinRate && config.Behavior.JoinChannels: + c.JoinRandomChannel(config, stats) + case action < config.Behavior.MessageRate+config.Behavior.ChannelJoinRate+config.Behavior.CommandRate && config.Behavior.UseNewCommands: + c.SendRandomCommand(config, stats) + case config.Behavior.ChangeNicks && rand.Float64() < 0.05: + c.ChangeNick(stats) + } +} + +// SendRandomMessage sends a random message to a random channel +func (c *IRCClient) SendRandomMessage(config *StressConfig, stats *TestStats) { + if len(c.Channels) == 0 { + return + } + + channel := c.Channels[rand.Intn(len(c.Channels))] + message := config.Messages[rand.Intn(len(config.Messages))] + + cmd := fmt.Sprintf("PRIVMSG %s :%s", channel, message) + if err := c.SendCommand(cmd); err == nil { + stats.mu.Lock() + stats.MessagesSent++ + stats.mu.Unlock() + } else { + stats.mu.Lock() + stats.Errors++ + stats.mu.Unlock() + } +} + +// JoinRandomChannel joins a random channel +func (c *IRCClient) JoinRandomChannel(config *StressConfig, stats *TestStats) { + channel := config.Channels[rand.Intn(len(config.Channels))] + + // Check if already in channel + for _, ch := range c.Channels { + if ch == channel { + return + } + } + + cmd := fmt.Sprintf("JOIN %s", channel) + if err := c.SendCommand(cmd); err == nil { + c.Channels = append(c.Channels, channel) + stats.mu.Lock() + stats.ChannelsJoined++ + stats.mu.Unlock() + } else { + stats.mu.Lock() + stats.Errors++ + stats.mu.Unlock() + } +} + +// SendRandomCommand sends a random IRC command +func (c *IRCClient) SendRandomCommand(config *StressConfig, stats *TestStats) { + command := config.Commands[rand.Intn(len(config.Commands))] + + if err := c.SendCommand(command); err == nil { + stats.mu.Lock() + stats.CommandsSent++ + stats.mu.Unlock() + } else { + stats.mu.Lock() + stats.Errors++ + stats.mu.Unlock() + } +} + +// ChangeNick changes the client's nickname +func (c *IRCClient) ChangeNick(stats *TestStats) { + newNick := fmt.Sprintf("User%d_%d", c.ID, rand.Intn(1000)) + cmd := fmt.Sprintf("NICK %s", newNick) + + if err := c.SendCommand(cmd); err == nil { + c.Nick = newNick + } else { + stats.mu.Lock() + stats.Errors++ + stats.mu.Unlock() + } +} + +// CreateClient creates and connects a new IRC client +func (st *StressTest) CreateClient(id int) error { + client := &IRCClient{ + ID: id, + Nick: fmt.Sprintf("StressUser%d", id), + Channels: make([]string, 0), + } + + // Connect to server + if err := client.Connect(st.Config.Server.Host, st.Config.Server.Port); err != nil { + return fmt.Errorf("client %d connection failed: %v", id, err) + } + + // Register with IRC server + if err := client.Register(); err != nil { + client.Conn.Close() + return fmt.Errorf("client %d registration failed: %v", id, err) + } + + st.Clients = append(st.Clients, client) + + st.Stats.mu.Lock() + st.Stats.ConnectedClients++ + st.Stats.mu.Unlock() + + // Start message reader goroutine + go client.ReadMessages(st.Stats) + + return nil +} + +// RunStressTest executes the complete stress test +func (st *StressTest) RunStressTest() error { + log.Printf("Starting stress test with %d clients", st.Config.Test.MaxClients) + + // Set random seed + rand.Seed(int64(st.Config.Test.RandomSeed)) + + // Connect clients gradually + connectDelay := time.Duration(st.Config.Test.ConnectDelay) * time.Millisecond + for i := 0; i < st.Config.Test.MaxClients; i++ { + if err := st.CreateClient(i); err != nil { + log.Printf("Failed to create client %d: %v", i, err) + st.Stats.mu.Lock() + st.Stats.Errors++ + st.Stats.mu.Unlock() + continue + } + + if i%10 == 0 { + log.Printf("Connected %d/%d clients", i+1, st.Config.Test.MaxClients) + } + + time.Sleep(connectDelay) + } + + log.Printf("All clients connected. Starting activity simulation...") + + // Start activity simulation + actionInterval := time.Duration(st.Config.Test.ActionInterval) * time.Millisecond + testDuration := time.Duration(st.Config.Test.TestDuration) * time.Second + + actionTicker := time.NewTicker(actionInterval) + defer actionTicker.Stop() + + statsTicker := time.NewTicker(10 * time.Second) + defer statsTicker.Stop() + + testTimer := time.NewTimer(testDuration) + defer testTimer.Stop() + + for { + select { + case <-actionTicker.C: + // Perform random actions for random clients + numActions := rand.Intn(len(st.Clients)/4) + 1 + for i := 0; i < numActions; i++ { + if len(st.Clients) > 0 { + clientIndex := rand.Intn(len(st.Clients)) + go st.Clients[clientIndex].PerformRandomAction(st.Config, st.Stats) + } + } + + case <-statsTicker.C: + st.PrintStats() + + case <-testTimer.C: + log.Println("Test duration completed. Shutting down...") + st.Shutdown() + return nil + + case <-st.quit: + log.Println("Test interrupted. Shutting down...") + st.Shutdown() + return nil + } + } +} + +// PrintStats prints current test statistics +func (st *StressTest) PrintStats() { + st.Stats.mu.Lock() + defer st.Stats.mu.Unlock() + + elapsed := time.Since(st.Stats.StartTime) + + log.Printf("=== STRESS TEST STATS ===") + log.Printf("Runtime: %v", elapsed.Round(time.Second)) + log.Printf("Connected Clients: %d", st.Stats.ConnectedClients) + log.Printf("Messages Sent: %d", st.Stats.MessagesSent) + log.Printf("Commands Sent: %d", st.Stats.CommandsSent) + log.Printf("Channels Joined: %d", st.Stats.ChannelsJoined) + log.Printf("Errors: %d", st.Stats.Errors) + + if elapsed.Seconds() > 0 { + log.Printf("Messages/sec: %.2f", float64(st.Stats.MessagesSent)/elapsed.Seconds()) + log.Printf("Commands/sec: %.2f", float64(st.Stats.CommandsSent)/elapsed.Seconds()) + } + log.Printf("========================") +} + +// Shutdown gracefully shuts down all clients +func (st *StressTest) Shutdown() { + log.Println("Shutting down all clients...") + + for i, client := range st.Clients { + if client.Active { + client.SendCommand("QUIT :Stress test completed") + client.Active = false + if client.Conn != nil { + client.Conn.Close() + } + } + + if i%50 == 0 { + log.Printf("Disconnected %d/%d clients", i+1, len(st.Clients)) + } + } + + st.PrintStats() + log.Println("Stress test completed!") +} + +func main() { + configFile := "stress_test_config.json" + + // Check if config file exists, create default if not + if _, err := os.Stat(configFile); os.IsNotExist(err) { + log.Printf("Config file %s not found. Creating default...", configFile) + if err := CreateDefaultConfig(configFile); err != nil { + log.Fatalf("Failed to create default config: %v", err) + } + log.Printf("Default config created at %s. Please review and modify as needed.", configFile) + log.Println("Run the command again to start the stress test.") + return + } + + // Load configuration + config, err := LoadConfig(configFile) + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + log.Printf("Loaded config: %d clients, %d second test", config.Test.MaxClients, config.Test.TestDuration) + + // Create and run stress test + stressTest := NewStressTest(config) + + if err := stressTest.RunStressTest(); err != nil { + log.Fatalf("Stress test failed: %v", err) + } +} diff --git a/tools/stress_test.py b/tools/stress_test.py new file mode 100644 index 0000000..4bf3601 --- /dev/null +++ b/tools/stress_test.py @@ -0,0 +1,464 @@ +#!/usr/bin/env python3 +""" +TechIRCd Advanced Stress Tester +=============================== +Comprehensive IRC server stress testing tool with configurable behavior. +""" + +import asyncio +import random +import time +import logging +from typing import List, Dict, Optional +import json + +# ============================================================================= +# CONFIGURATION - Edit these values to customize your stress test +# ============================================================================= + +CONFIG = { + # Server connection settings + "SERVER": { + "host": "localhost", + "port": 6667, + }, + + # Test parameters + "TEST": { + "max_clients": 300, # Number of concurrent clients + "connect_delay": 0.05, # Seconds between connections + "test_duration": 300, # Test duration in seconds + "action_interval": 1.0, # Seconds between random actions + "stats_interval": 10, # Seconds between stats reports + }, + + # Client behavior probabilities (0.0 = never, 1.0 = always) + "BEHAVIOR": { + "join_channels": True, + "send_messages": True, + "use_new_commands": True, + "change_nicks": True, + "random_quit": False, + + # Action rates (probability per action cycle) + "message_rate": 0.4, # Chance to send a message + "channel_join_rate": 0.2, # Chance to join a channel + "command_rate": 0.15, # Chance to send a command + "nick_change_rate": 0.05, # Chance to change nick + "quit_rate": 0.01, # Chance to quit and reconnect + }, + + # IRC channels to use + "CHANNELS": [ + "#test", "#stress", "#general", "#random", "#chaos", + "#lobby", "#gaming", "#tech", "#chat", "#help", + "#dev", "#admin", "#support", "#lounge", "#public" + ], + + # Random messages to send + "MESSAGES": [ + "Hello everyone!", + "This is a stress test message", + "How is everyone doing today?", + "Testing server stability under load", + "Random message from stress test client", + "IRC is still the best chat protocol!", + "TechIRCd is handling this load well", + "Can you see this message?", + "Stress testing in progress...", + "Everything working fine here", + "Anyone else here for the stress test?", + "Server performance looking good!", + "Testing new IRC commands", + "This channel is quite active", + "Load testing is important for stability" + ], + + # IRC commands to test (including new ones!) + "COMMANDS": [ + "MOTD", + "RULES", + "MAP", + "TIME", + "VERSION", + "LUSERS", + "WHO #test", + "WHOIS testuser", + "LIST", + "ADMIN", + "INFO", + "KNOCK #test", + "SETNAME :New real name from stress test" + ], + + # Logging configuration + "LOGGING": { + "level": "INFO", # DEBUG, INFO, WARNING, ERROR + "show_irc_traffic": False, # Set to True to see all IRC messages + } +} + +# ============================================================================= +# STRESS TESTING CODE - Don't modify unless you know what you're doing +# ============================================================================= + +class StressTestStats: + """Tracks statistics during stress testing""" + + def __init__(self): + self.start_time = time.time() + self.connected_clients = 0 + self.messages_sent = 0 + self.commands_sent = 0 + self.channels_joined = 0 + self.nick_changes = 0 + self.errors = 0 + self.reconnections = 0 + + def runtime(self) -> float: + return time.time() - self.start_time + + def print_stats(self): + runtime = self.runtime() + print("\n" + "="*50) + print("STRESS TEST STATISTICS") + print("="*50) + print(f"Runtime: {runtime:.1f}s") + print(f"Connected Clients: {self.connected_clients}") + print(f"Messages Sent: {self.messages_sent}") + print(f"Commands Sent: {self.commands_sent}") + print(f"Channels Joined: {self.channels_joined}") + print(f"Nick Changes: {self.nick_changes}") + print(f"Reconnections: {self.reconnections}") + print(f"Errors: {self.errors}") + + if runtime > 0: + print(f"Messages/sec: {self.messages_sent/runtime:.2f}") + print(f"Commands/sec: {self.commands_sent/runtime:.2f}") + print(f"Actions/sec: {(self.messages_sent + self.commands_sent)/runtime:.2f}") + + print("="*50) + +class IRCStressClient: + """Individual IRC client for stress testing""" + + def __init__(self, client_id: int, stats: StressTestStats): + self.client_id = client_id + self.nick = f"StressUser{client_id}" + self.user = f"stress{client_id}" + self.realname = f"Stress Test Client {client_id}" + self.channels = [] + self.stats = stats + self.reader = None + self.writer = None + self.connected = False + self.registered = False + self.running = True + + async def connect(self, host: str, port: int) -> bool: + """Connect to IRC server""" + try: + self.reader, self.writer = await asyncio.open_connection(host, port) + self.connected = True + logging.debug(f"Client {self.client_id} connected to {host}:{port}") + return True + except Exception as e: + logging.error(f"Client {self.client_id} connection failed: {e}") + self.stats.errors += 1 + return False + + async def register(self) -> bool: + """Register with IRC server""" + try: + await self.send_command(f"NICK {self.nick}") + await self.send_command(f"USER {self.user} 0 * :{self.realname}") + self.registered = True + self.stats.connected_clients += 1 + logging.debug(f"Client {self.client_id} registered as {self.nick}") + return True + except Exception as e: + logging.error(f"Client {self.client_id} registration failed: {e}") + self.stats.errors += 1 + return False + + async def send_command(self, command: str): + """Send IRC command to server""" + if not self.connected or not self.writer: + return + + try: + message = f"{command}\r\n" + self.writer.write(message.encode()) + await self.writer.drain() + + if CONFIG["LOGGING"]["show_irc_traffic"]: + logging.debug(f"Client {self.client_id} >>> {command}") + + except Exception as e: + logging.error(f"Client {self.client_id} send error: {e}") + self.stats.errors += 1 + + async def read_messages(self): + """Read and handle messages from server""" + while self.running and self.connected: + try: + if not self.reader: + break + + line = await self.reader.readline() + if not line: + break + + message = line.decode().strip() + if not message: + continue + + if CONFIG["LOGGING"]["show_irc_traffic"]: + logging.debug(f"Client {self.client_id} <<< {message}") + + # Handle PING + if message.startswith("PING"): + pong = message.replace("PING", "PONG", 1) + await self.send_command(pong) + + # Handle other server messages if needed + + except Exception as e: + logging.error(f"Client {self.client_id} read error: {e}") + self.stats.errors += 1 + break + + async def join_random_channel(self): + """Join a random channel""" + if not CONFIG["BEHAVIOR"]["join_channels"]: + return + + channel = random.choice(CONFIG["CHANNELS"]) + if channel not in self.channels: + await self.send_command(f"JOIN {channel}") + self.channels.append(channel) + self.stats.channels_joined += 1 + logging.debug(f"Client {self.client_id} joined {channel}") + + async def send_random_message(self): + """Send a random message to a random channel""" + if not CONFIG["BEHAVIOR"]["send_messages"] or not self.channels: + return + + channel = random.choice(self.channels) + message = random.choice(CONFIG["MESSAGES"]) + await self.send_command(f"PRIVMSG {channel} :{message}") + self.stats.messages_sent += 1 + logging.debug(f"Client {self.client_id} sent message to {channel}") + + async def send_random_command(self): + """Send a random IRC command""" + if not CONFIG["BEHAVIOR"]["use_new_commands"]: + return + + command = random.choice(CONFIG["COMMANDS"]) + await self.send_command(command) + self.stats.commands_sent += 1 + logging.debug(f"Client {self.client_id} sent command: {command}") + + async def change_nick(self): + """Change nickname""" + if not CONFIG["BEHAVIOR"]["change_nicks"]: + return + + new_nick = f"User{self.client_id}_{random.randint(1, 9999)}" + await self.send_command(f"NICK {new_nick}") + self.nick = new_nick + self.stats.nick_changes += 1 + logging.debug(f"Client {self.client_id} changed nick to {new_nick}") + + async def random_quit_reconnect(self): + """Randomly quit and reconnect""" + if not CONFIG["BEHAVIOR"]["random_quit"]: + return + + await self.send_command("QUIT :Reconnecting...") + await self.disconnect() + + # Wait a moment then reconnect + await asyncio.sleep(random.uniform(1, 3)) + + if await self.connect(CONFIG["SERVER"]["host"], CONFIG["SERVER"]["port"]): + await self.register() + self.stats.reconnections += 1 + logging.debug(f"Client {self.client_id} reconnected") + + async def perform_random_action(self): + """Perform a random IRC action""" + if not self.registered: + return + + action = random.random() + + if action < CONFIG["BEHAVIOR"]["message_rate"]: + await self.send_random_message() + elif action < CONFIG["BEHAVIOR"]["message_rate"] + CONFIG["BEHAVIOR"]["channel_join_rate"]: + await self.join_random_channel() + elif action < CONFIG["BEHAVIOR"]["message_rate"] + CONFIG["BEHAVIOR"]["channel_join_rate"] + CONFIG["BEHAVIOR"]["command_rate"]: + await self.send_random_command() + elif action < CONFIG["BEHAVIOR"]["message_rate"] + CONFIG["BEHAVIOR"]["channel_join_rate"] + CONFIG["BEHAVIOR"]["command_rate"] + CONFIG["BEHAVIOR"]["nick_change_rate"]: + await self.change_nick() + elif action < CONFIG["BEHAVIOR"]["message_rate"] + CONFIG["BEHAVIOR"]["channel_join_rate"] + CONFIG["BEHAVIOR"]["command_rate"] + CONFIG["BEHAVIOR"]["nick_change_rate"] + CONFIG["BEHAVIOR"]["quit_rate"]: + await self.random_quit_reconnect() + + async def disconnect(self): + """Disconnect from server""" + self.running = False + self.connected = False + + if self.writer: + try: + await self.send_command("QUIT :Stress test completed") + self.writer.close() + await self.writer.wait_closed() + except: + pass + + if self.registered: + self.stats.connected_clients -= 1 + self.registered = False + +class IRCStressTester: + """Main stress testing coordinator""" + + def __init__(self): + self.clients: List[IRCStressClient] = [] + self.stats = StressTestStats() + self.running = False + + # Setup logging + log_level = getattr(logging, CONFIG["LOGGING"]["level"]) + logging.basicConfig( + level=log_level, + format='%(asctime)s - %(levelname)s - %(message)s', + datefmt='%H:%M:%S' + ) + + async def create_client(self, client_id: int) -> Optional[IRCStressClient]: + """Create and connect a new client""" + client = IRCStressClient(client_id, self.stats) + + if await client.connect(CONFIG["SERVER"]["host"], CONFIG["SERVER"]["port"]): + if await client.register(): + # Start message reader + asyncio.create_task(client.read_messages()) + return client + + return None + + async def connect_all_clients(self): + """Connect all clients with delay""" + print(f"Connecting {CONFIG['TEST']['max_clients']} clients...") + + for i in range(CONFIG["TEST"]["max_clients"]): + client = await self.create_client(i) + if client: + self.clients.append(client) + + # Join initial channel + if CONFIG["BEHAVIOR"]["join_channels"]: + await client.join_random_channel() + + # Progress reporting + if (i + 1) % 10 == 0: + print(f"Connected {i + 1}/{CONFIG['TEST']['max_clients']} clients") + + # Delay between connections + if CONFIG["TEST"]["connect_delay"] > 0: + await asyncio.sleep(CONFIG["TEST"]["connect_delay"]) + + print(f"All {len(self.clients)} clients connected!") + + async def run_activity_simulation(self): + """Run the main activity simulation""" + print("Starting activity simulation...") + + start_time = time.time() + last_stats = time.time() + + while self.running and (time.time() - start_time) < CONFIG["TEST"]["test_duration"]: + # Perform random actions + active_clients = [c for c in self.clients if c.registered] + if active_clients: + # Select random clients to perform actions + num_actions = random.randint(1, len(active_clients) // 4 + 1) + selected_clients = random.sample(active_clients, min(num_actions, len(active_clients))) + + # Perform actions concurrently + tasks = [client.perform_random_action() for client in selected_clients] + await asyncio.gather(*tasks, return_exceptions=True) + + # Print stats periodically + if time.time() - last_stats >= CONFIG["TEST"]["stats_interval"]: + self.stats.print_stats() + last_stats = time.time() + + # Wait before next action cycle + await asyncio.sleep(CONFIG["TEST"]["action_interval"]) + + async def shutdown_all_clients(self): + """Gracefully disconnect all clients""" + print("Shutting down all clients...") + + disconnect_tasks = [] + for client in self.clients: + disconnect_tasks.append(client.disconnect()) + + # Wait for all disconnections + await asyncio.gather(*disconnect_tasks, return_exceptions=True) + + print("All clients disconnected.") + + async def run_stress_test(self): + """Run the complete stress test""" + print("="*60) + print("TECHIRCD ADVANCED STRESS TESTER") + print("="*60) + print(f"Target: {CONFIG['SERVER']['host']}:{CONFIG['SERVER']['port']}") + print(f"Clients: {CONFIG['TEST']['max_clients']}") + print(f"Duration: {CONFIG['TEST']['test_duration']}s") + print(f"Channels: {len(CONFIG['CHANNELS'])}") + print(f"Commands: {len(CONFIG['COMMANDS'])}") + print("="*60) + + self.running = True + + try: + # Connect all clients + await self.connect_all_clients() + + # Run activity simulation + await self.run_activity_simulation() + + except KeyboardInterrupt: + print("\nTest interrupted by user!") + except Exception as e: + print(f"\nTest failed with error: {e}") + finally: + self.running = False + await self.shutdown_all_clients() + + # Final stats + print("\nFINAL RESULTS:") + self.stats.print_stats() + +async def main(): + """Main entry point""" + stress_tester = IRCStressTester() + await stress_tester.run_stress_test() + +if __name__ == "__main__": + print("Starting TechIRCd Advanced Stress Tester...") + print("Press Ctrl+C to stop the test early.\n") + + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nTest terminated by user.") + except Exception as e: + print(f"\nFatal error: {e}") diff --git a/tools/stress_tester.go b/tools/stress_tester.go new file mode 100644 index 0000000..009ce6a --- /dev/null +++ b/tools/stress_tester.go @@ -0,0 +1,533 @@ +package main +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "log" + "math/rand" + "net" + "os" + "sync" + "time" +) + +// StressConfig defines the configuration for stress testing +type StressConfig struct { + Server struct { + Host string `json:"host"` + Port int `json:"port"` + } `json:"server"` + + Test struct { + MaxClients int `json:"max_clients"` + ConnectDelay int `json:"connect_delay_ms"` + ActionInterval int `json:"action_interval_ms"` + TestDuration int `json:"test_duration_seconds"` + RandomSeed int `json:"random_seed"` + } `json:"test"` + + Behavior struct { + JoinChannels bool `json:"join_channels"` + SendMessages bool `json:"send_messages"` + ChangeNicks bool `json:"change_nicks"` + UseNewCommands bool `json:"use_new_commands"` + RandomQuit bool `json:"random_quit"` + + MessageRate float64 `json:"message_rate"` + ChannelJoinRate float64 `json:"channel_join_rate"` + CommandRate float64 `json:"command_rate"` + } `json:"behavior"` + + Channels []string `json:"channels"` + Messages []string `json:"messages"` + Commands []string `json:"commands"` +} + +// IRCClient represents a single IRC client connection +type IRCClient struct { + ID int + Nick string + Conn net.Conn + Reader *bufio.Reader + Writer *bufio.Writer + Channels []string + Active bool + mu sync.Mutex +} + +// StressTest manages the entire stress testing operation +type StressTest struct { + Config *StressConfig + Clients []*IRCClient + Stats *TestStats + quit chan bool +} + +// TestStats tracks testing statistics +type TestStats struct { + ConnectedClients int + MessagesSent int + CommandsSent int + ChannelsJoined int + Errors int + StartTime time.Time + mu sync.Mutex +} + +// LoadConfig loads configuration from JSON file +func LoadConfig(filename string) (*StressConfig, error) { + file, err := os.Open(filename) + if err != nil { + return nil, fmt.Errorf("error opening config file: %v", err) + } + defer file.Close() + + config := &StressConfig{} + decoder := json.NewDecoder(file) + if err := decoder.Decode(config); err != nil { + return nil, fmt.Errorf("error parsing config: %v", err) + } + + return config, nil +} + +// CreateDefaultConfig creates a default configuration file +func CreateDefaultConfig(filename string) error { + config := &StressConfig{ + Server: struct { + Host string `json:"host"` + Port int `json:"port"` + }{ + Host: "localhost", + Port: 6667, + }, + Test: struct { + MaxClients int `json:"max_clients"` + ConnectDelay int `json:"connect_delay_ms"` + ActionInterval int `json:"action_interval_ms"` + TestDuration int `json:"test_duration_seconds"` + RandomSeed int `json:"random_seed"` + }{ + MaxClients: 300, + ConnectDelay: 100, + ActionInterval: 2000, + TestDuration: 180, + RandomSeed: 42, + }, + Behavior: struct { + JoinChannels bool `json:"join_channels"` + SendMessages bool `json:"send_messages"` + ChangeNicks bool `json:"change_nicks"` + UseNewCommands bool `json:"use_new_commands"` + RandomQuit bool `json:"random_quit"` + MessageRate float64 `json:"message_rate"` + ChannelJoinRate float64 `json:"channel_join_rate"` + CommandRate float64 `json:"command_rate"` + }{ + JoinChannels: true, + SendMessages: true, + ChangeNicks: true, + UseNewCommands: true, + RandomQuit: false, + MessageRate: 0.4, + ChannelJoinRate: 0.3, + CommandRate: 0.2, + }, + Channels: []string{ + "#test", "#stress", "#general", "#random", "#chaos", + "#lobby", "#gaming", "#tech", "#chat", "#help", + }, + Messages: []string{ + "Hello everyone!", + "This is a stress test message", + "How is everyone doing?", + "Testing the server stability", + "Random message from client", + "IRC is awesome!", + "TechIRCd rocks!", + "Can you see this message?", + "Stress testing in progress", + "Everything working fine here", + "Testing new IRC commands", + "Server performance looks good", + }, + Commands: []string{ + "MOTD", "RULES", "MAP", "TIME", "VERSION", "LUSERS", + "WHO #test", "WHOIS testuser", "LIST", "WHOWAS olduser", + }, + } + + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("error creating config file: %v", err) + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + return encoder.Encode(config) +} + +// NewStressTest creates a new stress test instance +func NewStressTest(config *StressConfig) *StressTest { + return &StressTest{ + Config: config, + Clients: make([]*IRCClient, 0, config.Test.MaxClients), + Stats: &TestStats{ + StartTime: time.Now(), + }, + quit: make(chan bool), + } +} + +// Connect establishes connection to IRC server +func (c *IRCClient) Connect(host string, port int) error { + conn, err := net.Dial("tcp", fmt.Sprintf("%s:%d", host, port)) + if err != nil { + return fmt.Errorf("failed to connect: %v", err) + } + + c.Conn = conn + c.Reader = bufio.NewReader(conn) + c.Writer = bufio.NewWriter(conn) + c.Active = true + + return nil +} + +// Register performs IRC client registration +func (c *IRCClient) Register() error { + commands := []string{ + fmt.Sprintf("NICK %s", c.Nick), + fmt.Sprintf("USER %s 0 * :Stress Test Client %d", c.Nick, c.ID), + } + + for _, cmd := range commands { + if err := c.SendCommand(cmd); err != nil { + return fmt.Errorf("registration failed: %v", err) + } + } + + return nil +} + +// SendCommand sends a command to the IRC server +func (c *IRCClient) SendCommand(command string) error { + c.mu.Lock() + defer c.mu.Unlock() + + if !c.Active || c.Writer == nil { + return fmt.Errorf("client not active") + } + + _, err := c.Writer.WriteString(command + "\r\n") + if err != nil { + return err + } + + return c.Writer.Flush() +} + +// ReadMessages continuously reads messages from server +func (c *IRCClient) ReadMessages(stats *TestStats) { + defer func() { + c.Active = false + if c.Conn != nil { + c.Conn.Close() + } + }() + + for c.Active { + if c.Reader == nil { + break + } + + line, err := c.Reader.ReadString('\n') + if err != nil { + stats.mu.Lock() + stats.Errors++ + stats.mu.Unlock() + break + } + + // Handle PING responses + if len(line) > 4 && line[:4] == "PING" { + pong := "PONG" + line[4:] + c.SendCommand(pong[:len(pong)-2]) // Remove \r\n + } + } +} + +// PerformRandomAction performs a random IRC action +func (c *IRCClient) PerformRandomAction(config *StressConfig, stats *TestStats) { + if !c.Active { + return + } + + action := rand.Float64() + + switch { + case action < config.Behavior.MessageRate && config.Behavior.SendMessages: + c.SendRandomMessage(config, stats) + case action < config.Behavior.MessageRate+config.Behavior.ChannelJoinRate && config.Behavior.JoinChannels: + c.JoinRandomChannel(config, stats) + case action < config.Behavior.MessageRate+config.Behavior.ChannelJoinRate+config.Behavior.CommandRate && config.Behavior.UseNewCommands: + c.SendRandomCommand(config, stats) + case config.Behavior.ChangeNicks && rand.Float64() < 0.05: + c.ChangeNick(stats) + } +} + +// SendRandomMessage sends a random message to a random channel +func (c *IRCClient) SendRandomMessage(config *StressConfig, stats *TestStats) { + if len(c.Channels) == 0 { + return + } + + channel := c.Channels[rand.Intn(len(c.Channels))] + message := config.Messages[rand.Intn(len(config.Messages))] + + cmd := fmt.Sprintf("PRIVMSG %s :%s", channel, message) + if err := c.SendCommand(cmd); err == nil { + stats.mu.Lock() + stats.MessagesSent++ + stats.mu.Unlock() + } else { + stats.mu.Lock() + stats.Errors++ + stats.mu.Unlock() + } +} + +// JoinRandomChannel joins a random channel +func (c *IRCClient) JoinRandomChannel(config *StressConfig, stats *TestStats) { + channel := config.Channels[rand.Intn(len(config.Channels))] + + // Check if already in channel + for _, ch := range c.Channels { + if ch == channel { + return + } + } + + cmd := fmt.Sprintf("JOIN %s", channel) + if err := c.SendCommand(cmd); err == nil { + c.Channels = append(c.Channels, channel) + stats.mu.Lock() + stats.ChannelsJoined++ + stats.mu.Unlock() + } else { + stats.mu.Lock() + stats.Errors++ + stats.mu.Unlock() + } +} + +// SendRandomCommand sends a random IRC command +func (c *IRCClient) SendRandomCommand(config *StressConfig, stats *TestStats) { + command := config.Commands[rand.Intn(len(config.Commands))] + + if err := c.SendCommand(command); err == nil { + stats.mu.Lock() + stats.CommandsSent++ + stats.mu.Unlock() + } else { + stats.mu.Lock() + stats.Errors++ + stats.mu.Unlock() + } +} + +// ChangeNick changes the client's nickname +func (c *IRCClient) ChangeNick(stats *TestStats) { + newNick := fmt.Sprintf("User%d_%d", c.ID, rand.Intn(1000)) + cmd := fmt.Sprintf("NICK %s", newNick) + + if err := c.SendCommand(cmd); err == nil { + c.Nick = newNick + } else { + stats.mu.Lock() + stats.Errors++ + stats.mu.Unlock() + } +} + +// CreateClient creates and connects a new IRC client +func (st *StressTest) CreateClient(id int) error { + client := &IRCClient{ + ID: id, + Nick: fmt.Sprintf("StressUser%d", id), + Channels: make([]string, 0), + } + + // Connect to server + if err := client.Connect(st.Config.Server.Host, st.Config.Server.Port); err != nil { + return fmt.Errorf("client %d connection failed: %v", id, err) + } + + // Register with IRC server + if err := client.Register(); err != nil { + client.Conn.Close() + return fmt.Errorf("client %d registration failed: %v", id, err) + } + + st.Clients = append(st.Clients, client) + + st.Stats.mu.Lock() + st.Stats.ConnectedClients++ + st.Stats.mu.Unlock() + + // Start message reader goroutine + go client.ReadMessages(st.Stats) + + return nil +} + +// RunStressTest executes the complete stress test +func (st *StressTest) RunStressTest() error { + log.Printf("🚀 Starting TechIRCd stress test with %d clients", st.Config.Test.MaxClients) + log.Printf("📡 Target server: %s:%d", st.Config.Server.Host, st.Config.Server.Port) + log.Printf("⏱️ Test duration: %d seconds", st.Config.Test.TestDuration) + + // Set random seed + rand.Seed(int64(st.Config.Test.RandomSeed)) + + // Connect clients gradually + connectDelay := time.Duration(st.Config.Test.ConnectDelay) * time.Millisecond + for i := 0; i < st.Config.Test.MaxClients; i++ { + if err := st.CreateClient(i); err != nil { + log.Printf("❌ Failed to create client %d: %v", i, err) + st.Stats.mu.Lock() + st.Stats.Errors++ + st.Stats.mu.Unlock() + continue + } + + if i%25 == 0 { + log.Printf("🔗 Connected %d/%d clients", i+1, st.Config.Test.MaxClients) + } + + time.Sleep(connectDelay) + } + + log.Printf("✅ All clients connected! Starting chaos simulation...") + + // Start activity simulation + actionInterval := time.Duration(st.Config.Test.ActionInterval) * time.Millisecond + testDuration := time.Duration(st.Config.Test.TestDuration) * time.Second + + actionTicker := time.NewTicker(actionInterval) + defer actionTicker.Stop() + + statsTicker := time.NewTicker(15 * time.Second) + defer statsTicker.Stop() + + testTimer := time.NewTimer(testDuration) + defer testTimer.Stop() + + for { + select { + case <-actionTicker.C: + // Perform random actions for random clients + numActions := rand.Intn(len(st.Clients)/3) + 1 + for i := 0; i < numActions; i++ { + if len(st.Clients) > 0 { + clientIndex := rand.Intn(len(st.Clients)) + go st.Clients[clientIndex].PerformRandomAction(st.Config, st.Stats) + } + } + + case <-statsTicker.C: + st.PrintStats() + + case <-testTimer.C: + log.Println("⏰ Test duration completed. Shutting down...") + st.Shutdown() + return nil + + case <-st.quit: + log.Println("🛑 Test interrupted. Shutting down...") + st.Shutdown() + return nil + } + } +} + +// PrintStats prints current test statistics +func (st *StressTest) PrintStats() { + st.Stats.mu.Lock() + defer st.Stats.mu.Unlock() + + elapsed := time.Since(st.Stats.StartTime) + + log.Printf("📊 === STRESS TEST STATS ===") + log.Printf("⏱️ Runtime: %v", elapsed.Round(time.Second)) + log.Printf("👥 Connected Clients: %d", st.Stats.ConnectedClients) + log.Printf("💬 Messages Sent: %d", st.Stats.MessagesSent) + log.Printf("⚡ Commands Sent: %d", st.Stats.CommandsSent) + log.Printf("🏠 Channels Joined: %d", st.Stats.ChannelsJoined) + log.Printf("❌ Errors: %d", st.Stats.Errors) + + if elapsed.Seconds() > 0 { + log.Printf("📈 Messages/sec: %.2f", float64(st.Stats.MessagesSent)/elapsed.Seconds()) + log.Printf("📈 Commands/sec: %.2f", float64(st.Stats.CommandsSent)/elapsed.Seconds()) + } + log.Printf("============================") +} + +// Shutdown gracefully shuts down all clients +func (st *StressTest) Shutdown() { + log.Println("🔌 Shutting down all clients...") + + for i, client := range st.Clients { + if client.Active { + client.SendCommand("QUIT :Stress test completed") + client.Active = false + if client.Conn != nil { + client.Conn.Close() + } + } + + if i%50 == 0 && i > 0 { + log.Printf("🔌 Disconnected %d/%d clients", i+1, len(st.Clients)) + } + } + + st.PrintStats() + log.Println("🎉 Stress test completed successfully!") +} + +func main() { + configFile := "stress_test_config.json" + + log.Println("🎯 TechIRCd Go Stress Tester v1.0") + log.Println("==================================") + + // Check if config file exists, create default if not + if _, err := os.Stat(configFile); os.IsNotExist(err) { + log.Printf("📝 Config file %s not found. Creating default...", configFile) + if err := CreateDefaultConfig(configFile); err != nil { + log.Fatalf("❌ Failed to create default config: %v", err) + } + log.Printf("✅ Default config created at %s", configFile) + log.Println("📖 Please review and modify the config as needed, then run again.") + return + } + + // Load configuration + config, err := LoadConfig(configFile) + if err != nil { + log.Fatalf("❌ Failed to load config: %v", err) + } + + log.Printf("📋 Loaded config: %d clients, %d second test", config.Test.MaxClients, config.Test.TestDuration) + + // Create and run stress test + stressTest := NewStressTest(config) + + if err := stressTest.RunStressTest(); err != nil { + log.Fatalf("❌ Stress test failed: %v", err) + } +} diff --git a/validation.go b/validation.go new file mode 100644 index 0000000..d090be8 --- /dev/null +++ b/validation.go @@ -0,0 +1,134 @@ +package main + +import ( + "fmt" + "strings" +) + +// ValidateConfig performs comprehensive validation of the server configuration +func (c *Config) Validate() error { + // Validate server settings + if c.Server.Name == "" { + return fmt.Errorf("server name cannot be empty") + } + + if c.Server.Network == "" { + return fmt.Errorf("network name cannot be empty") + } + + if c.Server.Listen.Host == "" { + c.Server.Listen.Host = "localhost" // Default fallback + } + + if c.Server.Listen.Port <= 0 || c.Server.Listen.Port > 65535 { + return fmt.Errorf("invalid port number: %d", c.Server.Listen.Port) + } + + if c.Server.Listen.EnableSSL && (c.Server.Listen.SSLPort <= 0 || c.Server.Listen.SSLPort > 65535) { + return fmt.Errorf("invalid SSL port number: %d", c.Server.Listen.SSLPort) + } + + // Validate limits + if c.Limits.MaxClients <= 0 { + c.Limits.MaxClients = 1000 // Default + } + + if c.Limits.MaxChannels <= 0 { + c.Limits.MaxChannels = 100 // Default + } + + if c.Limits.MaxNickLength <= 0 || c.Limits.MaxNickLength > 50 { + c.Limits.MaxNickLength = 30 // Default + } + + if c.Limits.PingTimeout <= 0 { + c.Limits.PingTimeout = 300 // Default 5 minutes + } + + if c.Limits.FloodLines <= 0 { + c.Limits.FloodLines = 10 // Default + } + + if c.Limits.FloodSeconds <= 0 { + c.Limits.FloodSeconds = 60 // Default + } + + // Validate channels + for _, channelName := range c.Channels.AutoJoin { + if !isChannelName(channelName) { + return fmt.Errorf("invalid channel name in auto_join: %s", channelName) + } + } + + // Validate default modes + validChannelModes := "mntisp" + for _, mode := range c.Channels.DefaultModes { + if mode != '+' && !strings.ContainsRune(validChannelModes, mode) { + return fmt.Errorf("invalid default channel mode: %c", mode) + } + } + + // Validate founder mode + validFounderModes := []string{"q", "a", "o", "h", "v"} + foundValidMode := false + for _, validMode := range validFounderModes { + if c.Channels.FounderMode == validMode { + foundValidMode = true + break + } + } + if !foundValidMode { + return fmt.Errorf("invalid founder_mode '%s', must be one of: q (owner), a (admin), o (operator), h (halfop), v (voice)", c.Channels.FounderMode) + } + + // Validate operators + for i, oper := range c.Opers { + if oper.Name == "" { + return fmt.Errorf("operator %d: name cannot be empty", i) + } + if oper.Password == "" { + return fmt.Errorf("operator %s: password cannot be empty", oper.Name) + } + if oper.Host == "" { + return fmt.Errorf("operator %s: host cannot be empty", oper.Name) + } + } + + return nil +} + +// SanitizeConfig applies safe defaults and sanitizes configuration values +func (c *Config) SanitizeConfig() { + // Ensure reasonable limits + if c.Limits.MaxClients > 10000 { + c.Limits.MaxClients = 10000 + } + + if c.Limits.MaxChannels > 1000 { + c.Limits.MaxChannels = 1000 + } + + // Set default founder mode if empty or invalid + if c.Channels.FounderMode == "" { + c.Channels.FounderMode = "o" // Default to operator + } + + // Ensure reasonable string lengths + if c.Limits.MaxTopicLength > 2048 { + c.Limits.MaxTopicLength = 2048 + } + + if c.Limits.MaxKickLength > 2048 { + c.Limits.MaxKickLength = 2048 + } + + // Ensure MOTD isn't too long + if len(c.MOTD) > 50 { + c.MOTD = c.MOTD[:50] + } + + // Sanitize channel modes + if c.Channels.DefaultModes == "" { + c.Channels.DefaultModes = "+nt" + } +} diff --git a/web_interface.go b/web_interface.go new file mode 100644 index 0000000..31a361a --- /dev/null +++ b/web_interface.go @@ -0,0 +1,127 @@ +package main + +import ( + "encoding/json" + "net/http" + "time" +) + +// Web administration interface +type WebInterface struct { + Enable bool `json:"enable"` + Port int `json:"port"` + Host string `json:"host"` + SSL struct { + Enable bool `json:"enable"` + CertFile string `json:"cert_file"` + KeyFile string `json:"key_file"` + } `json:"ssl"` + Authentication struct { + Method string `json:"method"` // basic, oauth, jwt + Username string `json:"username"` + Password string `json:"password"` + } `json:"authentication"` +} + +// WebServerStats represents statistics for the web interface +type WebServerStats struct { + Uptime time.Duration `json:"uptime"` + ActiveUsers int `json:"active_users"` + ActiveChannels int `json:"active_channels"` + LinkedServers int `json:"linked_servers"` + MessagesPerSec float64 `json:"messages_per_sec"` + MemoryUsage uint64 `json:"memory_usage"` + CPUUsage float64 `json:"cpu_usage"` +} + +// WebUserInfo represents user information for the web interface +type WebUserInfo struct { + Nick string `json:"nick"` + User string `json:"user"` + Host string `json:"host"` + Channels int `json:"channels"` + IsOper bool `json:"is_oper"` + ConnTime time.Time `json:"conn_time"` +} + +// REST API endpoints +func (w *WebInterface) RegisterRoutes() { + http.HandleFunc("/api/v1/server/stats", w.handleServerStats) + http.HandleFunc("/api/v1/users", w.handleUsers) + http.HandleFunc("/api/v1/channels", w.handleChannels) + http.HandleFunc("/api/v1/operators", w.handleOperators) + http.HandleFunc("/api/v1/config", w.handleConfig) + http.HandleFunc("/api/v1/logs", w.handleLogs) + http.HandleFunc("/api/v1/bans", w.handleBans) + http.HandleFunc("/api/v1/network", w.handleNetwork) + + // WebSocket for real-time updates + http.HandleFunc("/ws/live", w.handleWebSocket) + + // Static files for web interface + http.Handle("/", http.FileServer(http.Dir("web/static/"))) +} + +func (w *WebInterface) handleServerStats(rw http.ResponseWriter, r *http.Request) { + // TODO: Get actual server statistics + stats := WebServerStats{ + Uptime: time.Duration(0), + ActiveUsers: 0, + ActiveChannels: 0, + LinkedServers: 0, + MessagesPerSec: 0.0, + MemoryUsage: 0, + CPUUsage: 0.0, + } + + rw.Header().Set("Content-Type", "application/json") + json.NewEncoder(rw).Encode(stats) +} + +func (w *WebInterface) handleUsers(rw http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + // List all users + users := make([]*WebUserInfo, 0) + // TODO: Get actual users from server + json.NewEncoder(rw).Encode(users) + + case "POST": + // Send message to user or perform action + http.Error(rw, "Not implemented", http.StatusNotImplemented) + + case "DELETE": + // Disconnect user + http.Error(rw, "Not implemented", http.StatusNotImplemented) + } +} + +func (w *WebInterface) handleChannels(rw http.ResponseWriter, r *http.Request) { + http.Error(rw, "Not implemented", http.StatusNotImplemented) +} + +func (w *WebInterface) handleOperators(rw http.ResponseWriter, r *http.Request) { + http.Error(rw, "Not implemented", http.StatusNotImplemented) +} + +func (w *WebInterface) handleConfig(rw http.ResponseWriter, r *http.Request) { + http.Error(rw, "Not implemented", http.StatusNotImplemented) +} + +func (w *WebInterface) handleLogs(rw http.ResponseWriter, r *http.Request) { + http.Error(rw, "Not implemented", http.StatusNotImplemented) +} + +func (w *WebInterface) handleBans(rw http.ResponseWriter, r *http.Request) { + http.Error(rw, "Not implemented", http.StatusNotImplemented) +} + +func (w *WebInterface) handleNetwork(rw http.ResponseWriter, r *http.Request) { + http.Error(rw, "Not implemented", http.StatusNotImplemented) +} + +// Real-time dashboard with WebSocket +func (w *WebInterface) handleWebSocket(rw http.ResponseWriter, r *http.Request) { + // TODO: Upgrade to WebSocket and send real-time updates + http.Error(rw, "WebSocket not implemented", http.StatusNotImplemented) +} diff --git a/worker_pool.go b/worker_pool.go new file mode 100644 index 0000000..5632d44 --- /dev/null +++ b/worker_pool.go @@ -0,0 +1,180 @@ +package main + +import ( + "log" + "net" + "runtime" + "sync" + "time" +) + +// WorkerPool manages goroutines for handling client connections +type WorkerPool struct { + workers int + jobs chan net.Conn + server *Server + wg sync.WaitGroup + shutdown chan bool + stats *PoolStats +} + +type PoolStats struct { + ActiveWorkers int64 + ProcessedJobs int64 + QueuedJobs int64 + ErrorCount int64 + mu sync.RWMutex +} + +// NewWorkerPool creates a new worker pool +func NewWorkerPool(workers int, server *Server) *WorkerPool { + if workers <= 0 { + workers = runtime.NumCPU() * 4 // Default: 4 workers per CPU core + } + + return &WorkerPool{ + workers: workers, + jobs: make(chan net.Conn, workers*10), // Buffer for connection queue + server: server, + shutdown: make(chan bool), + stats: &PoolStats{}, + } +} + +// Start initializes and starts the worker pool +func (wp *WorkerPool) Start() { + log.Printf("Starting worker pool with %d workers", wp.workers) + + for i := 0; i < wp.workers; i++ { + wp.wg.Add(1) + go wp.worker(i) + } + + // Start stats reporter + go wp.statsReporter() +} + +// worker processes incoming connections +func (wp *WorkerPool) worker(id int) { + defer wp.wg.Done() + + wp.updateActiveWorkers(1) + defer wp.updateActiveWorkers(-1) + + log.Printf("Worker %d started", id) + + for { + select { + case conn := <-wp.jobs: + wp.handleConnection(conn, id) + wp.updateProcessedJobs(1) + + case <-wp.shutdown: + log.Printf("Worker %d shutting down", id) + return + } + } +} + +// handleConnection processes a single client connection +func (wp *WorkerPool) handleConnection(conn net.Conn, workerID int) { + defer func() { + if r := recover(); r != nil { + log.Printf("Worker %d recovered from panic: %v", workerID, r) + wp.updateErrorCount(1) + conn.Close() + } + }() + + // Set connection timeouts + conn.SetReadDeadline(time.Now().Add(30 * time.Second)) + conn.SetWriteDeadline(time.Now().Add(30 * time.Second)) + + client := NewClient(conn, wp.server) + wp.server.AddClient(client) + + log.Printf("Worker %d handling client from %s", workerID, conn.RemoteAddr()) + + // Handle client in this worker goroutine + client.Handle() +} + +// Submit adds a connection to the worker pool queue +func (wp *WorkerPool) Submit(conn net.Conn) bool { + wp.updateQueuedJobs(1) + + select { + case wp.jobs <- conn: + return true + default: + // Queue is full + wp.updateQueuedJobs(-1) + wp.updateErrorCount(1) + log.Printf("Worker pool queue full, rejecting connection from %s", conn.RemoteAddr()) + return false + } +} + +// Shutdown gracefully stops the worker pool +func (wp *WorkerPool) Shutdown() { + log.Println("Shutting down worker pool...") + + close(wp.shutdown) + close(wp.jobs) + + // Wait for all workers to finish + wp.wg.Wait() + + log.Println("Worker pool shutdown complete") +} + +// Stats update methods +func (wp *WorkerPool) updateActiveWorkers(delta int64) { + wp.stats.mu.Lock() + wp.stats.ActiveWorkers += delta + wp.stats.mu.Unlock() +} + +func (wp *WorkerPool) updateProcessedJobs(delta int64) { + wp.stats.mu.Lock() + wp.stats.ProcessedJobs += delta + wp.stats.QueuedJobs -= delta + wp.stats.mu.Unlock() +} + +func (wp *WorkerPool) updateQueuedJobs(delta int64) { + wp.stats.mu.Lock() + wp.stats.QueuedJobs += delta + wp.stats.mu.Unlock() +} + +func (wp *WorkerPool) updateErrorCount(delta int64) { + wp.stats.mu.Lock() + wp.stats.ErrorCount += delta + wp.stats.mu.Unlock() +} + +// GetStats returns current pool statistics +func (wp *WorkerPool) GetStats() PoolStats { + wp.stats.mu.RLock() + defer wp.stats.mu.RUnlock() + return *wp.stats +} + +// statsReporter periodically logs worker pool statistics +func (wp *WorkerPool) statsReporter() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + stats := wp.GetStats() + log.Printf("Worker Pool Stats - Active: %d, Processed: %d, Queued: %d, Errors: %d", + stats.ActiveWorkers, stats.ProcessedJobs, stats.QueuedJobs, stats.ErrorCount) + + case <-wp.shutdown: + return + } + } +}