diff --git a/conventional.yaml b/conventional.yaml index d0a01274..1a976932 100644 --- a/conventional.yaml +++ b/conventional.yaml @@ -143,6 +143,19 @@ server: # if this is true, the motd is escaped using formatting codes like $c, $b, and $i motd-formatting: true + # relaying using the RELAYMSG command + relaymsg: + # is relaymsg enabled at all? + enabled: true + + # which character(s) are reserved for relayed nicks? + separators: "/" + + # can channel operators use RELAYMSG in their channels? + # our implementation of RELAYMSG makes it safe for chanops to use without the + # possibility of real users being silently spoofed + available-to-chanops: true + # addresses/CIDRs the PROXY command can be used from # this should be restricted to localhost (127.0.0.1/8, ::1/128, and unix sockets), # unless you have a good reason. you should also add these addresses to the @@ -515,6 +528,7 @@ oper-classes: - "local_unban" - "nofakelag" - "roleplay" + - "relaymsg-anywhere" # network operator "network-oper": @@ -734,7 +748,7 @@ fakelag: roleplay: # are roleplay commands enabled at all? (channels and clients still have to # opt in individually with the +E mode) - enabled: true + enabled: false # require the "roleplay" oper capability to send roleplay messages? require-oper: false diff --git a/default.yaml b/default.yaml index c2591b47..519dc331 100644 --- a/default.yaml +++ b/default.yaml @@ -170,6 +170,19 @@ server: # if this is true, the motd is escaped using formatting codes like $c, $b, and $i motd-formatting: true + # relaying using the RELAYMSG command + relaymsg: + # is relaymsg enabled at all? + enabled: true + + # which character(s) are reserved for relayed nicks? + separators: "/" + + # can channel operators use RELAYMSG in their channels? + # our implementation of RELAYMSG makes it safe for chanops to use without the + # possibility of real users being silently spoofed + available-to-chanops: true + # addresses/CIDRs the PROXY command can be used from # this should be restricted to localhost (127.0.0.1/8, ::1/128, and unix sockets), # unless you have a good reason. you should also add these addresses to the @@ -543,6 +556,7 @@ oper-classes: - "local_unban" - "nofakelag" - "roleplay" + - "relaymsg-anywhere" # network operator "network-oper": @@ -762,7 +776,7 @@ fakelag: roleplay: # are roleplay commands enabled at all? (channels and clients still have to # opt in individually with the +E mode) - enabled: true + enabled: false # require the "roleplay" oper capability to send roleplay messages? require-oper: false diff --git a/gencapdefs.py b/gencapdefs.py index df0ca9a1..e717c0bd 100644 --- a/gencapdefs.py +++ b/gencapdefs.py @@ -93,6 +93,12 @@ CAPDEFS = [ url="https://ircv3.net/specs/extensions/multi-prefix-3.1.html", standard="IRCv3", ), + CapDef( + identifier="Relaymsg", + name="draft/relaymsg", + url="https://github.com/ircv3/ircv3-specifications/pull/417", + standard="proposed IRCv3", + ), CapDef( identifier="Rename", name="draft/rename", diff --git a/irc/caps/defs.go b/irc/caps/defs.go index 64455d48..f226cda5 100644 --- a/irc/caps/defs.go +++ b/irc/caps/defs.go @@ -7,7 +7,7 @@ package caps const ( // number of recognized capabilities: - numCapabs = 26 + numCapabs = 27 // length of the uint64 array that represents the bitset: bitsetLen = 1 ) @@ -53,6 +53,10 @@ const ( // https://github.com/ircv3/ircv3-specifications/pull/398 Multiline Capability = iota + // Relaymsg is the proposed IRCv3 capability named "draft/relaymsg": + // https://github.com/ircv3/ircv3-specifications/pull/417 + Relaymsg Capability = iota + // Rename is the proposed IRCv3 capability named "draft/rename": // https://github.com/SaberUK/ircv3-specifications/blob/rename/extensions/rename.md Rename Capability = iota @@ -131,6 +135,7 @@ var ( "draft/event-playback", "draft/languages", "draft/multiline", + "draft/relaymsg", "draft/rename", "draft/resume-0.5", "echo-message", diff --git a/irc/client_lookup_set.go b/irc/client_lookup_set.go index 3e7217ce..de10f6d3 100644 --- a/irc/client_lookup_set.go +++ b/irc/client_lookup_set.go @@ -165,6 +165,10 @@ func (clients *ClientManager) SetNick(client *Client, session *Session, newNick return "", errNicknameInvalid, false } + if config.isRelaymsgIdentifier(newNick) { + return "", errNicknameInvalid, false + } + if restrictedCasefoldedNicks.Has(newCfNick) || restrictedSkeletons.Has(newSkeleton) { return "", errNicknameInvalid, false } diff --git a/irc/commands.go b/irc/commands.go index 761e886d..1e58726d 100644 --- a/irc/commands.go +++ b/irc/commands.go @@ -252,6 +252,10 @@ func init() { minParams: 2, allowedInBatch: true, }, + "RELAYMSG": { + handler: relaymsgHandler, + minParams: 3, + }, "RENAME": { handler: renameHandler, minParams: 2, diff --git a/irc/config.go b/irc/config.go index d35af21f..e04e0e4f 100644 --- a/irc/config.go +++ b/irc/config.go @@ -501,14 +501,19 @@ type Config struct { SuppressIdent bool `yaml:"suppress-ident"` MOTD string motdLines []string - MOTDFormatting bool `yaml:"motd-formatting"` - ProxyAllowedFrom []string `yaml:"proxy-allowed-from"` - proxyAllowedFromNets []net.IPNet - WebIRC []webircConfig `yaml:"webirc"` - MaxSendQString string `yaml:"max-sendq"` - MaxSendQBytes int - AllowPlaintextResume bool `yaml:"allow-plaintext-resume"` - Compatibility struct { + MOTDFormatting bool `yaml:"motd-formatting"` + Relaymsg struct { + Enabled bool + Separators string + AvailableToChanops bool `yaml:"available-to-chanops"` + } + ProxyAllowedFrom []string `yaml:"proxy-allowed-from"` + proxyAllowedFromNets []net.IPNet + WebIRC []webircConfig `yaml:"webirc"` + MaxSendQString string `yaml:"max-sendq"` + MaxSendQBytes int + AllowPlaintextResume bool `yaml:"allow-plaintext-resume"` + Compatibility struct { ForceTrailing *bool `yaml:"force-trailing"` forceTrailing bool SendUnprefixedSasl bool `yaml:"send-unprefixed-sasl"` @@ -1106,6 +1111,17 @@ func LoadConfig(filename string) (config *Config, err error) { } config.Server.capValues[caps.Languages] = config.languageManager.CapValue() + if config.Server.Relaymsg.Enabled { + for _, char := range protocolBreakingNameCharacters { + if strings.ContainsRune(config.Server.Relaymsg.Separators, char) { + return nil, fmt.Errorf("RELAYMSG separators cannot include the characters %s", protocolBreakingNameCharacters) + } + } + config.Server.capValues[caps.Relaymsg] = config.Server.Relaymsg.Separators + } else { + config.Server.supportedCaps.Disable(caps.Relaymsg) + } + config.Debug.recoverFromErrors = utils.BoolDefaultTrue(config.Debug.RecoverFromErrors) // process operator definitions, store them to config.operators @@ -1206,6 +1222,19 @@ func (config *Config) getOutputPath(filename string) string { return filepath.Join(config.Server.OutputPath, filename) } +func (config *Config) isRelaymsgIdentifier(nick string) bool { + if !config.Server.Relaymsg.Enabled { + return false + } + + for _, char := range config.Server.Relaymsg.Separators { + if strings.ContainsRune(nick, char) { + return true + } + } + return false +} + // setISupport sets up our RPL_ISUPPORT reply. func (config *Config) generateISupport() (err error) { maxTargetsString := strconv.Itoa(maxTargets) diff --git a/irc/handlers.go b/irc/handlers.go index d5f48c52..ee2daf69 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -2013,6 +2013,16 @@ func messageHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *R if i == maxTargets { break } + + config := server.Config() + if config.isRelaymsgIdentifier(targetString) { + if histType == history.Privmsg { + rb.Add(nil, server.name, ERR_NOSUCHNICK, client.Nick(), targetString, client.t("Relayed users cannot receive private messages")) + } + // TAGMSG/NOTICEs are intentionally silently dropped + continue + } + // each target gets distinct msgids splitMsg := utils.MakeMessage(message) dispatchMessageToTarget(client, clientOnlyTags, histType, msg.Command, targetString, splitMsg, rb) @@ -2421,6 +2431,72 @@ func rehashHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re return false } +// RELAYMSG : +func relaymsgHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) (result bool) { + config := server.Config() + if !config.Server.Relaymsg.Enabled { + rb.Add(nil, server.name, "FAIL", "RELAYMSG", "NOT_ENABLED", client.t("RELAYMSG has been disabled")) + return false + } + + channel := server.channels.Get(msg.Params[0]) + if channel == nil { + rb.Add(nil, server.name, ERR_NOSUCHCHANNEL, client.Nick(), utils.SafeErrorParam(msg.Params[0]), client.t("No such channel")) + return false + } + + allowedToRelay := client.HasRoleCapabs("relaymsg-anywhere") || (config.Server.Relaymsg.AvailableToChanops && channel.ClientIsAtLeast(client, modes.ChannelOperator)) + if !allowedToRelay { + rb.Add(nil, server.name, "FAIL", "RELAYMSG", "NOT_PRIVED", client.t("You cannot relay messages to this channel")) + return false + } + + rawMessage := msg.Params[2] + if strings.TrimSpace(rawMessage) == "" { + rb.Add(nil, server.name, "FAIL", "RELAYMSG", "BLANK_MSG", client.t("The message must not be blank")) + return false + } + message := utils.MakeMessage(rawMessage) + + nick := msg.Params[1] + _, err := CasefoldName(nick) + if err != nil { + rb.Add(nil, server.name, "FAIL", "RELAYMSG", "INVALID_NICK", client.t("Invalid nickname")) + return false + } + if !config.isRelaymsgIdentifier(nick) { + rb.Add(nil, server.name, "FAIL", "RELAYMSG", "INVALID_NICK", fmt.Sprintf(client.t("Relayed nicknames MUST contain a relaymsg separator from this set: %s"), config.Server.Relaymsg.Separators)) + return false + } + + channel.AddHistoryItem(history.Item{ + Type: history.Privmsg, + Message: message, + Nick: nick, + }, "") + + // send msg + channelName := channel.Name() + relayTags := map[string]string{ + "relaymsg": client.Nick(), + } + for _, member := range channel.Members() { + for _, session := range member.Sessions() { + var tagsToUse map[string]string + if session.capabilities.Has(caps.Relaymsg) { + tagsToUse = relayTags + } + + if session == rb.session { + rb.AddSplitMessageFromClient(nick, "*", tagsToUse, "PRIVMSG", channelName, message) + } else { + session.sendSplitMsgFromClientInternal(false, nick, "*", tagsToUse, "PRIVMSG", channelName, message) + } + } + } + return false +} + // RENAME [] func renameHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { oldName, newName := msg.Params[0], msg.Params[1] diff --git a/irc/help.go b/irc/help.go index 74a734c1..3105dbd9 100644 --- a/irc/help.go +++ b/irc/help.go @@ -417,6 +417,16 @@ Replies to a PING. Used to check link connectivity.`, text: `PRIVMSG {,} Sends the text to the given targets as a PRIVMSG.`, + }, + "relaymsg": { + text: `RELAYMSG : + +This command lets channel operators relay messages to their +channel from other messaging systems using relay bots. The +spoofed nickname MUST contain a forwardslash. + +For example: + RELAYMSG #ircv3 Mallory/D :Welp, we linked Discord...`, }, "rename": { text: `RENAME [] diff --git a/irc/roleplay.go b/irc/roleplay.go index 42a87e74..fdd59584 100644 --- a/irc/roleplay.go +++ b/irc/roleplay.go @@ -82,7 +82,7 @@ func sendRoleplayMessage(server *Server, client *Client, source string, targetSt if rb.session == session { rb.AddSplitMessageFromClient(source, "", nil, "PRIVMSG", targetString, splitMessage) } else { - session.sendSplitMsgFromClientInternal(false, source, "", nil, "PRIVMSG", targetString, splitMessage) + session.sendSplitMsgFromClientInternal(false, source, "*", nil, "PRIVMSG", targetString, splitMessage) } } } @@ -108,7 +108,7 @@ func sendRoleplayMessage(server *Server, client *Client, source string, targetSt cnick := client.Nick() tnick := user.Nick() for _, session := range user.Sessions() { - session.sendSplitMsgFromClientInternal(false, source, "", nil, "PRIVMSG", tnick, splitMessage) + session.sendSplitMsgFromClientInternal(false, source, "*", nil, "PRIVMSG", tnick, splitMessage) } if away, awayMessage := user.Away(); away { //TODO(dan): possibly implement cooldown of away notifications to users diff --git a/irc/server.go b/irc/server.go index 40368fe2..4b178f18 100644 --- a/irc/server.go +++ b/irc/server.go @@ -485,6 +485,10 @@ func (server *Server) applyConfig(config *Config) (err error) { return fmt.Errorf("UTF-8 enforcement cannot be changed after launching the server, rehash aborted") } else if oldConfig.Accounts.Multiclient.AlwaysOn != config.Accounts.Multiclient.AlwaysOn { return fmt.Errorf("Default always-on setting cannot be changed after launching the server, rehash aborted") + } else if oldConfig.Server.Relaymsg.Enabled != config.Server.Relaymsg.Enabled { + return fmt.Errorf("Cannot enable or disable relaying after launching the server, rehash aborted") + } else if oldConfig.Server.Relaymsg.Separators != config.Server.Relaymsg.Separators { + return fmt.Errorf("Cannot change relaying separators after launching the server, rehash aborted") } } diff --git a/irc/strings.go b/irc/strings.go index f1f6ccf5..6c496f95 100644 --- a/irc/strings.go +++ b/irc/strings.go @@ -19,6 +19,16 @@ import ( const ( precisUTF8MappingToken = "rfc8265" + + // space can't be used + // , is used as a separator + // * is used in mask matching + // ? is used in mask matching + // . denotes a server name + // ! separates nickname from username + // @ separates username from hostname + // : means trailing + protocolBreakingNameCharacters = " ,*?.!@:" ) var ( @@ -138,18 +148,10 @@ func CasefoldName(name string) (string, error) { return "", errStringIsEmpty } - // space can't be used - // , is used as a separator - // * is used in mask matching - // ? is used in mask matching - // . denotes a server name - // ! separates nickname from username - // @ separates username from hostname - // : means trailing // # is a channel prefix // ~&@%+ are channel membership prefixes // - I feel like disallowing - if strings.ContainsAny(lowered, " ,*?.!@:") || strings.ContainsAny(string(lowered[0]), "#~&@%+-") { + if strings.ContainsAny(lowered, protocolBreakingNameCharacters) || strings.ContainsAny(string(lowered[0]), "#~&@%+-") { return "", errInvalidCharacter }