// 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" "crypto/tls" "crypto/sha256" "database/sql" "encoding/base64" "encoding/hex" "encoding/json" "flag" "fmt" "log" "net" "os" "strconv" "strings" "sync" "time" "github.com/ergochat/irc-go/ircmsg" _ "github.com/mattn/go-sqlite3" ) type Client struct { nick string user string realname string conn net.Conn host string channels map[string]*Channel monitorTargets map[string]bool registered bool saslRequired bool saslComplete bool awaitingSASLData bool } type Channel struct { name string description string members map[string]*Client ops map[string]bool history []HistoryMessage mu sync.RWMutex } type HistoryMessage struct { MsgID string Time time.Time Prefix string Command string Params []string Text string } 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"` Enableserverpasswd bool `json:"enableserverpassword"` Serverpassword string `json:"serverpassword"` 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 Enableserverpasswd bool Serverpassword string db *sql.DB ) 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 Enableserverpasswd = conf.Enableserverpasswd Serverpassword = conf.Serverpassword 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() { tlsEnabled := flag.Bool("tls", false, "Enable TLS") certFile := flag.String("cert", "", "TLS certificate file") keyFile := flag.String("key", "", "TLS key file") flag.Parse() 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) } var ln net.Listener if *tlsEnabled { if *certFile == "" || *keyFile == "" { log.Fatalf("TLS enabled but cert/key not provided") } cert, err := tls.LoadX509KeyPair(*certFile, *keyFile) if err != nil { log.Fatalf("failed to load TLS cert/key: %v", err) } tlsCfg := &tls.Config{Certificates: []tls.Certificate{cert}} ln, err = tls.Listen("tcp", fmt.Sprintf(":%d", cfg.Port), tlsCfg) if err != nil { log.Fatalf("Error listening with TLS: %v\n", err) } log.Printf("IRC server listening with TLS on port %d\n", cfg.Port) } else { 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) } sqlinitDB() 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), monitorTargets: make(map[string]bool), 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 client.nick = newNick cliMu.Lock() if oldNick != "" { delete(clients, oldNick) } clients[newNick] = client cliMu.Unlock() if oldNick != "" { notifyMonitorsOffline(oldNick) } notifyMonitorsOnline(newNick) for _, ch := range client.channels { ch.mu.Lock() if oldNick != "" { delete(ch.members, oldNick) } else { delete(ch.members, "") } ch.members[newNick] = client ch.mu.Unlock() } if oldNick != "" { for _, ch := range client.channels { broadcastNames(ch) } for _, other := range clients { if other != client { other.fwrite(":%s NICK %s\r\n", oldNick, newNick) } } } client.checkRegistration() } } case "PASS": if !Enableserverpasswd { client.fwrite(":server 421 * PASS :Server password not enabled\r\n") continue } if len(msg.Params) < 1 { client.fwrite(":server 461 * PASS :Not enough parameters\r\n") continue } if msg.Params[0] == Serverpassword { client.saslComplete = true client.saslRequired = false client.fwrite(":server NOTICE * :Server password accepted\r\n") } else { client.fwrite(":server 464 * :Password incorrect\r\n") return } 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, as well as chathistory client.fwrite(":server CAP * LS :sasl=PLAIN sasl chathistory server-time message-tags draft/chathistory batch\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 if len(msg.Params) >= 2 { client.fwrite(":server CAP * ACK :%s\r\n", msg.Params[1]) } 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.realname = msg.Params[3] 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 "INVITE": if !client.registered { client.sendUnregisteredNotice() continue } if len(msg.Params) < 2 { client.fwrite(":server 461 %s INVITE :Not enough parameters\r\n", client.nick) continue } targetNick := msg.Params[0] channelName := normalizeChannelName(msg.Params[1]) cliMu.RLock() target, ok := clients[targetNick] cliMu.RUnlock() if !ok { client.fwrite(":server 401 %s %s :No such nick\r\n", client.nick, targetNick) continue } chMu.Lock() ch, ok := channels[channelName] chMu.Unlock() if !ok { client.fwrite(":server 403 %s %s :No such channel\r\n", client.nick, channelName) continue } ch.mu.RLock() _, inChannel := ch.members[client.nick] ch.mu.RUnlock() if !inChannel { client.fwrite(":server 442 %s %s :You're not on that channel\r\n", client.nick, channelName) continue } client.fwrite(":server 341 %s %s %s\r\n", client.nick, targetNick, channelName) target.fwrite(":%s INVITE %s :%s\r\n", client.nick, targetNick, channelName) 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) } case "ISON": if len(msg.Params) < 1 { client.fwrite(":server 461 %s ISON :Not enough parameters\r\n", client.nick) continue } requested := msg.Params var online []string cliMu.RLock() for _, nick := range requested { if _, ok := clients[nick]; ok { online = append(online, nick) } } cliMu.RUnlock() client.fwrite(":server 303 %s :%s\r\n", client.nick, strings.Join(online, " ")) // ircv3 stuff case "CHATHISTORY": if len(msg.Params) < 1 { client.fwrite(":server 461 %s CHATHISTORY :Not enough parameters\r\n", client.nick) continue } subcmd := strings.ToUpper(msg.Params[0]) switch subcmd { case "LATEST", "BEFORE", "AFTER", "AROUND": if len(msg.Params) < 4 { client.fwrite(":server 461 %s CHATHISTORY :Not enough parameters\r\n", client.nick) continue } target := historyTargetName(msg.Params[1]) count, convErr := strconv.Atoi(msg.Params[3]) if convErr != nil || count <= 0 { client.fwrite(":server 461 %s CHATHISTORY :Invalid message count\r\n", client.nick) continue } client.serveHistory(subcmd, target, count, msg.Params) case "BETWEEN": if len(msg.Params) < 5 { client.fwrite(":server 461 %s CHATHISTORY :Not enough parameters\r\n", client.nick) continue } target := historyTargetName(msg.Params[1]) count, convErr := strconv.Atoi(msg.Params[4]) if convErr != nil || count <= 0 { client.fwrite(":server 461 %s CHATHISTORY :Invalid message count\r\n", client.nick) continue } client.serveHistory(subcmd, target, count, msg.Params) case "TARGETS": if len(msg.Params) < 4 { client.fwrite(":server 461 %s CHATHISTORY :Not enough parameters\r\n", client.nick) continue } count, convErr := strconv.Atoi(msg.Params[3]) if convErr != nil || count <= 0 { client.fwrite(":server 461 %s CHATHISTORY :Invalid message count\r\n", client.nick) continue } client.serveHistory(subcmd, "", count, msg.Params) default: client.fwrite(":server 400 %s CHATHISTORY :Unknown subcommand\r\n", client.nick) } case "MONITOR": if len(msg.Params) < 1 { client.fwrite(":server 461 %s MONITOR :Not enough parameters\r\n", client.nick) continue } subcmd := strings.ToUpper(msg.Params[0]) switch subcmd { case "+": for _, nick := range msg.Params[1:] { client.monitorTargets[nick] = true cliMu.RLock() if _, ok := clients[nick]; ok { client.fwrite(":server 730 %s %s :is online\r\n", client.nick, nick) } cliMu.RUnlock() } case "-": for _, nick := range msg.Params[1:] { delete(client.monitorTargets, nick) } case "C": client.monitorTargets = make(map[string]bool) case "L": var nicks []string for nick := range client.monitorTargets { nicks = append(nicks, nick) } client.fwrite(":server 732 %s :%s\r\n", client.nick, strings.Join(nicks, " ")) client.fwrite(":server 733 %s :End of MONITOR list\r\n", client.nick) default: client.fwrite(":server 461 %s MONITOR :Invalid subcommand\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 historyTargetName(name string) string { if strings.HasPrefix(name, "#") || strings.HasPrefix(name, "&") { return name } if nicknameExists(name) { return name } return name } 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 (c *Client) serveHistory(subcmd, target string, count int, params []string) { chMu.Lock() ch, isChannel := channels[target] chMu.Unlock() var lockCh func() var unlockCh func() if isChannel { lockCh = ch.mu.RLock unlockCh = ch.mu.RUnlock lockCh() defer unlockCh() } var msgs []HistoryMessage switch subcmd { case "LATEST": if isChannel && count <= len(ch.history) { msgs = ch.history[len(ch.history)-count:] } else { extra := count if isChannel { extra = count - len(ch.history) } rows, err := db.Query(`SELECT msgid, timestamp, prefix, command, msg FROM messages WHERE channel=? ORDER BY timestamp DESC LIMIT ?`, target, extra) if err != nil { c.fwrite(":server 718 %s %s :No history available\r\n", c.nick, target) return } defer rows.Close() dbMsgs := scanRows(rows, target) if isChannel { msgs = append(dbMsgs, ch.history...) } else { for i, j := 0, len(dbMsgs)-1; i < j; i, j = i+1, j-1 { dbMsgs[i], dbMsgs[j] = dbMsgs[j], dbMsgs[i] } msgs = dbMsgs } } case "BEFORE": if len(params) < 4 { c.fwrite(":server 461 %s CHATHISTORY :Not enough parameters\r\n", c.nick) return } msgid := strings.TrimPrefix(params[2], "msgid=") rows, err := db.Query(`SELECT msgid, timestamp, prefix, command, msg FROM messages WHERE channel=? AND msgid < ? ORDER BY timestamp DESC LIMIT ?`, target, msgid, count) if err != nil { c.fwrite(":server 718 %s %s :No history available\r\n", c.nick, target) return } defer rows.Close() msgs = scanRows(rows, target) case "BETWEEN": if len(params) < 5 { c.fwrite(":server 461 %s CHATHISTORY :Not enough parameters\r\n", c.nick) return } start := strings.TrimPrefix(params[2], "msgid=") end := strings.TrimPrefix(params[3], "msgid=") rows, err := db.Query(`SELECT msgid, timestamp, prefix, command, msg FROM messages WHERE channel=? AND msgid BETWEEN ? AND ? ORDER BY timestamp ASC LIMIT ?`, target, start, end, count) if err != nil { c.fwrite(":server 718 %s %s :No history available\r\n", c.nick, target) return } defer rows.Close() msgs = scanRows(rows, target) case "AFTER": if len(params) < 4 { c.fwrite(":server 461 %s CHATHISTORY :Not enough parameters\r\n", c.nick) return } arg := params[2] var rows *sql.Rows var err error if strings.HasPrefix(arg, "msgid=") { msgid := strings.TrimPrefix(arg, "msgid=") rows, err = db.Query(`SELECT msgid, timestamp, prefix, command, msg FROM messages WHERE channel=? AND msgid > ? ORDER BY timestamp ASC LIMIT ?`, target, msgid, count) } else if strings.HasPrefix(arg, "timestamp=") { tsStr := strings.TrimPrefix(arg, "timestamp=") t, parseErr := time.Parse(time.RFC3339Nano, tsStr) if parseErr != nil { c.fwrite(":server 461 %s CHATHISTORY :Invalid timestamp\r\n", c.nick) return } rows, err = db.Query(`SELECT msgid, timestamp, prefix, command, msg FROM messages WHERE channel=? AND timestamp > ? ORDER BY timestamp ASC LIMIT ?`, target, t.UnixNano(), count) } else { c.fwrite(":server 461 %s CHATHISTORY :Invalid anchor parameter\r\n", c.nick) return } if err != nil { c.fwrite(":server 718 %s %s :No history available\r\n", c.nick, target) return } defer rows.Close() msgs = scanRows(rows, target) case "TARGETS": if len(params) < 4 { c.fwrite(":server 461 %s CHATHISTORY :Not enough parameters\r\n", c.nick) return } // Parse start and end timestamps startStr := strings.TrimPrefix(params[1], "timestamp=") endStr := strings.TrimPrefix(params[2], "timestamp=") startTime, err1 := time.Parse(time.RFC3339Nano, startStr) endTime, err2 := time.Parse(time.RFC3339Nano, endStr) if err1 != nil || err2 != nil { c.fwrite(":server 461 %s CHATHISTORY :Invalid timestamp\r\n", c.nick) return } rows, err := db.Query(`SELECT msgid, timestamp, prefix, command, msg FROM messages WHERE timestamp BETWEEN ? AND ? ORDER BY timestamp ASC LIMIT ?`, startTime.UnixNano(), endTime.UnixNano(), count) if err != nil { c.fwrite(":server 718 %s * :No history available\r\n", c.nick) return } defer rows.Close() msgs = scanRows(rows, "") case "AROUND": if len(params) < 4 { c.fwrite(":server 461 %s CHATHISTORY :Not enough parameters\r\n", c.nick) return } msgid := strings.TrimPrefix(params[2], "msgid=") half := count / 2 beforeRows, _ := db.Query(`SELECT msgid, timestamp, prefix, command, msg FROM messages WHERE channel=? AND msgid < ? ORDER BY timestamp DESC LIMIT ?`, target, msgid, half) beforeMsgs := scanRows(beforeRows, target) anchor, err := findMsgIndex(target, msgid) var anchorMsg []HistoryMessage if err == nil && anchor != nil { anchorMsg = []HistoryMessage{*anchor} } afterRows, _ := db.Query(`SELECT msgid, timestamp, prefix, command, msg FROM messages WHERE channel=? AND msgid > ? ORDER BY timestamp ASC LIMIT ?`, target, msgid, half) afterMsgs := scanRows(afterRows, target) msgs = append(beforeMsgs, anchorMsg...) msgs = append(msgs, afterMsgs...) } batchID := fmt.Sprintf("hist-%d", time.Now().UnixNano()) c.fwrite(":server BATCH +%s chathistory %s\r\n", batchID, target) for _, m := range msgs { tags := fmt.Sprintf("@msgid=%s;time=%s", m.MsgID, m.Time.UTC().Format(time.RFC3339Nano)) c.fwrite("%s :%s %s %s :%s\r\n", tags, m.Prefix, m.Command, strings.Join(m.Params, " "), m.Text) } c.fwrite(":server BATCH -%s\r\n", batchID) } func findMsgIndex(channel, msgid string) (*HistoryMessage, error) { row := db.QueryRow(`SELECT msgid, timestamp, prefix, command, msg FROM messages WHERE channel=? AND msgid=?`, channel, msgid) var m HistoryMessage var tsInt int64 err := row.Scan(&m.MsgID, &tsInt, &m.Prefix, &m.Command, &m.Text) if err != nil { return nil, err } m.Time = time.Unix(0, tsInt).UTC() m.Params = []string{channel} return &m, nil } 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] && !globalOps[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] && !globalOps[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) // get or create channel 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 } chMu.Unlock() ch.mu.Lock() // reject duplicate join if _, exists := ch.members[c.nick]; exists { ch.mu.Unlock() c.fwrite(":server 443 %s %s %s :is already on channel\r\n", c.nick, c.nick, name) return } // clean up stale empty nick delete(ch.members, "") // add new member ch.members[c.nick] = c c.channels[name] = ch ch.mu.Unlock() fmt.Fprintf(c.conn, ":%s JOIN %s\r\n", c.nick, name) ch.mu.RLock() members := make([]*Client, 0, len(ch.members)) for _, m := range ch.members { members = append(members, m) } ch.mu.RUnlock() for _, member := range members { if member != c { fmt.Fprintf(member.conn, ":%s JOIN %s\r\n", c.nick, name) } } if ch.description != "" { c.fwrite(":server 332 %s %s :%s\r\n", c.nick, name, ch.description) } else { c.fwrite(":server 331 %s %s :No topic is set\r\n", c.nick, name) } c.sendNames(ch) if ch.ops[c.nick] { for _, member := range members { member.fwrite(":server MODE %s +o %s\r\n", name, c.nick) } } } func (c *Client) partChannel(name string) { name = normalizeChannelName(name) 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() storeDirectMessage(sender, target, text) return } cliMu.Unlock() chMu.Lock() ch, ok := channels[target] chMu.Unlock() if !ok { sender.fwrite(":server 401 %s %s :No such nick/channel\r\n", sender.nick, target) return } ch.mu.RLock() _, member := ch.members[sender.nick] ch.mu.RUnlock() if !member { sender.fwrite(":server 404 %s %s :Cannot send to channel\r\n", sender.nick, target) 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) } } storeMessage(ch, sender, target, text) } func (c *Client) checkRegistration() { if c.nick != "" && c.user != "" && !c.registered { if !c.saslComplete { c.fwrite(":server 464 %s :You must authenticate before registering\r\n", c.nick) return } c.registered = true notifyMonitorsOnline(c.nick) 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 has %d users right now\r\n", c.nick, len(clients)) 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) if motdString != nil { for _, i := range motdString { c.fwrite(":server 372 %s :- %s\r\n", c.nick, i) } } else { c.fwrite("%s :MOTD File is missing", c.nick) } c.fwrite(":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() { c.fwrite(":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 { c.fwrite(":server 352 %s %s %s server %s H :0 %s\r\n", c.nick, name, member.host, member.nick, member.nick) } c.fwrite(":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 storeMessage(ch *Channel, sender *Client, target, text string) { msg := HistoryMessage{ MsgID: fmt.Sprintf("%019d", time.Now().UnixNano()), Time: time.Now(), Prefix: fmt.Sprintf("%s!%s@%s", sender.nick, sender.user, sender.host), Command: "PRIVMSG", Params: []string{target}, Text: text, } ch.history = append(ch.history, msg) if len(ch.history) > 1000 { ch.history = ch.history[1:] // drop oldest } persistMessage(msg, sender.nick, target) } func storeDirectMessage(sender *Client, target, text string) { msg := HistoryMessage{ MsgID: fmt.Sprintf("%019d", time.Now().UnixNano()), Time: time.Now(), Prefix: fmt.Sprintf("%s!%s@%s", sender.nick, sender.user, sender.host), Command: "PRIVMSG", Params: []string{target}, Text: text, } persistMessage(msg, sender.nick, target) } func persistMessage(msg HistoryMessage, nick, target string) { _, err := db.Exec(`INSERT INTO messages(msgid, nick, timestamp, channel, prefix, command, msg) VALUES(?, ?, ?, ?, ?, ?, ?)`, msg.MsgID, nick, msg.Time.UnixNano(), target, msg.Prefix, msg.Command, msg.Text, ) if err != nil { log.Printf("persistMessage error: %v", err) } } 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() notifyMonitorsOffline(c.nick) for name, ch := range c.channels { ch.mu.Lock() delete(ch.members, c.nick) ch.mu.Unlock() ch.mu.RLock() snapshot := make([]*Client, 0, len(ch.members)) for _, member := range ch.members { snapshot = append(snapshot, member) } ch.mu.RUnlock() for _, member := range snapshot { if member.nick != c.nick { member.fwrite(":%s PART %s\r\n", c.nick, name) } } delete(c.channels, name) } cliMu.RLock() snapshot := make([]*Client, 0, len(clients)) for _, other := range clients { snapshot = append(snapshot, other) } cliMu.RUnlock() for _, other := range snapshot { other.fwrite(":%s QUIT :Client disconnected\r\n", c.nick) } } 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 ...any) { 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 { log.Printf("open error: %v", err) return false } defer file.Close() passHash := sha256.Sum256([]byte(pass)) passHex := hex.EncodeToString(passHash[:]) scanner := bufio.NewScanner(file) for scanner.Scan() { fields := strings.Fields(scanner.Text()) if len(fields) < 2 { continue } if fields[0] == user && fields[1] == passHex { return true } } if err := scanner.Err(); err != nil { log.Printf("checkCredentials: scanner error: %v", err) } return false } // database stuff func sqlinitDB() error { var err error db, err = sql.Open("sqlite3", "irc.db") if err != nil { return err } stmt := `CREATE TABLE IF NOT EXISTS messages( msgid TEXT PRIMARY KEY, nick TEXT, timestamp INTEGER, channel TEXT, prefix TEXT, command TEXT, msg TEXT )` _, err = db.Exec(stmt) return err } func scanRows(rows *sql.Rows, channel string) []HistoryMessage { var msgs []HistoryMessage for rows.Next() { var m HistoryMessage var tsInt int64 if err := rows.Scan(&m.MsgID, &tsInt, &m.Prefix, &m.Command, &m.Text); err != nil { continue } m.Time = time.Unix(0, tsInt).UTC() m.Params = []string{channel} msgs = append(msgs, m) } return msgs } // IRC MONITOR command stuff func notifyMonitorsOnline(nick string) { cliMu.RLock() defer cliMu.RUnlock() for _, c := range clients { if c.monitorTargets[nick] { c.fwrite(":server 730 %s %s :is online\r\n", c.nick, nick) } } } func notifyMonitorsOffline(nick string) { cliMu.RLock() defer cliMu.RUnlock() for _, c := range clients { if c.monitorTargets[nick] { c.fwrite(":server 731 %s %s :is offline\r\n", c.nick, nick) } } }