// An IRC-Client library. package ircclient import ( "bufio" "crypto/tls" "crypto/x509" "encoding/base64" "fmt" "io" "log" "math/rand" "net" "os" "os/signal" "strconv" "strings" "sync" "syscall" "time" ) // get client version information from runtime package. var VERSION string = GetModuleVersion() func GetModuleVersion() string { modules := GetModules() version, has := modules["git.red-green.com/RedGreen/irc-client"] if has { return version } return "git.red-green.com/irc-client" } // Is string in slice? func StrInArray(strings []string, str string) bool { for _, s := range strings { if s == str { return true } } return false } // IRC Message type IRCMsg struct { MsgParts []string // Message parts From string // Message From To string // Message To Cmd string // Command Msg string // Message } // Convert nick to lowercase following IRC capitalization rules. func NameLower(name string) string { // uppercase: []\- // lowercase: {}|^ var result string = strings.ToLower(name) result = strings.ReplaceAll(result, "[", "{") result = strings.ReplaceAll(result, "]", "}") result = strings.ReplaceAll(result, "\\", "|") result = strings.ReplaceAll(result, "-", "^") return result } // Check to see if nicks or channels match according to IRC rules. func Match(name1 string, name2 string) bool { return NameLower(name1) == NameLower(name2) } // Strip out the NICK part of the From message. // :NickServ!services@services.red-green.com => NickServ // IRCNick return just the NICK from :Nick!name@host func IRCNick(from string) string { if from[0] == ':' { from = from[1:] } var pos int = strings.Index(from, "!") if pos != -1 { from = from[:pos] } return from } /* IRCParse - split line into IRCMsg Everything after " :" is the Msg. Everything before " :" is split into MsgParts[]. If >= 3 MsgParts { To = MsgParts[2] } if >= 2 MsgParts { From = IrcNic(MsgParts[0]) Cmd = MsgParts[1] } else { Cmd = MsgParts[0] } Example Messages: :irc.red-green.com 001 test :Welcome to IRC ^From ^Cmd ^Msg ^To ^0 ^1 ^2 MsgParts[] PING :1234567890 ^Cmd ^Msg ^0 MsgParts[] */ // IRCParse Parse an IRC line into the IRCMsg struct. func IRCParse(line string) IRCMsg { var pos int = strings.Index(line, " :") var results IRCMsg if pos != -1 { // Message is everything after " :" results.Msg = line[pos+2:] line = line[:pos] } results.MsgParts = strings.Split(line, " ") if len(results.MsgParts) >= 2 { results.From = IRCNick(results.MsgParts[0]) results.Cmd = results.MsgParts[1] } else { results.Cmd = results.MsgParts[0] } if len(results.MsgParts) >= 3 { results.To = results.MsgParts[2] } // Translate CTCP and ACTION if results.Cmd == "PRIVMSG" { if (results.Msg[0] == '\x01') && (results.Msg[len(results.Msg)-1] == '\x01') { results.Cmd = "CTCP" results.Msg = results.Msg[1 : len(results.Msg)-1] if strings.HasPrefix(results.Msg, "ACTION ") { results.Cmd = "ACTION" results.Msg = results.Msg[7:] } } } return results } /* func oldIRCParse(line string) []string { var pos int = strings.Index(line, " :") var message string if pos != -1 { message = line[pos+2:] line = line[:pos] } var results []string results = strings.Split(line, " ") if message != "" { results = append(results, message) } return results } */ // Sent to writer // // The To part allows the writer to throttle messages to a specific target. // When throttled, it delays and alternates between the targets. type IRCWrite struct { To string // Write To Output string // Output } // Configuration type IRCConfig struct { Port int `json:"Port"` // IRC Connection port Hostname string `json:"Hostname"` // Hostname for connection UseTLS bool `json:"UseTLS"` // Use TLS Secure connection UseSASL bool `json:"UseSASL"` // Authenticate via SASL Insecure bool `json:"Insecure"` // Allow self-signed certificates Nick string `json:"Nick"` // Client's nick Username string `json:"Username"` // Client's username Realname string `json:"Realname"` // Client's realname Password string `json:"Password"` // Password for nickserv ServerPassword string `json:"ServerPassword"` // Password for server AutoJoin []string `json:"AutoJoin"` // Channels to auto-join RejoinDelay int `json:"RejoinDelay"` // ms to rejoin Version string `json:"Version"` // Version displayed Flood_Num int `json:"FloodNum"` // Number of lines sent before considered a flood Flood_Time int `json:"FloodTime"` // Number of Seconds to track previous messages Flood_Delay int `json:"FloodDelay"` // Delay between sending when flood protection on (Milliseconds) Debug_Output bool `json:"Debug"` // Enable debug output } type IRCClient struct { IRCConfig MyNick string // Client's current nick OnExit func() // Called on exit Socket net.Conn // Connection to IRCD Reader *bufio.Reader // For reading a line at a time from the IRCD ReadChannel chan IRCMsg // Channel of parsed IRC Messages ReadEvents []string // WriteChannel chan IRCWrite // Channel for writing messages to IRCD DelChannel chan string // For deleting channel or nicks that are missing. Registered bool // Registered with services? ISupport map[string]string // IRCD capabilities (005) wg sync.WaitGroup // Safe shutdown of goroutines Mutex sync.Mutex // Guards MyNick } // Get the current nick of the client func (Config *IRCClient) GetNick() string { Config.Mutex.Lock() defer Config.Mutex.Unlock() return Config.MyNick } // Sets the current nick of the client func (Config *IRCClient) SetNick(nick string) { Config.Mutex.Lock() defer Config.Mutex.Unlock() Config.MyNick = nick } // Is channel in the AutoJoin list? func (Config *IRCClient) IsAuto(channel string) bool { return StrInArray(Config.AutoJoin, channel) } // Connect to IRCD and authenticate. // // This starts the ReaderRoutine to handle processing the lines from the IRCD. // You must setup ReadChannel if you want to process any messages. func (Config *IRCClient) Connect() bool { var err error // Set sensible defaults (if not provided), if Config.Version == "" { Config.Version = VERSION } if Config.Flood_Num == 0 { Config.Flood_Num = 5 } if Config.Flood_Time == 0 { Config.Flood_Time = 10 } if Config.Flood_Delay == 0 { Config.Flood_Delay = 2000 } if Config.UseSASL { if !Config.UseTLS { log.Println("Can't UseSASL if not using UseTLS") Config.UseSASL = false } } Config.Registered = false if Config.ReadChannel == nil { log.Println("Warning: ReadChannel is nil. You can't received IRCMsg messages.") } if Config.UseTLS { var tlsConfig tls.Config if !Config.Insecure { // Use system default CA Certificates certPool := x509.NewCertPool() tlsConfig.ClientCAs = certPool tlsConfig.InsecureSkipVerify = false } else { tlsConfig.InsecureSkipVerify = true } Config.Socket, err = tls.Dial("tcp", Config.Hostname+":"+strconv.Itoa(Config.Port), &tlsConfig) if err != nil { log.Fatal("tls.Dial:", err) } Config.Reader = bufio.NewReader(Config.Socket) } else { Config.Socket, err = net.Dial("tcp", Config.Hostname+":"+strconv.Itoa(Config.Port)) if err != nil { log.Fatal("net.Dial:", err) } Config.Reader = bufio.NewReader(Config.Socket) } // WriteChannel may contain a message when we're trying to PriorityWrite from sigChannel. Config.WriteChannel = make(chan IRCWrite, 3) Config.DelChannel = make(chan string) Config.ISupport = make(map[string]string) // We are connected. go Config.WriterRoutine() Config.wg.Add(1) // Registration if Config.UseTLS && Config.UseSASL { Config.PriorityWrite("CAP REQ :sasl") } if Config.ServerPassword != "" { Config.PriorityWrite(fmt.Sprintf("PASS %s", Config.ServerPassword)) } // Register nick Config.MyNick = Config.Nick Config.PriorityWrite(fmt.Sprintf("NICK %s", Config.Nick)) Config.PriorityWrite(fmt.Sprintf("USER %s 0 * :%s", Config.Username, Config.Realname)) go Config.ReaderRoutine() Config.wg.Add(1) return true } // Write to IRCD func (Config *IRCClient) write(output string) error { var err error if Config.Debug_Output { log.Println(">>", output) } output += "\r\n" _, err = Config.Socket.Write([]byte(output)) return err } // WriterRoutine a goroutine that handles flood control func (Config *IRCClient) WriterRoutine() { var err error var throttle ThrottleBuffer var Flood FloodTrack Flood.Init(Config.Flood_Num, Config.Flood_Time) defer Config.wg.Done() throttle.Init() // signal handler var sigChannel chan os.Signal = make(chan os.Signal, 1) signal.Notify(sigChannel, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) // Change this into a select with timeout. // Timeout, if there's something to be buffered. var gotSignal bool for { if throttle.Life_sucks { select { case output := <-Config.WriteChannel: if output.To == "" { err = Config.write(output.Output) if err != nil { log.Println("Writer:", err) return } continue } throttle.push(output.To, output.Output) case <-sigChannel: if !gotSignal { gotSignal = true log.Println("SIGNAL") Config.PriorityWrite("QUIT :Received SIGINT") } continue case remove := <-Config.DelChannel: if Config.Debug_Output { log.Printf("Remove: [%s]\n", remove) } throttle.delete(remove) case <-time.After(time.Millisecond * time.Duration(Config.Flood_Delay)): var msg string = throttle.pop() err = Config.write(msg) if err != nil { log.Println("Writer:", err) } } } else { // Life is good. select { case <-sigChannel: if !gotSignal { gotSignal = true log.Println("SIGNAL") Config.PriorityWrite("QUIT :Received SIGINT") } continue case remove := <-Config.DelChannel: if Config.Debug_Output { log.Printf("Remove: [%s]\n", remove) } throttle.delete(remove) case output := <-Config.WriteChannel: if output.To == "" { err = Config.write(output.Output) if err != nil { log.Println("Writer:", err) return } continue } if Flood.Full() { throttle.push(output.To, output.Output) } else { // Flood limits not reached Flood.Save() err = Config.write(output.Output) if err != nil { log.Println("Writer:", err) } } } } } } // Shutdown the client. func (Config *IRCClient) Close() { Config.Socket.Close() Config.PriorityWrite("") if Config.OnExit != nil { Config.OnExit() } Config.wg.Wait() } // Generate random nick to change to (upon collision/433) func RandomNick(nick string) string { var result string = nick + "-" result += strconv.Itoa(rand.Intn(1000)) return result } // PriorityWrite: Send output command to server immediately. // // This is never throttled. func (Config *IRCClient) PriorityWrite(output string) { Config.WriteChannel <- IRCWrite{To: "", Output: output} } // WriteTo: Send throttled output command using "to" to throttle. func (Config *IRCClient) WriteTo(to string, output string) { Config.WriteChannel <- IRCWrite{To: to, Output: output} } // Msg: Send PRIVMSG, uses WriteTo to throttle. func (Config *IRCClient) Msg(to string, message string) { Config.WriteTo(to, fmt.Sprintf("PRIVMSG %s :%s", to, message)) } // Notice: Send NOTICE, uses WriteTo to throttle. func (Config *IRCClient) Notice(to string, message string) { Config.WriteTo(to, fmt.Sprintf("NOTICE %s :%s", to, message)) } // Action: Send PRIVMSG CTCP ACTION, uses WriteTo to throttle. func (Config *IRCClient) Action(to string, message string) { Config.WriteTo(to, fmt.Sprintf("PRIVMSG %s :\x01ACTION %s\x01", to, message)) } // Goroutine reader routine // // This reads a line at a time, delimited by '\n'. // Auto-replies to server PING. // Converts CTCP & ACTION messages to type CTCP/ACTION. // Automatically answers /VERSION and /TIME. // Handles SASL authentication. // Rejoins AutoJoin channels kicked from. func (Config *IRCClient) ReaderRoutine() { defer Config.wg.Done() var registering bool for { var line string var err error var results IRCMsg line, err = Config.Reader.ReadString('\n') if err == nil { line = strings.Trim(line, "\r\n") if Config.Debug_Output { log.Println("<<", line) } results = IRCParse(line) switch results.Cmd { case "PING": // Ping from Server Config.PriorityWrite("PONG " + results.Msg) case "005": var support string for _, support = range results.MsgParts[3:] { if strings.Contains(support, "=") { var suppart []string = strings.Split(support, "=") Config.ISupport[suppart[0]] = suppart[1] } else { Config.ISupport[support] = "" } } case "433": if !registering { // Nick already in use! var newNick string = RandomNick(Config.Nick) Config.MyNick = newNick Config.PriorityWrite("NICK " + newNick) } case "CTCP": if strings.HasPrefix(results.Msg, "PING ") { Config.WriteTo(IRCNick(results.From), fmt.Sprintf("NOTICE %s :\x01%s\x01", IRCNick(results.From), results.Msg)) } else if strings.HasPrefix(results.Msg, "VERSION") { var version string if Config.Version != "" { version = Config.Version + " " + VERSION } else { version = VERSION } Config.WriteTo(IRCNick(results.From), fmt.Sprintf("NOTICE %s :\x01VERSION %s\x01", IRCNick(results.From), version)) } else if strings.HasPrefix(results.Msg, "TIME") { var now time.Time = time.Now() Config.WriteTo(IRCNick(results.From), fmt.Sprintf("NOTICE %s :\x01TIME %s\x01", IRCNick(results.From), now.Format(time.ANSIC))) } } } else { // This is likely, 2022/04/05 10:11:41 ReadString: EOF if err != io.EOF { log.Println("ReadString:", err) } close(Config.ReadChannel) return } var msg IRCMsg = results if !Config.Debug_Output { if msg.Cmd == "ERROR" || msg.Cmd[0] == '4' || msg.Cmd[0] == '5' { // Always log errors. log.Println("<<", line) } } if msg.Cmd == "401" || msg.Cmd == "404" { // No such nick/channel log.Printf("Remove %s from buffer.", msg.MsgParts[3]) Config.DelChannel <- msg.MsgParts[3] } if !Config.Registered { // We're not registered yet // Answer the queries for SASL authentication if msg.Cmd == "CAP" && msg.MsgParts[3] == "ACK" { Config.PriorityWrite("AUTHENTICATE PLAIN") } if msg.Cmd == "CAP" && msg.MsgParts[3] == "NAK" { // SASL Authentication failed/not available. Config.PriorityWrite("CAP END") Config.UseSASL = false } // msg.Cmd == "AUTH..." if msg.MsgParts[0] == "AUTHENTICATE" && msg.MsgParts[1] == "+" { var userpass string = fmt.Sprintf("\x00%s\x00%s", Config.Nick, Config.Password) var b64 string = base64.StdEncoding.EncodeToString([]byte(userpass)) Config.PriorityWrite("AUTHENTICATE " + b64) } if msg.Cmd == "903" { // Success SASL Config.PriorityWrite("CAP END") Config.Registered = true } if msg.Cmd == "904" { // Failed SASL Config.PriorityWrite("CAP END") // Should we exit here? Config.UseSASL = false } /* 2022/04/06 19:12:11 << :NickServ!services@services.red-green.com NOTICE meow :nick, type /msg NickServ IDENTIFY password. Otherwise, */ if (msg.From == "NickServ") && (msg.Cmd == "NOTICE") { if strings.Contains(msg.Msg, "IDENTIFY") && Config.Password != "" { Config.PriorityWrite(fmt.Sprintf("NS IDENTIFY %s", Config.Password)) } } if !Config.UseSASL && (msg.Cmd == "900") { Config.Registered = true } } // This is a better way of knowing when we've identified for services if (msg.Cmd == "MODE") && (msg.To == Config.MyNick) { // This should probably be look for + and contains "r" if (msg.Msg[0] == '+') && (strings.Contains(msg.Msg, "r")) { Config.ReadChannel <- IRCMsg{Cmd: "Identified"} if len(Config.AutoJoin) > 0 { Config.PriorityWrite("JOIN " + strings.Join(Config.AutoJoin, ",")) } } } if msg.Cmd == "KICK" { // We were kicked, is channel in AutoJoin? if msg.MsgParts[3] == Config.MyNick { if Config.IsAuto(msg.To) { // Yes, we were kicked from AutoJoin channel time.AfterFunc(time.Duration(Config.RejoinDelay)*time.Millisecond, func() { Config.WriteTo(msg.To, "JOIN "+msg.To) }) } } } if Config.ReadChannel != nil { Config.ReadChannel <- msg } if (msg.Cmd == "376") || (msg.Cmd == "422") { // End MOTD, or MOTD Missing var reg IRCMsg = IRCMsg{Cmd: "EndMOTD"} Config.ReadChannel <- reg registering = true if Config.Password == "" { // We can't register with services, so join the AutJoin channels. if len(Config.AutoJoin) > 0 { Config.PriorityWrite("JOIN " + strings.Join(Config.AutoJoin, ",")) } } } if msg.Cmd == "NICK" { // Handle nick changes from the server if Match(msg.From, Config.MyNick) { Config.SetNick(msg.Msg) if Config.Debug_Output { log.Println("Nick is now:", Config.MyNick) } } } } }