// IRC server // this is a basic ad-hoc server with a few commands // works for small group chats and stuff // now has SASL support package main import ( "bufio" "encoding/base64" "encoding/json" "fmt" "log" "net" "os" "strings" "sync" "time" "github.com/ergochat/irc-go/ircmsg" ) type Client struct { nick string user string conn net.Conn host string channels map[string]*Channel registered bool saslRequired bool saslComplete bool awaitingSASLData bool } type Channel struct { name string description string members map[string]*Client ops map[string]bool mu sync.RWMutex } type Channelconfig struct { Channel string `json:"channel"` Description string `json:"description"` } type Config struct { Port int `json:"port"` Webapiport int `json:"webapiport"` Motd string `json:"motd"` Channels []Channelconfig `json:"channels"` Ops []string `json:"ops"` } var ( channels = make(map[string]*Channel) clients = make(map[string]*Client) globalOps = make(map[string]bool) chMu sync.Mutex // mutex for channels cliMu sync.RWMutex // mutex for clients motdString []string motdFile string ) func loadConfig() (*Config, error) { var conf Config jsdata, err := os.ReadFile("config.json") if err != nil { return nil, err } err = json.Unmarshal(jsdata, &conf) if err != nil { return nil, err } for _, cfg := range conf.Channels { addChannel(cfg.Channel, cfg.Description) } for _, op := range conf.Ops { cleanedNick := strings.TrimPrefix(op, "@") globalOps[cleanedNick] = true } motdFile = conf.Motd return &conf, nil } func loadMOTD(filename string) error { file, err := os.Open(filename) if err != nil { return err } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { text := scanner.Text() motdString = append(motdString, text) } if err := scanner.Err(); err != nil { return err } return nil } func main() { cfg, err := loadConfig() if err != nil { log.Fatalf("could not load config file: %v\n", err) } errmotd := loadMOTD(motdFile) if errmotd != nil { log.Fatalf("could not load message of the day file: %v", err) } ln, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.Port)) if err != nil { log.Fatalf("Error listening: %v\n", err) } log.Printf("IRC server listening on port %d\n", cfg.Port) go startHTTP(*cfg) for { conn, err := ln.Accept() if err != nil { log.Printf("Error accepting: %v", err) continue } go handleConn(conn) } } func handleConn(conn net.Conn) { fmt.Printf("connection from %s\n", conn.RemoteAddr().String()) defer conn.Close() host, _, _ := net.SplitHostPort(conn.RemoteAddr().String()) reader := bufio.NewReader(conn) client := &Client{ conn: conn, host: host, channels: make(map[string]*Channel), registered: false, } defer client.cleanup() for { line, err := reader.ReadString('\n') if err != nil { return } line = strings.TrimRight(line, "\r\n") msg, err := ircmsg.ParseLine(line) if err != nil { continue } //fmt.Printf(" %s\n", line) switch strings.ToUpper(msg.Command) { case "NICK": if len(msg.Params) > 0 { newNick := msg.Params[0] if nicknameExists(newNick) { client.fwrite(":server 433 * %s :Nickname is already in use\r\n", newNick) } else { oldNick := client.nick if oldNick != "" { // nick change cliMu.Lock() delete(clients, oldNick) clients[newNick] = client cliMu.Unlock() client.nick = newNick for _, ch := range client.channels { broadcastNames(ch) } for _, other := range clients { if other != client { other.fwrite(":%s NICK %s\r\n", oldNick, newNick) } } } else { client.nick = newNick cliMu.Lock() clients[newNick] = client cliMu.Unlock() } client.checkRegistration() } } case "CAP": if len(msg.Params) < 1 { client.fwrite(":server 461 %s CAP :Not enough parameters\r\n", client.nick) continue } subcmd := strings.ToUpper(msg.Params[0]) switch subcmd { case "LS": // explicitly tell we only support PLAIN SASL client.fwrite(":server CAP * LS :sasl=PLAIN\r\n") case "REQ": if len(msg.Params) >= 2 && strings.Contains(strings.ToLower(msg.Params[1]), "sasl") { client.saslRequired = true client.fwrite(":server CAP * ACK :sasl\r\n") } else { client.fwrite(":server CAP * NAK :%s\r\n", msg.Params[1]) } case "END": client.checkRegistration() default: client.fwrite(":server 410 %s CAP :Invalid CAP subcommand\r\n", client.nick) } case "AUTHENTICATE": if len(msg.Params) == 0 { client.fwrite(":server 461 %s AUTHENTICATE :Not enough parameters\r\n", client.nick) continue } arg := msg.Params[0] if strings.ToUpper(arg) == "PLAIN" { client.awaitingSASLData = true client.fwrite("AUTHENTICATE +\r\n") continue } if client.awaitingSASLData { data, err := base64.StdEncoding.DecodeString(arg) if err != nil { client.fwrite(":server 904 %s :Invalid base64\r\n", client.nick) client.awaitingSASLData = false continue } parts := strings.Split(string(data), "\x00") if len(parts) != 3 { client.fwrite(":server 904 %s :Malformed SASL data\r\n", client.nick) client.awaitingSASLData = false continue } authcid, password := parts[1], parts[2] if checkCredentials(authcid, password) { client.saslComplete = true client.fwrite(":server 903 %s :SASL authentication successful\r\n", client.nick) client.checkRegistration() } else { client.fwrite(":server 904 %s :SASL authentication failed\r\n", client.nick) } client.awaitingSASLData = false } else { client.fwrite(":server 904 %s :Unsupported SASL mechanism\r\n", client.nick) } case "USER": if len(msg.Params) >= 4 { uname := msg.Params[0] client.user = uname client.checkRegistration() } else { fmt.Fprintf(conn, ":server 461 * USER :Not enough parameters\r\n") } case "JOIN": if !client.registered { client.sendUnregisteredNotice() continue } if len(msg.Params) > 0 && client.registered { joinChannel(client, msg.Params[0]) } case "PRIVMSG": if !client.registered { client.sendUnregisteredNotice() continue } if len(msg.Params) >= 2 && client.registered { target := msg.Params[0] text := msg.Params[1] sendMessage(client, target, text) } case "PING": if len(msg.Params) > 0 { fmt.Fprintf(conn, "PONG :%s\r\n", msg.Params[0]) } case "WHO": if !client.registered { client.sendUnregisteredNotice() continue } if len(msg.Params) > 0 { showWho(client, msg.Params[0]) } case "LIST": if !client.registered { client.sendUnregisteredNotice() continue } client.handleList() case "WHOIS": if !client.registered { client.sendUnregisteredNotice() continue } if len(msg.Params) > 0 { usertolookUp := msg.Params[0] client.performWhois(usertolookUp) } case "PART": if !client.registered { client.sendUnregisteredNotice() continue } if len(msg.Params) > 0 { client.partChannel(msg.Params[0]) } case "NOTICE": if !client.registered { client.sendUnregisteredNotice() continue } if len(msg.Params) >= 2 { target := msg.Params[0] text := msg.Params[1] sendNotice(client, target, text) } case "QUIT": if !client.registered { client.sendUnregisteredNotice() return } client.cleanup() return // mod commands case "KICK": if !client.registered { client.sendUnregisteredNotice() continue } if len(msg.Params) < 2 { client.fwrite(":server 461 %s KICK :Not enough parameters\r\n", client.nick) continue } channelName := normalizeChannelName(msg.Params[0]) targetNick := msg.Params[1] reason := "Kicked" if len(msg.Params) > 2 { reason = msg.Params[2] } kickUser(client, channelName, targetNick, reason) case "MODE": if !client.registered { client.sendUnregisteredNotice() continue } if len(msg.Params) < 2 { client.fwrite(":server 461 %s MODE :Not enough parameters\r\n", client.nick) continue } channelName := normalizeChannelName(msg.Params[0]) mode := msg.Params[1] targetNick := "" if len(msg.Params) > 2 { targetNick = msg.Params[2] } handleMode(client, channelName, mode, targetNick) // end of mod cmds case "NAMES": if !client.registered { client.sendUnregisteredNotice() continue } if len(msg.Params) > 0 { name := normalizeChannelName(msg.Params[0]) chMu.Lock() ch, ok := channels[name] chMu.Unlock() if ok { client.sendNames(ch) } else { fmt.Fprintf(client.conn, ":server 403 %s %s :No such channel\r\n", client.nick, name) } } else { fmt.Fprintf(client.conn, ":server 461 %s NAMES :Not enough parameters\r\n", client.nick) } default: if client.nick != "" { fmt.Fprintf(conn, ":server 421 %s %s :Unknown command or command is not implemented\r\n", client.nick, msg.Command) } else { fmt.Fprintf(conn, ":server 421 * %s :Unknown command or command is not implemented\r\n", msg.Command) } } } } func (c *Client) sendNames(ch *Channel) { ch.mu.Lock() defer ch.mu.Unlock() var nicks []string for nick := range ch.members { if ch.ops[nick] { nicks = append(nicks, "@"+nick) } else { nicks = append(nicks, nick) } } // the code 353 indicates start of name list fmt.Fprintf(c.conn, ":server 353 %s = %s :%s\r\n", c.nick, ch.name, strings.Join(nicks, " ")) fmt.Fprintf(c.conn, ":server 366 %s %s :End of /NAMES list.\r\n", c.nick, ch.name) } func kickUser(sender *Client, channelName, targetNick, reason string) { chMu.Lock() ch, ok := channels[channelName] chMu.Unlock() if !ok { sender.fwrite(":server 403 %s %s :No such channel\r\n", sender.nick, channelName) return } ch.mu.Lock() defer ch.mu.Unlock() if !ch.ops[sender.nick] { sender.fwrite(":server 482 %s %s :You're not channel operator\r\n", sender.nick, channelName) return } target, ok := ch.members[targetNick] if !ok { sender.fwrite(":server 441 %s %s %s :target user is not on that channel\r\n", sender.nick, targetNick, channelName) return } if globalOps[targetNick] { sender.fwrite(":server 482 %s %s :Cannot kick global operator\r\n", sender.nick, channelName) return } for _, member := range ch.members { member.fwrite(":%s KICK %s %s :%s\r\n", sender.nick, channelName, targetNick, reason) } // Remove target delete(ch.members, targetNick) delete(target.channels, channelName) } func handleMode(sender *Client, channelName, mode, targetNick string) { chMu.Lock() ch, ok := channels[channelName] chMu.Unlock() if !ok { sender.fwrite(":server 403 %s %s :No such channel\r\n", sender.nick, channelName) return } ch.mu.Lock() defer ch.mu.Unlock() if !ch.ops[sender.nick] { sender.fwrite(":server 482 %s %s :You're not channel operator\r\n", sender.nick, channelName) return } switch mode { case "+o": if targetNick != "" { ch.ops[targetNick] = true for _, member := range ch.members { member.fwrite(":%s MODE %s +o %s\r\n", sender.nick, channelName, targetNick) } } case "-o": if targetNick != "" { if globalOps[targetNick] { sender.fwrite(":server 482 %s %s :Cannot remove global operator\r\n", sender.nick, channelName) return } delete(ch.ops, targetNick) for _, member := range ch.members { member.fwrite(":%s MODE %s -o %s\r\n", sender.nick, channelName, targetNick) } } default: sender.fwrite(":server 501 %s :Unknown MODE flag\r\n", sender.nick) } } func joinChannel(c *Client, name string) { name = normalizeChannelName(name) chMu.Lock() ch, ok := channels[name] if !ok { ch = &Channel{ name: name, members: make(map[string]*Client), ops: make(map[string]bool), description: "none", } channels[name] = ch if globalOps[c.nick] { ch.ops[c.nick] = true } } if globalOps[c.nick] { ch.ops[c.nick] = true } chMu.Unlock() ch.mu.Lock() ch.members[c.nick] = c c.channels[name] = ch ch.mu.Unlock() for _, member := range ch.members { fmt.Fprintf(member.conn, ":%s JOIN %s\r\n", c.nick, name) } c.sendNames(ch) } func (c *Client) partChannel(name string) { chMu.Lock() ch, ok := channels[name] chMu.Unlock() if !ok { return } ch.mu.Lock() delete(ch.members, c.nick) delete(c.channels, name) for _, member := range ch.members { fmt.Fprintf(member.conn, ":%s PART %s\r\n", c.nick, name) } ch.mu.Unlock() //broadcastNames(ch) } func addChannel(name string, description string) { name = normalizeChannelName(name) chMu.Lock() defer chMu.Unlock() if _, ok := channels[name]; !ok { channels[name] = &Channel{ name: name, members: make(map[string]*Client), ops: make(map[string]bool), description: description, } } } func sendMessage(sender *Client, target, text string) { cliMu.Lock() if c, ok := clients[target]; ok { c.fwrite(":%s!%s@%s PRIVMSG %s :%s\r\n", sender.nick, sender.user, sender.host, target, text) cliMu.Unlock() return } cliMu.Unlock() chMu.Lock() ch, ok := channels[target] chMu.Unlock() if !ok { return } ch.mu.Lock() defer ch.mu.Unlock() for _, member := range ch.members { if member.nick != "" && member.nick != sender.nick { fmt.Fprintf(member.conn, ":%s!%s@%s PRIVMSG %s :%s\r\n", sender.nick, sender.user, sender.host, target, text) } } } func (c *Client) checkRegistration() { //log.Printf("checkRegistration called: nick=%q user=%q registered=%v", c.nick, c.user, c.registered) if c.nick != "" && c.user != "" && !c.registered { if c.saslRequired && !c.saslComplete { return } c.registered = true c.fwrite(":server 001 %s :Welcome to Gocirc, %s!\r\n", c.nick, c.nick) c.fwrite(":server 002 %s :Your host is server\r\n", c.nick) c.fwrite(":server 003 %s :This server was created just now\r\n", c.nick) c.fwrite(":server 004 %s server irc 0.1\r\n", c.nick) fmt.Fprintf(c.conn, ":server 375 %s :- Message of the Day -\r\n", c.nick) for _, i := range motdString { c.fwrite(":server 372 %s :- %s\r\n", c.nick, i) } fmt.Fprintf(c.conn, ":server 376 %s :End of MOTD\r\n", c.nick) } } func reloadMOTD() { for { time.Sleep(3 * time.Minute) motdString = []string{} loadMOTD(motdFile) } } func (c *Client) performWhois(nick string) { cliMu.RLock() target, ok := clients[nick] cliMu.RUnlock() if !ok { c.fwrite(":server 401 %s %s :No such nick/channel\r\n", c.nick, nick) return } c.fwrite(":server 311 %s %s %s %s * :%s\r\n", c.nick, target.nick, target.user, target.host, target.nick) var chans []string for name := range target.channels { chans = append(chans, normalizeChannelName(name)) } if len(chans) > 0 { c.fwrite(":server 319 %s %s :%s\r\n", c.nick, target.nick, strings.Join(chans, " ")) } c.fwrite(":server 318 %s %s :End of WHOIS list\r\n", c.nick, nick) } func (c *Client) sendUnregisteredNotice() { fmt.Fprintf(c.conn, ":server 451 * :You have not registered\r\n") } func showWho(c *Client, name string) { name = normalizeChannelName(name) chMu.Lock() ch, ok := channels[name] chMu.Unlock() if !ok { return } ch.mu.Lock() defer ch.mu.Unlock() for _, member := range ch.members { fmt.Fprintf(c.conn, ":server 352 %s %s %s server %s H :0 %s\r\n", c.nick, name, member.host, member.nick, member.nick) } fmt.Fprintf(c.conn, ":server 315 %s %s :End of WHO list\r\n", c.nick, name) } func (c *Client) handleList() { chMu.Lock() defer chMu.Unlock() // Start of list fmt.Fprintf(c.conn, ":server 321 %s Channel :Users Name\r\n", c.nick) for _, ch := range channels { ch.mu.Lock() count := len(ch.members) ch.mu.Unlock() // 322 = RPL_LIST fmt.Fprintf(c.conn, ":server 322 %s %s %d :%s\r\n", c.nick, ch.name, count, ch.description) } // End of list c.fwrite(":server 323 %s :End of /LIST\r\n", c.nick) } func nicknameExists(nick string) bool { cliMu.RLock() defer cliMu.RUnlock() _, exists := clients[nick] return exists } func (c *Client) cleanup() { cliMu.Lock() delete(clients, c.nick) cliMu.Unlock() for name, ch := range c.channels { ch.mu.Lock() delete(ch.members, c.nick) for _, member := range ch.members { member.fwrite(":%s PART %s\r\n", c.nick, name) } ch.mu.Unlock() broadcastNames(ch) delete(c.channels, name) } cliMu.RLock() for _, other := range clients { other.fwrite(":%s QUIT :Client disconnected\r\n", c.nick) } cliMu.RUnlock() } func broadcastNames(ch *Channel) { ch.mu.Lock() defer ch.mu.Unlock() var nicks []string for nick := range ch.members { if ch.ops[nick] { nicks = append(nicks, "@"+nick) } else { nicks = append(nicks, nick) } } for _, member := range ch.members { fmt.Fprintf(member.conn, ":server 353 %s = %s :%s\r\n", member.nick, ch.name, strings.Join(nicks, " ")) fmt.Fprintf(member.conn, ":server 366 %s %s :End of /NAMES list.\r\n", member.nick, ch.name) } } func sendNotice(sender *Client, target string, text string) { cliMu.RLock() defer cliMu.RUnlock() if c, ok := clients[target]; ok { c.fwrite(":%s NOTICE %s :%s\r\n", sender.nick, target, text) } } // utils func (c *Client) fwrite(format string, args ...interface{}) { fmt.Fprintf(c.conn, format, args...) } // usage: input: main return value: #main, 2nd input: #other output: #other // only prepends a # to a channel name func normalizeChannelName(name string) string { if !strings.HasPrefix(name, "#") { return "#" + name } return name } // authentication func checkCredentials(user, pass string) bool { file, err := os.Open("accounts.txt") if err != nil { return false } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { fields := strings.Fields(scanner.Text()) if len(fields) < 2 { continue } if fields[0] == user && fields[1] == pass { return true } } return scanner.Err() == nil && false }