diff --git a/irc/client.go b/irc/client.go index 95b0a90a..dd30f8ce 100644 --- a/irc/client.go +++ b/irc/client.go @@ -353,6 +353,32 @@ func (client *Client) updateNickMask() { client.nickMaskCasefolded = nickMaskCasefolded } +// AllNickmasks returns all the possible nickmasks for the client. +func (client *Client) AllNickmasks() []string { + var masks []string + var mask string + var err error + + if len(client.vhost) > 0 { + mask, err = Casefold(fmt.Sprintf("%s!%s@%s", client.nick, client.username, client.vhost)) + if err == nil { + masks = append(masks, mask) + } + } + + mask, err = Casefold(fmt.Sprintf("%s!%s@%s", client.nick, client.username, client.rawHostname)) + if err == nil { + masks = append(masks, mask) + } + + mask2, err := Casefold(fmt.Sprintf("%s!%s@%s", client.nick, client.username, IPString(client.socket.conn.RemoteAddr()))) + if err == nil && mask2 != mask { + masks = append(masks, mask2) + } + + return masks +} + // SetNickname sets the very first nickname for the client. func (client *Client) SetNickname(nickname string) error { if client.HasNick() { diff --git a/irc/commands.go b/irc/commands.go index 8641e05a..964eaca3 100644 --- a/irc/commands.go +++ b/irc/commands.go @@ -107,6 +107,11 @@ var Commands = map[string]Command{ oper: true, capabs: []string{"oper:local_kill"}, //TODO(dan): when we have S2S, this will be checked in the command handler itself }, + "KLINE": { + handler: klineHandler, + minParams: 1, + oper: true, + }, "LIST": { handler: listHandler, minParams: 0, @@ -210,6 +215,11 @@ var Commands = map[string]Command{ minParams: 1, oper: true, }, + "UNKLINE": { + handler: unKLineHandler, + minParams: 1, + oper: true, + }, "USER": { handler: userHandler, usablePreReg: true, diff --git a/irc/help.go b/irc/help.go index d8efd7c8..6b27bbc8 100644 --- a/irc/help.go +++ b/irc/help.go @@ -157,6 +157,30 @@ channel privs.`, Removes the given user from the network, showing them the reason if it is supplied.`, + }, + "kline": { + oper: true, + text: `KLINE [MYSELF] [duration] [ON ] [reason [| oper reason]] + +Bans a mask from connecting to the server. If the duration is given then only for that +long. The reason is shown to the user themselves, but everyone else will see a standard +message. The oper reason is shown to operators getting info about the KLINEs that exist. + +Bans are saved across subsequent launches of the server. + +"MYSELF" is required when the KLINE matches the address the person applying it is connected +from. If "MYSELF" is not given, trying to KLINE yourself will result in an error. + +[duration] can be of the following forms: + 10h 8m 13s + + is specified in typical IRC format. For example: + dan + dan!5*@127.* + +ON specifies that the ban is to be set on that specific server. + +[reason] and [oper reason], if they exist, are separated by a vertical bar (|).`, }, "list": { text: `LIST [{,}] [{,}] @@ -309,6 +333,16 @@ Removes an existing ban on an IP address or a network. 127.0.0.1/8 8.8.8.8/24`, }, + "unkline": { + oper: true, + text: `UNKLINE + +Removes an existing ban on a mask. + +For example: + dan + dan!5*@127.*`, + }, "user": { text: `USER 0 * diff --git a/irc/kline.go b/irc/kline.go new file mode 100644 index 00000000..78ac8208 --- /dev/null +++ b/irc/kline.go @@ -0,0 +1,291 @@ +// Copyright (c) 2016- Daniel Oaks +// released under the MIT license + +package irc + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/DanielOaks/girc-go/ircmatch" + "github.com/DanielOaks/girc-go/ircmsg" + "github.com/tidwall/buntdb" +) + +const ( + keyKlineEntry = "bans.kline %s" +) + +// KLineInfo contains the address itself and expiration time for a given network. +type KLineInfo struct { + // Mask that is blocked. + Mask string + // Matcher, to facilitate fast matching. + Matcher ircmatch.Matcher + // Info contains information on the ban. + Info IPBanInfo +} + +// KLineManager manages and klines. +type KLineManager struct { + // kline'd entries + entries map[string]*KLineInfo +} + +// NewKLineManager returns a new KLineManager. +func NewKLineManager() *KLineManager { + var km KLineManager + km.entries = make(map[string]*KLineInfo) + return &km +} + +// AllBans returns all bans (for use with APIs, etc). +func (km *KLineManager) AllBans() map[string]IPBanInfo { + allb := make(map[string]IPBanInfo) + + for name, info := range km.entries { + allb[name] = info.Info + } + + return allb +} + +// AddMask adds to the blocked list. +func (km *KLineManager) AddMask(mask string, length *IPRestrictTime, reason string, operReason string) { + kln := KLineInfo{ + Mask: mask, + Matcher: ircmatch.MakeMatch(mask), + Info: IPBanInfo{ + Time: length, + Reason: reason, + OperReason: operReason, + }, + } + km.entries[mask] = &kln +} + +// RemoveMask removes a mask from the blocked list. +func (km *KLineManager) RemoveMask(mask string) { + delete(km.entries, mask) +} + +// CheckMasks returns whether or not the hostmask(s) are banned, and how long they are banned for. +func (km *KLineManager) CheckMasks(masks ...string) (isBanned bool, info *IPBanInfo) { + // check networks + var masksToRemove []string + + for _, entryInfo := range km.entries { + var matches bool + for _, mask := range masks { + if entryInfo.Matcher.Match(mask) { + matches = true + break + } + } + if !matches { + continue + } + + if entryInfo.Info.Time != nil { + if entryInfo.Info.Time.IsExpired() { + // ban on network has expired, remove it from our blocked list + masksToRemove = append(masksToRemove, entryInfo.Mask) + } else { + return true, &entryInfo.Info + } + } else { + return true, &entryInfo.Info + } + } + + // remove expired networks + for _, expiredMask := range masksToRemove { + km.RemoveMask(expiredMask) + } + + // no matches! + return false, nil +} + +// KLINE [MYSELF] [duration] [ON ] [reason [| oper reason]] +func klineHandler(server *Server, client *Client, msg ircmsg.IrcMessage) bool { + // check oper permissions + if !client.class.Capabilities["oper:local_ban"] { + client.Send(nil, server.name, ERR_NOPRIVS, client.nick, msg.Command, "Insufficient oper privs") + return false + } + + currentArg := 0 + + // when setting a ban that covers the oper's current connection, we require them to say + // "KLINE MYSELF" so that we're sure they really mean it. + var klineMyself bool + if len(msg.Params) > currentArg+1 && strings.ToLower(msg.Params[currentArg]) == "myself" { + klineMyself = true + currentArg++ + } + + // duration + duration, err := time.ParseDuration(msg.Params[currentArg]) + durationIsUsed := err == nil + if durationIsUsed { + currentArg++ + } + + // get mask + if len(msg.Params) < currentArg+1 { + client.Send(nil, server.name, ERR_NEEDMOREPARAMS, client.nick, msg.Command, "Not enough parameters") + return false + } + mask := strings.ToLower(msg.Params[currentArg]) + currentArg++ + + // check mask + if !strings.Contains(mask, "!") && !strings.Contains(mask, "@") { + mask = mask + "!*@*" + } else if !strings.Contains(mask, "@") { + mask = mask + "@*" + } + + matcher := ircmatch.MakeMatch(mask) + + for _, clientMask := range client.AllNickmasks() { + if !klineMyself && matcher.Match(clientMask) { + client.Send(nil, server.name, ERR_UNKNOWNERROR, client.nick, msg.Command, "This ban matches you. To KLINE yourself, you must use the command: /KLINE MYSELF ") + return false + } + } + + // check remote + if len(msg.Params) > currentArg && msg.Params[currentArg] == "ON" { + client.Send(nil, server.name, ERR_UNKNOWNERROR, client.nick, msg.Command, "Remote servers not yet supported") + return false + } + + // get comment(s) + reason := "No reason given" + operReason := "No reason given" + if len(msg.Params) > currentArg { + tempReason := strings.TrimSpace(msg.Params[currentArg]) + if len(tempReason) > 0 && tempReason != "|" { + tempReasons := strings.SplitN(tempReason, "|", 2) + if tempReasons[0] != "" { + reason = tempReasons[0] + } + if len(tempReasons) > 1 && tempReasons[1] != "" { + operReason = tempReasons[1] + } else { + operReason = reason + } + } + } + + // assemble ban info + var banTime *IPRestrictTime + if durationIsUsed { + banTime = &IPRestrictTime{ + Duration: duration, + Expires: time.Now().Add(duration), + } + } + + info := IPBanInfo{ + Reason: reason, + OperReason: operReason, + Time: banTime, + } + + // save in datastore + err = server.store.Update(func(tx *buntdb.Tx) error { + klineKey := fmt.Sprintf(keyKlineEntry, mask) + + // assemble json from ban info + b, err := json.Marshal(info) + if err != nil { + return err + } + + tx.Set(klineKey, string(b), nil) + + return nil + }) + + server.klines.AddMask(mask, banTime, reason, operReason) + + if durationIsUsed { + client.Notice(fmt.Sprintf("Added temporary (%s) K-Line for %s", duration.String(), mask)) + } else { + client.Notice(fmt.Sprintf("Added K-Line for %s", mask)) + } + + return false +} + +func unKLineHandler(server *Server, client *Client, msg ircmsg.IrcMessage) bool { + // check oper permissions + if !client.class.Capabilities["oper:local_unban"] { + client.Send(nil, server.name, ERR_NOPRIVS, client.nick, msg.Command, "Insufficient oper privs") + return false + } + + // get host + mask := msg.Params[0] + + if !strings.Contains(mask, "!") && !strings.Contains(mask, "@") { + mask = mask + "!*@*" + } else if !strings.Contains(mask, "@") { + mask = mask + "@*" + } + + // save in datastore + err := server.store.Update(func(tx *buntdb.Tx) error { + klineKey := fmt.Sprintf(keyKlineEntry, mask) + + // check if it exists or not + val, err := tx.Get(klineKey) + if val == "" { + return errNoExistingBan + } else if err != nil { + return err + } + + tx.Delete(klineKey) + return nil + }) + + if err != nil { + client.Send(nil, server.name, ERR_UNKNOWNERROR, client.nick, msg.Command, fmt.Sprintf("Could not remove ban [%s]", err.Error())) + return false + } + + server.klines.RemoveMask(mask) + + client.Notice(fmt.Sprintf("Removed K-Line for %s", mask)) + return false +} + +func (s *Server) loadKLines() { + s.klines = NewKLineManager() + + // load from datastore + s.store.View(func(tx *buntdb.Tx) error { + //TODO(dan): We could make this safer + tx.AscendKeys("bans.kline *", func(key, value string) bool { + // get address name + key = key[len("bans.kline "):] + mask := key + + // load ban info + var info IPBanInfo + json.Unmarshal([]byte(value), &info) + + // add to the server + s.klines.AddMask(mask, info.Time, info.Reason, info.OperReason) + + return true // true to continue I guess? + }) + return nil + }) +} diff --git a/irc/server.go b/irc/server.go index e617dbee..eabfa4e1 100644 --- a/irc/server.go +++ b/irc/server.go @@ -87,6 +87,7 @@ type Server struct { dlines *DLineManager idle chan *Client isupport *ISupportList + klines *KLineManager limits Limits listenerEventActMutex sync.Mutex listeners map[string]ListenerInterface @@ -214,8 +215,9 @@ func NewServer(configFilename string, config *Config) *Server { return nil } - // load dlines + // load *lines server.loadDLines() + server.loadKLines() // load password manager err = server.store.View(func(tx *buntdb.Tx) error { @@ -569,6 +571,21 @@ func (server *Server) tryRegister(c *Client) { (c.capState == CapNegotiating) { return } + + // check KLINEs + isBanned, info := server.klines.CheckMasks(c.AllNickmasks()...) + if isBanned { + reason := info.Reason + if info.Time != nil { + reason += fmt.Sprintf(" [%s]", info.Time.Duration.String()) + } + c.Send(nil, "", "ERROR", fmt.Sprintf("You are banned from this server (%s)", reason)) + c.quitMessageSent = true + c.destroy() + return + } + + // continue registration c.Register() // send welcome text