From 7835d39dda5f7a1dde3056de3dd8a84913b94c14 Mon Sep 17 00:00:00 2001 From: Hubert Hirtz Date: Tue, 18 May 2021 21:26:03 +0200 Subject: Fix races conditions Refactor: - Split out reads/writes from irc.Session to irc.ChanInOut, - Message handling is now manual, messages must be passed to irc.Session.HandleMessage for its state to change, - Remove data-race-prone App.addLineNow (called from both the main eventLoop and irc loops) and add App.addStatusLine (to be called from the main event loop) and App.queueStatusLine (to be called from other goroutines). These two functions now write to both the current buffer and the home buffer, - add a irc.Typings.List function that locks the list of typings before accessing it. Changes as I went through the whole code... - CAP handling is fixed (especially CAP DEL and CAP ACK), - irc.Session now handles PREFIX, - unhandled messages are now shown, except for some rare cases where it's completely useless to show them. --- irc/session.go | 878 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 878 insertions(+) create mode 100644 irc/session.go (limited to 'irc/session.go') diff --git a/irc/session.go b/irc/session.go new file mode 100644 index 0000000..69d29aa --- /dev/null +++ b/irc/session.go @@ -0,0 +1,878 @@ +package irc + +import ( + "bytes" + "encoding/base64" + "errors" + "fmt" + "strconv" + "strings" + "time" + "unicode" + "unicode/utf8" +) + +type SASLClient interface { + Handshake() (mech string) + Respond(challenge string) (res string, err error) +} + +type SASLPlain struct { + Username string + Password string +} + +func (auth *SASLPlain) Handshake() (mech string) { + mech = "PLAIN" + return +} + +func (auth *SASLPlain) Respond(challenge string) (res string, err error) { + if challenge != "+" { + err = errors.New("unexpected challenge") + return + } + + user := []byte(auth.Username) + pass := []byte(auth.Password) + payload := bytes.Join([][]byte{user, user, pass}, []byte{0}) + res = base64.StdEncoding.EncodeToString(payload) + + return +} + +// SupportedCapabilities is the set of capabilities supported by this library. +var SupportedCapabilities = map[string]struct{}{ + "account-notify": {}, + "account-tag": {}, + "away-notify": {}, + "batch": {}, + "cap-notify": {}, + "draft/chathistory": {}, + "echo-message": {}, + "extended-join": {}, + "invite-notify": {}, + "labeled-response": {}, + "message-tags": {}, + "multi-prefix": {}, + "server-time": {}, + "sasl": {}, + "setname": {}, + "userhost-in-names": {}, +} + +// Values taken by the "@+typing=" client tag. TypingUnspec means the value or +// tag is absent. +const ( + TypingUnspec = iota + TypingActive + TypingPaused + TypingDone +) + +// User is a known IRC user (we share a channel with it). +type User struct { + Name *Prefix // the nick, user and hostname of the user if known. + AwayMsg string // the away message if the user is away, "" otherwise. +} + +// Channel is a joined channel. +type Channel struct { + Name string // the name of the channel. + Members map[*User]string // the set of members associated with their membership. + Topic string // the topic of the channel, or "" if absent. + TopicWho *Prefix // the name of the last user who set the topic. + TopicTime time.Time // the last time the topic has been changed. + Secret bool // whether the channel is on the server channel list. + + complete bool // whether this stucture is fully initialized. +} + +// SessionParams defines how to connect to an IRC server. +type SessionParams struct { + Nickname string + Username string + RealName string + + Auth SASLClient +} + +type Session struct { + out chan<- Message + closed bool + registered bool + typings *Typings // incoming typing notifications. + typingStamps map[string]time.Time // user typing instants. + + nick string + nickCf string // casemapped nickname. + user string + real string + acct string + host string + auth SASLClient + + availableCaps map[string]string + enabledCaps map[string]struct{} + + // ISUPPORT features + casemap func(string) string + chantypes string + linelen int + historyLimit int + prefixSymbols string + prefixModes string + + users map[string]*User // known users. + channels map[string]Channel // joined channels. + chBatches map[string]HistoryEvent // channel history batches being processed. + chReqs map[string]struct{} // set of targets for which history is currently requested. +} + +func NewSession(out chan<- Message, params SessionParams) *Session { + s := &Session{ + out: out, + typings: NewTypings(), + typingStamps: map[string]time.Time{}, + nick: params.Nickname, + nickCf: CasemapASCII(params.Nickname), + user: params.Username, + real: params.RealName, + auth: params.Auth, + availableCaps: map[string]string{}, + enabledCaps: map[string]struct{}{}, + casemap: CasemapRFC1459, + chantypes: "#&", + linelen: 512, + historyLimit: 100, + prefixSymbols: "@+", + prefixModes: "ov", + users: map[string]*User{}, + channels: map[string]Channel{}, + chBatches: map[string]HistoryEvent{}, + chReqs: map[string]struct{}{}, + } + + s.out <- NewMessage("CAP", "LS", "302") + s.out <- NewMessage("NICK", s.nick) + s.out <- NewMessage("USER", s.user, "0", "*", s.real) + + return s +} + +func (s *Session) Close() { + if s.closed { + return + } + s.closed = true + close(s.out) +} + +// HasCapability reports whether the given capability has been negociated +// successfully. +func (s *Session) HasCapability(capability string) bool { + _, ok := s.enabledCaps[capability] + return ok +} + +func (s *Session) Nick() string { + return s.nick +} + +// NickCf is our casemapped nickname. +func (s *Session) NickCf() string { + return s.nickCf +} + +func (s *Session) IsMe(nick string) bool { + return s.nickCf == s.casemap(nick) +} + +func (s *Session) IsChannel(name string) bool { + return strings.IndexAny(name, s.chantypes) == 0 +} + +func (s *Session) Casemap(name string) string { + return s.casemap(name) +} + +// Users returns the list of all known nicknames. +func (s *Session) Users() []string { + users := make([]string, 0, len(s.users)) + for _, u := range s.users { + users = append(users, u.Name.Name) + } + return users +} + +// Names returns the list of users in the given channel, or nil if this channel +// is not known by the session. +func (s *Session) Names(channel string) []Member { + var names []Member + if c, ok := s.channels[s.Casemap(channel)]; ok { + names = make([]Member, 0, len(c.Members)) + for u, pl := range c.Members { + names = append(names, Member{ + PowerLevel: pl, + Name: u.Name.Copy(), + }) + } + } + return names +} + +// Typings returns the list of nickname who are currently typing. +func (s *Session) Typings(target string) []string { + targetCf := s.casemap(target) + res := s.typings.List(targetCf) + for i := 0; i < len(res); i++ { + if s.IsMe(res[i]) { + res = append(res[:i], res[i+1:]...) + i-- + } else if u, ok := s.users[res[i]]; ok { + res[i] = u.Name.Name + } + } + return res +} + +func (s *Session) ChannelsSharedWith(name string) []string { + var user *User + if u, ok := s.users[s.Casemap(name)]; ok { + user = u + } else { + return nil + } + var channels []string + for _, c := range s.channels { + if _, ok := c.Members[user]; ok { + channels = append(channels, c.Name) + } + } + return channels +} + +func (s *Session) Topic(channel string) (topic string, who *Prefix, at time.Time) { + channelCf := s.Casemap(channel) + if c, ok := s.channels[channelCf]; ok { + topic = c.Topic + who = c.TopicWho + at = c.TopicTime + } + return +} + +func (s *Session) SendRaw(raw string) { + s.out <- NewMessage(raw) +} + +func (s *Session) Join(channel string) { + // TODO support keys + s.out <- NewMessage("JOIN", channel) +} + +func (s *Session) Part(channel, reason string) { + s.out <- NewMessage("PART", channel, reason) +} + +func (s *Session) ChangeTopic(channel, topic string) { + s.out <- NewMessage("TOPIC", channel, topic) +} + +func (s *Session) Quit(reason string) { + s.out <- NewMessage("QUIT", reason) +} + +func (s *Session) ChangeNick(nick string) { + s.out <- NewMessage("NICK", nick) +} + +func (s *Session) ChangeMode(channel, flags string, args []string) { + args = append([]string{channel, flags}, args...) + s.out <- NewMessage("MODE", args...) +} + +func splitChunks(s string, chunkLen int) (chunks []string) { + if chunkLen <= 0 { + return []string{s} + } + for chunkLen < len(s) { + i := chunkLen + min := chunkLen - utf8.UTFMax + for min <= i && !utf8.RuneStart(s[i]) { + i-- + } + chunks = append(chunks, s[:i]) + s = s[i:] + } + if len(s) != 0 { + chunks = append(chunks, s) + } + return +} + +func (s *Session) PrivMsg(target, content string) { + hostLen := len(s.host) + if hostLen == 0 { + hostLen = len("255.255.255.255") + } + maxMessageLen := s.linelen - + len(":!@ PRIVMSG :\r\n") - + len(s.nick) - + len(s.user) - + hostLen - + len(target) + chunks := splitChunks(content, maxMessageLen) + for _, chunk := range chunks { + s.out <- NewMessage("PRIVMSG", target, chunk) + } + targetCf := s.Casemap(target) + delete(s.typingStamps, targetCf) +} + +func (s *Session) Typing(target string) { + if !s.HasCapability("message-tags") { + return + } + targetCf := s.casemap(target) + now := time.Now() + if t, ok := s.typingStamps[targetCf]; ok && now.Sub(t).Seconds() < 3.0 { + return + } + s.typingStamps[targetCf] = now + s.out <- NewMessage("TAGMSG", target).WithTag("+typing", "active") +} + +func (s *Session) TypingStop(target string) { + if !s.HasCapability("message-tags") { + return + } + s.out <- NewMessage("TAGMSG", target).WithTag("+typing", "done") +} + +type HistoryRequest struct { + s *Session + target string + command string + bounds []string + limit int +} + +func formatTimestamp(t time.Time) string { + return fmt.Sprintf("timestamp=%04d-%02d-%02dT%02d:%02d:%02d.%03dZ", + t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond()/1e6) +} + +func (r *HistoryRequest) WithLimit(limit int) *HistoryRequest { + if limit < r.s.historyLimit { + r.limit = limit + } else { + r.limit = r.s.historyLimit + } + return r +} + +func (r *HistoryRequest) doRequest() { + if !r.s.HasCapability("draft/chathistory") { + return + } + + targetCf := r.s.casemap(r.target) + if _, ok := r.s.chReqs[targetCf]; ok { + return + } + r.s.chReqs[targetCf] = struct{}{} + + args := make([]string, 0, len(r.bounds)+3) + args = append(args, r.command) + args = append(args, r.target) + args = append(args, r.bounds...) + args = append(args, strconv.Itoa(r.limit)) + r.s.out <- NewMessage("CHATHISTORY", args...) +} + +func (r *HistoryRequest) Before(t time.Time) { + r.command = "BEFORE" + r.bounds = []string{formatTimestamp(t)} + r.doRequest() +} + +func (s *Session) NewHistoryRequest(target string) *HistoryRequest { + return &HistoryRequest{ + s: s, + target: target, + limit: s.historyLimit, + } +} + +func (s *Session) HandleMessage(msg Message) Event { + if s.registered { + return s.handleRegistered(msg) + } else { + return s.handleUnregistered(msg) + } +} + +func (s *Session) handleUnregistered(msg Message) Event { + switch msg.Command { + case "AUTHENTICATE": + if s.auth != nil { + res, err := s.auth.Respond(msg.Params[0]) + if err != nil { + s.out <- NewMessage("AUTHENTICATE", "*") + } else { + s.out <- NewMessage("AUTHENTICATE", res) + } + } + case rplLoggedin: + s.out <- NewMessage("CAP", "END") + s.acct = msg.Params[2] + s.host = ParsePrefix(msg.Params[1]).Host + case errNicklocked, errSaslfail, errSasltoolong, errSaslaborted, errSaslalready, rplSaslmechs: + s.out <- NewMessage("CAP", "END") + case "CAP": + switch msg.Params[1] { + case "LS": + var willContinue bool + var ls string + + if msg.Params[2] == "*" { + willContinue = true + ls = msg.Params[3] + } else { + willContinue = false + ls = msg.Params[2] + } + + for _, c := range ParseCaps(ls) { + s.availableCaps[c.Name] = c.Value + } + + if !willContinue { + for c := range s.availableCaps { + if _, ok := SupportedCapabilities[c]; !ok { + continue + } + s.out <- NewMessage("CAP", "REQ", c) + } + + _, ok := s.availableCaps["sasl"] + if s.auth == nil || !ok { + s.out <- NewMessage("CAP", "END") + } + } + default: + return s.handleRegistered(msg) + } + case errNicknameinuse: + s.out <- NewMessage("NICK", msg.Params[1]+"_") + case rplSaslsuccess: + // do nothing + default: + return s.handleRegistered(msg) + } + return nil +} + +func (s *Session) handleRegistered(msg Message) Event { + if id, ok := msg.Tags["batch"]; ok { + if b, ok := s.chBatches[id]; ok { + ev := s.newMessageEvent(msg) + s.chBatches[id] = HistoryEvent{ + Target: b.Target, + Messages: append(b.Messages, ev), + } + return nil + } + } + + switch msg.Command { + case rplWelcome: + s.nick = msg.Params[0] + s.nickCf = s.Casemap(s.nick) + s.registered = true + s.users[s.nickCf] = &User{Name: &Prefix{ + Name: s.nick, User: s.user, Host: s.host, + }} + if s.host == "" { + s.out <- NewMessage("WHO", s.nick) + } + return RegisteredEvent{} + case rplIsupport: + s.updateFeatures(msg.Params[1 : len(msg.Params)-1]) + case rplWhoreply: + if s.nickCf == s.Casemap(msg.Params[5]) { + s.host = msg.Params[3] + } + case "CAP": + switch msg.Params[1] { + case "ACK": + for _, c := range ParseCaps(msg.Params[2]) { + if c.Enable { + s.enabledCaps[c.Name] = struct{}{} + } else { + delete(s.enabledCaps, c.Name) + } + + if s.auth != nil && c.Name == "sasl" { + h := s.auth.Handshake() + s.out <- NewMessage("AUTHENTICATE", h) + } else if len(s.channels) != 0 && c.Name == "multi-prefix" { + // TODO merge NAMES commands + for channel := range s.channels { + s.out <- NewMessage("NAMES", channel) + } + } + } + case "NAK": + // do nothing + case "NEW": + for _, c := range ParseCaps(msg.Params[2]) { + s.availableCaps[c.Name] = c.Value + _, ok := SupportedCapabilities[c.Name] + if !ok { + continue + } + s.out <- NewMessage("CAP", "REQ", c.Name) + } + + _, ok := s.availableCaps["sasl"] + if s.acct == "" && ok { + // TODO authenticate + } + case "DEL": + for _, c := range ParseCaps(msg.Params[2]) { + delete(s.availableCaps, c.Name) + delete(s.enabledCaps, c.Name) + } + } + case "JOIN": + nickCf := s.Casemap(msg.Prefix.Name) + channelCf := s.Casemap(msg.Params[0]) + if s.IsMe(msg.Prefix.Name) { + s.channels[channelCf] = Channel{ + Name: msg.Params[0], + Members: map[*User]string{}, + } + } else if c, ok := s.channels[channelCf]; ok { + if _, ok := s.users[nickCf]; !ok { + s.users[nickCf] = &User{Name: msg.Prefix.Copy()} + } + c.Members[s.users[nickCf]] = "" + return UserJoinEvent{ + User: msg.Prefix.Name, + Channel: c.Name, + } + } + case "PART": + nickCf := s.Casemap(msg.Prefix.Name) + channelCf := s.Casemap(msg.Params[0]) + if s.IsMe(msg.Prefix.Name) { + if c, ok := s.channels[channelCf]; ok { + delete(s.channels, channelCf) + for u := range c.Members { + s.cleanUser(u) + } + return SelfPartEvent{ + Channel: c.Name, + } + } + } else if c, ok := s.channels[channelCf]; ok { + if u, ok := s.users[nickCf]; ok { + delete(c.Members, u) + s.cleanUser(u) + s.typings.Done(channelCf, nickCf) + return UserPartEvent{ + User: u.Name.Name, + Channel: c.Name, + } + } + } + case "KICK": + nickCf := s.Casemap(msg.Params[1]) + channelCf := s.Casemap(msg.Params[0]) + if s.IsMe(msg.Prefix.Name) { + if c, ok := s.channels[channelCf]; ok { + delete(s.channels, channelCf) + for u := range c.Members { + s.cleanUser(u) + } + return SelfPartEvent{ + Channel: c.Name, + } + } + } else if c, ok := s.channels[channelCf]; ok { + if u, ok := s.users[nickCf]; ok { + delete(c.Members, u) + s.cleanUser(u) + s.typings.Done(channelCf, nickCf) + return UserPartEvent{ + User: u.Name.Name, + Channel: c.Name, + } + } + } + case "QUIT": + nickCf := s.Casemap(msg.Prefix.Name) + + if u, ok := s.users[nickCf]; ok { + var channels []string + for channelCf, c := range s.channels { + if _, ok := c.Members[u]; ok { + channels = append(channels, c.Name) + delete(c.Members, u) + s.cleanUser(u) + s.typings.Done(channelCf, nickCf) + } + } + return UserQuitEvent{ + User: u.Name.Name, + Channels: channels, + } + } + case rplNamreply: + channelCf := s.Casemap(msg.Params[2]) + + if c, ok := s.channels[channelCf]; ok { + c.Secret = msg.Params[1] == "@" + + for _, name := range ParseNameReply(msg.Params[3], s.prefixSymbols) { + nickCf := s.Casemap(name.Name.Name) + + if _, ok := s.users[nickCf]; !ok { + s.users[nickCf] = &User{Name: name.Name.Copy()} + } + c.Members[s.users[nickCf]] = name.PowerLevel + } + + s.channels[channelCf] = c + } + case rplEndofnames: + channelCf := s.Casemap(msg.Params[1]) + if c, ok := s.channels[channelCf]; ok && !c.complete { + c.complete = true + s.channels[channelCf] = c + return SelfJoinEvent{ + Channel: c.Name, + } + } + case rplTopic: + channelCf := s.Casemap(msg.Params[1]) + if c, ok := s.channels[channelCf]; ok { + c.Topic = msg.Params[2] + s.channels[channelCf] = c + } + case rplTopicwhotime: + channelCf := s.Casemap(msg.Params[1]) + t, _ := strconv.ParseInt(msg.Params[3], 10, 64) + if c, ok := s.channels[channelCf]; ok { + c.TopicWho = ParsePrefix(msg.Params[2]) + c.TopicTime = time.Unix(t, 0) + s.channels[channelCf] = c + } + case rplNotopic: + channelCf := s.Casemap(msg.Params[1]) + if c, ok := s.channels[channelCf]; ok { + c.Topic = "" + s.channels[channelCf] = c + } + case "TOPIC": + channelCf := s.Casemap(msg.Params[0]) + if c, ok := s.channels[channelCf]; ok { + c.Topic = msg.Params[1] + c.TopicWho = msg.Prefix.Copy() + c.TopicTime = msg.TimeOrNow() + s.channels[channelCf] = c + return TopicChangeEvent{ + Channel: c.Name, + Topic: c.Topic, + } + } + case "PRIVMSG", "NOTICE": + targetCf := s.casemap(msg.Params[0]) + nickCf := s.casemap(msg.Prefix.Name) + s.typings.Done(targetCf, nickCf) + return s.newMessageEvent(msg) + case "TAGMSG": + nickCf := s.Casemap(msg.Prefix.Name) + targetCf := s.Casemap(msg.Params[0]) + + if s.IsMe(msg.Prefix.Name) { + // TAGMSG from self + break + } + + if t, ok := msg.Tags["+typing"]; ok { + if t == "active" { + s.typings.Active(targetCf, nickCf) + } else if t == "paused" { + s.typings.Done(targetCf, nickCf) + } else if t == "done" { + s.typings.Done(targetCf, nickCf) + } + } + case "BATCH": + batchStart := msg.Params[0][0] == '+' + id := msg.Params[0][1:] + + if batchStart && msg.Params[1] == "chathistory" { + s.chBatches[id] = HistoryEvent{Target: msg.Params[2]} + } else if b, ok := s.chBatches[id]; ok { + delete(s.chBatches, id) + delete(s.chReqs, s.Casemap(b.Target)) + return b + } + case "NICK": + nickCf := s.Casemap(msg.Prefix.Name) + newNick := msg.Params[0] + newNickCf := s.Casemap(newNick) + + if formerUser, ok := s.users[nickCf]; ok { + formerUser.Name.Name = newNick + delete(s.users, nickCf) + s.users[newNickCf] = formerUser + } else { + break + } + + if s.IsMe(msg.Prefix.Name) { + s.nick = newNick + s.nickCf = newNickCf + return SelfNickEvent{ + FormerNick: msg.Prefix.Name, + } + } else { + return UserNickEvent{ + User: msg.Params[0], + FormerNick: msg.Prefix.Name, + } + } + case "PING": + s.out <- NewMessage("PONG", msg.Params[0]) + case "ERROR": + s.Close() + case "FAIL": + return ErrorEvent{ + Severity: SeverityFail, + Code: msg.Params[1], + Message: strings.Join(msg.Params[2:], " "), + } + case "WARN": + return ErrorEvent{ + Severity: SeverityWarn, + Code: msg.Params[1], + Message: strings.Join(msg.Params[2:], " "), + } + case "NOTE": + return ErrorEvent{ + Severity: SeverityNote, + Code: msg.Params[1], + Message: strings.Join(msg.Params[2:], " "), + } + default: + if msg.IsReply() { + return ErrorEvent{ + Severity: ReplySeverity(msg.Command), + Code: msg.Command, + Message: strings.Join(msg.Params[1:], " "), + } + } + } + return nil +} + +func (s *Session) newMessageEvent(msg Message) MessageEvent { + targetCf := s.Casemap(msg.Params[0]) + ev := MessageEvent{ + User: msg.Prefix.Name, // TODO correctly casemap + Target: msg.Params[0], // TODO correctly casemap + Command: msg.Command, + Content: msg.Params[1], + Time: msg.TimeOrNow(), + } + if c, ok := s.channels[targetCf]; ok { + ev.Target = c.Name + ev.TargetIsChannel = true + } + return ev +} + +func (s *Session) cleanUser(parted *User) { + for _, c := range s.channels { + if _, ok := c.Members[parted]; ok { + return + } + } + delete(s.users, s.Casemap(parted.Name.Name)) +} + +func (s *Session) updateFeatures(features []string) { + for _, f := range features { + if f == "" || f == "-" || f == "=" || f == "-=" { + continue + } + + var ( + add bool + key string + value string + ) + + if strings.HasPrefix(f, "-") { + add = false + f = f[1:] + } else { + add = true + } + + kv := strings.SplitN(f, "=", 2) + key = strings.ToUpper(kv[0]) + if len(kv) > 1 { + value = kv[1] + } + + if !add { + // TODO support ISUPPORT negations + continue + } + + Switch: + switch key { + case "CASEMAPPING": + switch value { + case "ascii": + s.casemap = CasemapASCII + default: + s.casemap = CasemapRFC1459 + } + case "CHANTYPES": + s.chantypes = value + case "CHATHISTORY": + historyLimit, err := strconv.Atoi(value) + if err == nil { + s.historyLimit = historyLimit + } + case "LINELEN": + linelen, err := strconv.Atoi(value) + if err == nil && linelen != 0 { + s.linelen = linelen + } + case "PREFIX": + if value == "" { + s.prefixModes = "" + s.prefixSymbols = "" + } + if len(value)%2 != 0 { + break Switch + } + for i := 0; i < len(value); i++ { + if unicode.MaxASCII < value[i] { + break Switch + } + } + numPrefixes := len(value)/2 - 1 + s.prefixModes = value[1 : numPrefixes+1] + s.prefixSymbols = value[numPrefixes+2:] + } + } +} -- cgit v1.2.3