diff --git a/default.yaml b/default.yaml index 7a62f227..6b95891f 100644 --- a/default.yaml +++ b/default.yaml @@ -369,7 +369,7 @@ server: # in a "closed-loop" system where you control the server and all the clients, # you may want to increase the maximum (non-tag) length of an IRC line from # the default value of 512. DO NOT change this on a public server: - #max-line-len: 512 + max-line-len: 2048 # send all 0's as the LUSERS (user counts) output to non-operators; potentially useful # if you don't want to publicize how popular the server is diff --git a/irc/caps/defs.go b/irc/caps/defs.go index 18ff5759..e035c541 100644 --- a/irc/caps/defs.go +++ b/irc/caps/defs.go @@ -7,7 +7,7 @@ package caps const ( // number of recognized capabilities: - numCapabs = 34 + numCapabs = 35 // length of the uint32 array that represents the bitset: bitsetLen = 2 ) @@ -148,6 +148,8 @@ const ( // ZNCSelfMessage is the ZNC vendor capability named "znc.in/self-message": // https://wiki.znc.in/Query_buffers ZNCSelfMessage Capability = iota + + ExtendedNames Capability = iota ) // `capabilityNames[capab]` is the string name of the capability `capab` @@ -187,5 +189,6 @@ var ( "userhost-in-names", "znc.in/playback", "znc.in/self-message", + "cef/extended-names", } ) diff --git a/irc/cef.go b/irc/cef.go new file mode 100644 index 00000000..ec420e93 --- /dev/null +++ b/irc/cef.go @@ -0,0 +1,124 @@ +package irc + +import ( + "bufio" + "fmt" + "github.com/ergochat/ergo/irc/caps" + "io" + "net" + "os" + "strings" +) + +const PORT = "127.0.0.1:22843" + +type CefConnection struct { + connections map[string]net.Conn + server *Server +} + +func (instance *CefConnection) CEFMessage(action string, extra ...string) { + if instance == nil { + return + } + str := fmt.Sprintf("%s %s\n", action, strings.Join(extra, " ")) + for _, conn := range instance.connections { + conn.Write([]byte(str)) + } +} + +func (instance *CefConnection) KickBroadcast(channel string, user string) { + instance.CEFMessage("KICK", channel, user) +} + +func cefConnection(server *Server) *CefConnection { + // create a tcp listener on the given port + listener, err := net.Listen("tcp", PORT) + if err != nil { + fmt.Println("Unable to open CefConnection listener:", err) + os.Exit(1) + } + fmt.Printf("CefConnection listener on %s active\n", PORT) + instance := CefConnection{connections: make(map[string]net.Conn), server: server} + go cefListener(listener, &instance) + + return &instance +} + +func cefListener(listener net.Listener, instance *CefConnection) { + // listen for new connections + for { + conn, err := listener.Accept() + if err != nil { + fmt.Println("failed to accept cef connection, err:", err) + continue + } + instance.connections[conn.RemoteAddr().String()] = conn + + // pass an accepted connection to a handler goroutine + go handleConnection(conn, instance) + } +} + +func handleConnection(conn net.Conn, instance *CefConnection) { + defer delete(instance.connections, conn.RemoteAddr().String()) + reader := bufio.NewReader(conn) + println("Connection with CEF service established") + for { + // read client request data + bytes, err := reader.ReadBytes(byte('\n')) + if err != nil { + if err != io.EOF { + fmt.Println("failed to read data, err:", err) + } + return + } + convertedLine := string(bytes[:len(bytes)-1]) + line := strings.Split(strings.Trim(convertedLine, " \n"), " ") + fmt.Printf("cef: %+q\n", line) + switch line[0] { + case "PART": + if len(line) == 1 || len(line[1]) == 0 { + println("skipping malformed line") + continue + } + channel := instance.server.channels.Get(line[1]) + for _, member := range channel.Members() { + for _, session := range member.Sessions() { + session.Send(nil, member.server.name, "VOICEPART", line[1], line[2]) + } + } + break + case "VOICESTATE": + channel := instance.server.channels.Get(line[1]) + for _, member := range channel.Members() { + for _, session := range member.Sessions() { + session.Send(nil, member.server.name, "VOICESTATE", line[1], line[2], line[3], line[4]) + } + } + break + case "BROADCASTAS": + // TODO: global broadcast + user := instance.server.clients.Get(line[1]) + if user != nil { + // I'm not too sure what the capability bit is, I think it's just ones that match + for friend := range user.Friends(caps.ExtendedNames) { + friend.Send(nil, user.NickMaskString(), line[2], line[3:]...) + } + } + break + case "BROADCASTTO": + channel := instance.server.channels.Get(line[1]) + if channel != nil { + // I'm not too sure what the capability bit is, I think it's just ones that match + for _, person := range channel.Members() { + person.Send(nil, instance.server.name, line[2], line[3:]...) + } + } + break + default: + println("Unknown cef message: ", line[0]) + } + + } +} diff --git a/irc/channel.go b/irc/channel.go index 664ea14d..0326c8b1 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -477,6 +477,12 @@ func (channel *Channel) Names(client *Client, rb *ResponseBuffer) { if respectAuditorium && memberData.modes.HighestChannelUserMode() == modes.Mode(0) { continue } + if rb.session.capabilities.Has(caps.ExtendedNames) { + away, _ := target.Away() + if away { + nick = nick + "*" + } + } tl.AddParts(memberData.modes.Prefixes(isMultiPrefix), nick) } } @@ -795,6 +801,8 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp } client.server.logger.Debug("channels", fmt.Sprintf("%s joined channel %s", details.nick, chname)) + // I think this is assured to always be a good join point + client.server.cefManager.CEFMessage("POLL", channel.NameCasefolded()) givenMode := func() (givenMode modes.Mode) { channel.joinPartMutex.Lock() @@ -991,6 +999,7 @@ func (channel *Channel) playJoinForSession(session *Session) { channel.Names(client, sessionRb) } sessionRb.Send(false) + client.server.cefManager.CEFMessage("POLL", channel.NameCasefolded()) } // Part parts the given client from this channel, with the given message. @@ -1450,6 +1459,7 @@ func (channel *Channel) Quit(client *Client) { client.server.channels.Cleanup(channel) } client.removeChannel(channel) + client.server.cefManager.KickBroadcast(channel.name, client.Username()) } func (channel *Channel) Kick(client *Client, target *Client, comment string, rb *ResponseBuffer, hasPrivs bool) { @@ -1513,7 +1523,7 @@ func (channel *Channel) Purge(source string) { now := time.Now().UTC() for _, member := range members { tnick := member.Nick() - msgid := utils.GenerateSecretToken() + msgid := utils.GenerateMessageIdStr() for _, session := range member.Sessions() { session.sendFromClientInternal(false, now, msgid, source, "*", false, nil, "KICK", chname, tnick, member.t("This channel has been purged by the server administrators and cannot be used")) } diff --git a/irc/chanserv.go b/irc/chanserv.go index a8360c03..4ffd20a8 100644 --- a/irc/chanserv.go +++ b/irc/chanserv.go @@ -753,6 +753,7 @@ func csListHandler(service *ircService, server *Server, client *Client, command } func csInfoHandler(service *ircService, server *Server, client *Client, command string, params []string, rb *ResponseBuffer) { + if len(params) == 0 { // #765 listRegisteredChannels(service, client.Account(), rb) @@ -765,37 +766,41 @@ func csInfoHandler(service *ircService, server *Server, client *Client, command return } - // purge status - if client.HasRoleCapabs("chanreg") { - purgeRecord, err := server.channels.LoadPurgeRecord(chname) - if err == nil { - service.Notice(rb, fmt.Sprintf(client.t("Channel %s was purged by the server operators and cannot be used"), chname)) - service.Notice(rb, fmt.Sprintf(client.t("Purged by operator: %s"), purgeRecord.Oper)) - service.Notice(rb, fmt.Sprintf(client.t("Purged at: %s"), purgeRecord.PurgedAt.Format(time.RFC1123))) - if purgeRecord.Reason != "" { - service.Notice(rb, fmt.Sprintf(client.t("Purge reason: %s"), purgeRecord.Reason)) - } - } - } else { - if server.channels.IsPurged(chname) { - service.Notice(rb, fmt.Sprintf(client.t("Channel %s was purged by the server operators and cannot be used"), chname)) - } - } - var chinfo RegisteredChannel channel := server.channels.Get(params[0]) if channel != nil { chinfo = channel.exportSummary() } + tags := map[string]string{ + "target": chinfo.Name, + } + + // purge status + if client.HasRoleCapabs("chanreg") { + purgeRecord, err := server.channels.LoadPurgeRecord(chname) + if err == nil { + service.TaggedNotice(rb, fmt.Sprintf(client.t("Channel %s was purged by the server operators and cannot be used"), chname), tags) + service.TaggedNotice(rb, fmt.Sprintf(client.t("Purged by operator: %s"), purgeRecord.Oper), tags) + service.TaggedNotice(rb, fmt.Sprintf(client.t("Purged at: %s"), purgeRecord.PurgedAt.Format(time.RFC1123)), tags) + if purgeRecord.Reason != "" { + service.TaggedNotice(rb, fmt.Sprintf(client.t("Purge reason: %s"), purgeRecord.Reason), tags) + } + } + } else { + if server.channels.IsPurged(chname) { + service.TaggedNotice(rb, fmt.Sprintf(client.t("Channel %s was purged by the server operators and cannot be used"), chname), tags) + } + } + // channel exists but is unregistered, or doesn't exist: if chinfo.Founder == "" { - service.Notice(rb, fmt.Sprintf(client.t("Channel %s is not registered"), chname)) + service.TaggedNotice(rb, fmt.Sprintf(client.t("Channel %s is not registered"), chname), tags) return } - service.Notice(rb, fmt.Sprintf(client.t("Channel %s is registered"), chinfo.Name)) - service.Notice(rb, fmt.Sprintf(client.t("Founder: %s"), chinfo.Founder)) - service.Notice(rb, fmt.Sprintf(client.t("Registered at: %s"), chinfo.RegisteredAt.Format(time.RFC1123))) + service.TaggedNotice(rb, fmt.Sprintf(client.t("Channel %s is registered"), chinfo.Name), tags) + service.TaggedNotice(rb, fmt.Sprintf(client.t("Founder: %s"), chinfo.Founder), tags) + service.TaggedNotice(rb, fmt.Sprintf(client.t("Registered at: %s"), chinfo.RegisteredAt.Format(time.RFC1123)), tags) } func displayChannelSetting(service *ircService, settingName string, settings ChannelSettings, client *Client, rb *ResponseBuffer) { diff --git a/irc/client.go b/irc/client.go index 218f4d3d..b51d524a 100644 --- a/irc/client.go +++ b/irc/client.go @@ -1436,6 +1436,10 @@ func composeMultilineBatch(batchID, fromNickMask, fromAccount string, isBot bool for _, msg := range message.Split { message := ircmsg.MakeMessage(nil, fromNickMask, command, target, msg.Message) message.SetTag("batch", batchID) + for k, v := range msg.Tags { + message.SetTag(k, v) + } + if msg.Concat { message.SetTag(caps.MultilineConcatTag, "") } diff --git a/irc/constants.go b/irc/constants.go index 975e9953..cf6b257c 100644 --- a/irc/constants.go +++ b/irc/constants.go @@ -8,7 +8,7 @@ package irc const ( // maxLastArgLength is used to simply cap off the final argument when creating general messages where we need to select a limit. // for instance, in MONITOR lists, RPL_ISUPPORT lists, etc. - maxLastArgLength = 400 + maxLastArgLength = 1024 // maxTargets is the maximum number of targets for PRIVMSG and NOTICE. maxTargets = 4 ) diff --git a/irc/getters.go b/irc/getters.go index abedb246..b6d1642b 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -410,7 +410,11 @@ func (client *Client) SetMode(mode modes.Mode, on bool) bool { func (client *Client) SetRealname(realname string) { client.stateMutex.Lock() + // TODO: make this configurable client.realname = realname + if len(realname) > 64 { + client.realname = client.realname[:64] + } alwaysOn := client.registered && client.alwaysOn client.stateMutex.Unlock() if alwaysOn { diff --git a/irc/handlers.go b/irc/handlers.go index 0f264a87..2af9b038 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -1128,6 +1128,12 @@ func extjwtHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo } claims["channel"] = channel.Name() + + var channelModeStrings []string + for _, mode := range channel.flags.AllModes() { + channelModeStrings = append(channelModeStrings, mode.String()) + } + claims["chanModes"] = channelModeStrings claims["joined"] = 0 claims["cmodes"] = []string{} if present, joinTimeSecs, cModes := channel.ClientStatus(client); present { @@ -2234,7 +2240,7 @@ func absorbBatchedMessage(server *Server, client *Client, msg ircmsg.Message, ba if !isConcat && len(rb.session.batch.message.Split) != 0 { rb.session.batch.lenBytes++ // bill for the newline } - rb.session.batch.message.Append(msg.Params[1], isConcat) + rb.session.batch.message.Append(msg.Params[1], isConcat, msg.ClientOnlyTags()) rb.session.batch.lenBytes += len(msg.Params[1]) config := server.Config() if config.Limits.Multiline.MaxBytes < rb.session.batch.lenBytes { @@ -2317,6 +2323,41 @@ func messageHandler(server *Server, client *Client, msg ircmsg.Message, rb *Resp return false } +// Not really sure how to do this in Go +var endChars = map[int32]bool{ + ' ': true, + '@': true, + ':': true, + '!': true, + '?': true, +} + +func detectMentions(message string) (mentions []string) { + buf := "" + mentions = []string{} + working := false + for _, char := range message { + if char == '@' { + working = true + continue + } + if !working { + continue + } + if _, stop := endChars[char]; stop { + working = false + mentions = append(mentions, buf) + buf = "" + } else { + buf += string(char) + } + } + if len(buf) != 0 { + mentions = append(mentions, buf) + } + return +} + func dispatchMessageToTarget(client *Client, tags map[string]string, histType history.ItemType, command, target string, message utils.SplitMessage, rb *ResponseBuffer) { server := client.server @@ -2333,7 +2374,15 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi } return } + // This likely isn't that great for performance. Should figure out some way to deal with this at some point + mentions := detectMentions(message.Message) channel.SendSplitMessage(command, lowestPrefix, tags, client, message, rb) + for _, mention := range mentions { + user := client.server.clients.Get(mention) + if user != nil { + client.server.cefManager.CEFMessage("MENTION", user.nickCasefolded, channel.Name(), message.Msgid) + } + } } else if target[0] == '$' && len(target) > 2 && client.Oper().HasRoleCapab("massmessage") { details := client.Details() matcher, err := utils.CompileGlob(target[2:], false) @@ -2456,7 +2505,9 @@ func dispatchMessageToTarget(client *Client, tags map[string]string, histType hi Message: message, Tags: tags, } + client.addHistoryItem(user, item, &details, &tDetails, config) + client.server.cefManager.CEFMessage("MENTION", user.nickCasefolded, client.nick, message.Msgid) } } @@ -2745,7 +2796,7 @@ func redactHandler(server *Server, client *Client, msg ircmsg.Message, rb *Respo var members []*Client // members of a channel, or both parties of a PM var canDelete CanDelete - msgid := utils.GenerateSecretToken() + msgid := utils.GenerateMessageIdStr() time := time.Now().UTC().Round(0) details := client.Details() isBot := client.HasMode(modes.Bot) diff --git a/irc/jwt/extjwt.go b/irc/jwt/extjwt.go index ea764667..bc352171 100644 --- a/irc/jwt/extjwt.go +++ b/irc/jwt/extjwt.go @@ -49,7 +49,7 @@ func (t *JwtServiceConfig) Enabled() bool { func (t *JwtServiceConfig) Sign(claims MapClaims) (result string, err error) { claims["exp"] = time.Now().Unix() + int64(t.Expiration/time.Second) - + claims["now"] = time.Now().Unix() if t.rsaPrivateKey != nil { token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(claims)) return token.SignedString(t.rsaPrivateKey) diff --git a/irc/mysql/serialization.go b/irc/mysql/serialization.go index 5226a1dd..c864495e 100644 --- a/irc/mysql/serialization.go +++ b/irc/mysql/serialization.go @@ -4,7 +4,6 @@ import ( "encoding/json" "github.com/ergochat/ergo/irc/history" - "github.com/ergochat/ergo/irc/utils" ) // 123 / '{' is the magic number that means JSON; @@ -18,6 +17,7 @@ func unmarshalItem(data []byte, result *history.Item) (err error) { return json.Unmarshal(data, result) } +// TODO: probably should convert the internal mysql column to uint func decodeMsgid(msgid string) ([]byte, error) { - return utils.B32Encoder.DecodeString(msgid) + return []byte(msgid), nil } diff --git a/irc/responsebuffer.go b/irc/responsebuffer.go index 6dfeade8..85d5fd3c 100644 --- a/irc/responsebuffer.go +++ b/irc/responsebuffer.go @@ -142,7 +142,14 @@ func (rb *ResponseBuffer) AddSplitMessageFromClient(fromNickMask string, fromAcc if i == 0 { msgid = message.Msgid } - rb.AddFromClient(message.Time, msgid, fromNickMask, fromAccount, isBot, tags, command, target, messagePair.Message) + mergedTags := make(map[string]string) + for k, v := range tags { + mergedTags[k] = v + } + for k, v := range messagePair.Tags { + mergedTags[k] = v + } + rb.AddFromClient(message.Time, msgid, fromNickMask, fromAccount, isBot, mergedTags, command, target, messagePair.Message) } } } diff --git a/irc/server.go b/irc/server.go index eef47c2c..4a9fe1ea 100644 --- a/irc/server.go +++ b/irc/server.go @@ -96,6 +96,9 @@ type Server struct { semaphores ServerSemaphores flock flock.Flocker defcon atomic.Uint32 + + // CEF + cefManager *CefConnection } // NewServer returns a new Oragono server. @@ -163,6 +166,8 @@ func (server *Server) Shutdown() { func (server *Server) Run() { defer server.Shutdown() + server.cefManager = cefConnection(server) + for { select { case <-server.exitSignals: diff --git a/irc/services.go b/irc/services.go index 7bd9c5f1..f06181bd 100644 --- a/irc/services.go +++ b/irc/services.go @@ -107,6 +107,10 @@ func (service *ircService) Notice(rb *ResponseBuffer, text string) { rb.Add(nil, service.prefix, "NOTICE", rb.target.Nick(), text) } +func (service *ircService) TaggedNotice(rb *ResponseBuffer, text string, tags map[string]string) { + rb.Add(tags, service.prefix, "NOTICE", rb.target.Nick(), text) +} + // all service commands at the protocol level, by uppercase command name // e.g., NICKSERV, NS var ergoServicesByCommandAlias map[string]*ircService diff --git a/irc/utils/crypto.go b/irc/utils/crypto.go index a479b96d..9f9af53c 100644 --- a/irc/utils/crypto.go +++ b/irc/utils/crypto.go @@ -14,6 +14,7 @@ import ( "encoding/hex" "errors" "net" + "strconv" "strings" "time" ) @@ -31,8 +32,26 @@ var ( const ( SecretTokenLength = 26 + MachineId = 1 // Since there's no scaling Ergo, id is fixed at 1. Other things can have 2-127 ) +var inc uint64 = 0 + +// slingamn, if you ever see this, i'm sorry - I just didn't want to attach what i think is redundant data to every +// message. +func GenerateMessageId() uint64 { + inc++ + var ts = time.Now().Unix() & 0xffffffffffff + var flake = uint64(ts << 16) + flake |= MachineId << 10 + flake |= inc % 0x3ff + return flake +} + +func GenerateMessageIdStr() string { + return strconv.FormatUint(GenerateMessageId(), 10) +} + // generate a secret token that cannot be brute-forced via online attacks func GenerateSecretToken() string { // 128 bits of entropy are enough to resist any online attack: diff --git a/irc/utils/text.go b/irc/utils/text.go index c42368c6..341f3ec3 100644 --- a/irc/utils/text.go +++ b/irc/utils/text.go @@ -16,6 +16,7 @@ func IsRestrictedCTCPMessage(message string) bool { type MessagePair struct { Message string + Tags map[string]string Concat bool // should be relayed with the multiline-concat tag } @@ -37,19 +38,20 @@ type SplitMessage struct { func MakeMessage(original string) (result SplitMessage) { result.Message = original - result.Msgid = GenerateSecretToken() + result.Msgid = GenerateMessageIdStr() result.SetTime() return } -func (sm *SplitMessage) Append(message string, concat bool) { +func (sm *SplitMessage) Append(message string, concat bool, tags map[string]string) { if sm.Msgid == "" { - sm.Msgid = GenerateSecretToken() + sm.Msgid = GenerateMessageIdStr() } sm.Split = append(sm.Split, MessagePair{ Message: message, Concat: concat, + Tags: tags, }) }