diff --git a/irc/client.go b/irc/client.go index 67b95a10..58ba6777 100644 --- a/irc/client.go +++ b/irc/client.go @@ -49,6 +49,7 @@ type Client struct { class *OperClass ctime time.Time exitedSnomaskSent bool + fakelag *Fakelag flags map[modes.Mode]bool hasQuit bool hops int @@ -145,6 +146,26 @@ func NewClient(server *Server, conn net.Conn, isTLS bool) *Client { return client } +func (client *Client) resetFakelag() { + fakelag := func() *Fakelag { + if client.HasRoleCapabs("nofakelag") { + return nil + } + + flc := client.server.FakelagConfig() + + if !flc.Enabled { + return nil + } + + return NewFakelag(flc.Window, flc.BurstLimit, flc.MessagesPerWindow, flc.Cooldown) + }() + + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + client.fakelag = fakelag +} + // IP returns the IP address of this client. func (client *Client) IP() net.IP { if client.proxiedIP != nil { @@ -221,6 +242,8 @@ func (client *Client) run() { client.nickTimer = NewNickTimer(client) + client.resetFakelag() + // Set the hostname for this client // (may be overridden by a later PROXY command from stunnel) client.rawHostname = utils.AddrLookupHostname(client.socket.conn.RemoteAddr()) diff --git a/irc/commands.go b/irc/commands.go index 360504e8..9970af68 100644 --- a/irc/commands.go +++ b/irc/commands.go @@ -40,11 +40,13 @@ func (cmd *Command) Run(server *Server, client *Client, msg ircmsg.IrcMessage) b return false } + if client.registered { + client.fakelag.Touch() + } + rb := NewResponseBuffer(client) rb.Label = GetLabel(msg) - exiting := cmd.handler(server, client, msg, rb) - rb.Send() // after each command, see if we can send registration to the client diff --git a/irc/config.go b/irc/config.go index cacec5a4..09c0bb82 100644 --- a/irc/config.go +++ b/irc/config.go @@ -189,6 +189,14 @@ type StackImpactConfig struct { AppName string `yaml:"app-name"` } +type FakelagConfig struct { + Enabled bool + Window time.Duration + BurstLimit uint `yaml:"burst-limit"` + MessagesPerWindow uint `yaml:"messages-per-window"` + Cooldown time.Duration +} + // Config defines the overall configuration. type Config struct { Network struct { @@ -255,6 +263,8 @@ type Config struct { LineLen LineLenConfig `yaml:"linelen"` } + Fakelag FakelagConfig + Filename string } diff --git a/irc/fakelag.go b/irc/fakelag.go new file mode 100644 index 00000000..a3ed64b2 --- /dev/null +++ b/irc/fakelag.go @@ -0,0 +1,93 @@ +// Copyright (c) 2018 Shivaram Lingamneni +// released under the MIT license + +package irc + +import ( + "time" +) + +// fakelag is a system for artificially delaying commands when a user issues +// them too rapidly + +type FakelagState uint + +const ( + // initially, the client is "bursting" and can send n commands without + // encountering fakelag + FakelagBursting FakelagState = iota + // after that, they're "throttled" and we sleep in between commands until + // they're spaced sufficiently far apart + FakelagThrottled +) + +// this is intentionally not threadsafe, because it should only be touched +// from the loop that accepts the client's input and runs commands +type Fakelag struct { + window time.Duration + burstLimit uint + throttleMessagesPerWindow uint + cooldown time.Duration + nowFunc func() time.Time + sleepFunc func(time.Duration) + + state FakelagState + burstCount uint // number of messages sent in the current burst + lastTouch time.Time +} + +func NewFakelag(window time.Duration, burstLimit uint, throttleMessagesPerWindow uint, cooldown time.Duration) *Fakelag { + return &Fakelag{ + window: window, + burstLimit: burstLimit, + throttleMessagesPerWindow: throttleMessagesPerWindow, + cooldown: cooldown, + nowFunc: time.Now, + sleepFunc: time.Sleep, + state: FakelagBursting, + } +} + +// register a new command, sleep if necessary to delay it +func (fl *Fakelag) Touch() { + if fl == nil { + return + } + + now := fl.nowFunc() + // XXX if lastTouch.IsZero(), treat it as "very far in the past", which is fine + elapsed := now.Sub(fl.lastTouch) + fl.lastTouch = now + + if fl.state == FakelagBursting { + // determine if the previous burst is over + if elapsed > fl.cooldown { + fl.burstCount = 0 + } + + fl.burstCount++ + if fl.burstCount > fl.burstLimit { + // reset burst window for next time + fl.burstCount = 0 + // transition to throttling + fl.state = FakelagThrottled + // continue to throttling logic + } else { + return + } + } + + if fl.state == FakelagThrottled { + if elapsed > fl.cooldown { + // let them burst again + fl.state = FakelagBursting + return + } + // space them out by at least window/messagesperwindow + sleepDuration := time.Duration((int64(fl.window) / int64(fl.throttleMessagesPerWindow)) - int64(elapsed)) + if sleepDuration < 0 { + sleepDuration = 0 + } + fl.sleepFunc(sleepDuration) + } +} diff --git a/irc/fakelag_test.go b/irc/fakelag_test.go new file mode 100644 index 00000000..b2141039 --- /dev/null +++ b/irc/fakelag_test.go @@ -0,0 +1,114 @@ +// Copyright (c) 2018 Shivaram Lingamneni +// released under the MIT license + +package irc + +import ( + "testing" + "time" +) + +type mockTime struct { + now time.Time + sleepList []time.Duration + lastCheckedSleep int +} + +func (mt *mockTime) Now() (now time.Time) { + return mt.now +} + +func (mt *mockTime) Sleep(dur time.Duration) { + mt.sleepList = append(mt.sleepList, dur) + mt.pause(dur) +} + +func (mt *mockTime) pause(dur time.Duration) { + mt.now = mt.now.Add(dur) +} + +func (mt *mockTime) lastSleep() (slept bool, duration time.Duration) { + if mt.lastCheckedSleep == len(mt.sleepList)-1 { + slept = false + return + } + + slept = true + mt.lastCheckedSleep += 1 + duration = mt.sleepList[mt.lastCheckedSleep] + return +} + +func newFakelagForTesting(window time.Duration, burstLimit uint, throttleMessagesPerWindow uint, cooldown time.Duration) (*Fakelag, *mockTime) { + fl := NewFakelag(window, burstLimit, throttleMessagesPerWindow, cooldown) + mt := new(mockTime) + mt.now, _ = time.Parse("Mon Jan 2 15:04:05 -0700 MST 2006", "Mon Jan 2 15:04:05 -0700 MST 2006") + mt.lastCheckedSleep = -1 + fl.nowFunc = mt.Now + fl.sleepFunc = mt.Sleep + return fl, mt +} + +func TestFakelag(t *testing.T) { + window, _ := time.ParseDuration("1s") + fl, mt := newFakelagForTesting(window, 3, 2, window) + + fl.Touch() + slept, _ := mt.lastSleep() + if slept { + t.Fatalf("should not have slept") + } + + interval, _ := time.ParseDuration("100ms") + for i := 0; i < 2; i++ { + mt.pause(interval) + fl.Touch() + slept, _ := mt.lastSleep() + if slept { + t.Fatalf("should not have slept") + } + } + + mt.pause(interval) + fl.Touch() + if fl.state != FakelagThrottled { + t.Fatalf("should be throttled") + } + slept, duration := mt.lastSleep() + if !slept { + t.Fatalf("should have slept due to fakelag") + } + expected, _ := time.ParseDuration("400ms") + if duration != expected { + t.Fatalf("incorrect sleep time: %v != %v", expected, duration) + } + + fl.Touch() + if fl.state != FakelagThrottled { + t.Fatalf("should be throttled") + } + slept, duration = mt.lastSleep() + if duration != interval { + t.Fatalf("incorrect sleep time: %v != %v", interval, duration) + } + + mt.pause(interval * 6) + fl.Touch() + if fl.state != FakelagThrottled { + t.Fatalf("should still be throttled") + } + slept, duration = mt.lastSleep() + if duration != 0 { + t.Fatalf("we paused for long enough that we shouldn't sleep here") + } + + mt.pause(window * 2) + fl.Touch() + if fl.state != FakelagBursting { + t.Fatalf("should be bursting again") + } + slept, _ = mt.lastSleep() + if slept { + t.Fatalf("should not have slept") + } +} diff --git a/irc/getters.go b/irc/getters.go index 29e32361..3c5fbc53 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -59,7 +59,19 @@ func (server *Server) ChannelRegistrationEnabled() bool { func (server *Server) AccountConfig() *AccountConfig { server.configurableStateMutex.RLock() defer server.configurableStateMutex.RUnlock() - return server.accountConfig + if server.config == nil { + return nil + } + return &server.config.Accounts +} + +func (server *Server) FakelagConfig() *FakelagConfig { + server.configurableStateMutex.RLock() + defer server.configurableStateMutex.RUnlock() + if server.config == nil { + return nil + } + return &server.config.Fakelag } func (client *Client) Nick() string { diff --git a/irc/handlers.go b/irc/handlers.go index c25fe7ce..47af0c4b 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -1757,7 +1757,6 @@ func operHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp return true } - client.flags[modes.Operator] = true client.operName = name client.class = oper.Class client.whoisLine = oper.WhoisLine @@ -1795,6 +1794,11 @@ func operHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp rb.Add(nil, server.name, "MODE", client.nick, applied.String()) server.snomasks.Send(sno.LocalOpers, fmt.Sprintf(ircfmt.Unescape("Client opered up $c[grey][$r%s$c[grey], $r%s$c[grey]]"), client.nickMaskString, client.operName)) + + // client may now be unthrottled by the fakelag system + client.resetFakelag() + + client.flags[modes.Operator] = true return false } diff --git a/irc/server.go b/irc/server.go index b78584e8..83534d36 100644 --- a/irc/server.go +++ b/irc/server.go @@ -87,7 +87,6 @@ type ListenerWrapper struct { // Server is the main Oragono server. type Server struct { - accountConfig *AccountConfig accounts *AccountManager batches *BatchManager channelRegistrationEnabled bool @@ -95,6 +94,7 @@ type Server struct { channelRegistry *ChannelRegistry checkIdent bool clients *ClientManager + config *Config configFilename string configurableStateMutex sync.RWMutex // tier 1; generic protection for server state modified by rehash() connectionLimiter *connection_limits.Limiter @@ -214,10 +214,10 @@ func (server *Server) setISupport() { isupport.Add("UTF8MAPPING", casemappingName) // account registration - if server.accountConfig.Registration.Enabled { + if server.config.Accounts.Registration.Enabled { // 'none' isn't shown in the REGCALLBACKS vars var enabledCallbacks []string - for _, name := range server.accountConfig.Registration.EnabledCallbacks { + for _, name := range server.config.Accounts.Registration.EnabledCallbacks { if name != "*" { enabledCallbacks = append(enabledCallbacks, name) } @@ -830,10 +830,6 @@ func (server *Server) applyConfig(config *Config, initial bool) error { removedCaps.Add(caps.SASL) } - server.configurableStateMutex.Lock() - server.accountConfig = &config.Accounts - server.configurableStateMutex.Unlock() - nickReservationPreviouslyDisabled := oldAccountConfig != nil && !oldAccountConfig.NickReservation.Enabled nickReservationNowEnabled := config.Accounts.NickReservation.Enabled if nickReservationPreviouslyDisabled && nickReservationNowEnabled { @@ -943,14 +939,6 @@ func (server *Server) applyConfig(config *Config, initial bool) error { } } - // set RPL_ISUPPORT - var newISupportReplies [][]string - oldISupportList := server.isupport - server.setISupport() - if oldISupportList != nil { - newISupportReplies = oldISupportList.GetDifference(server.isupport) - } - server.loadMOTD(config.Server.MOTD, config.Server.MOTDFormatting) // reload logging config @@ -963,6 +951,11 @@ func (server *Server) applyConfig(config *Config, initial bool) error { sendRawOutputNotice := !initial && !server.loggingRawIO && nowLoggingRawIO server.loggingRawIO = nowLoggingRawIO + // save a pointer to the new config + server.configurableStateMutex.Lock() + server.config = config + server.configurableStateMutex.Unlock() + server.storeFilename = config.Datastore.Path server.logger.Info("rehash", "Using datastore", server.storeFilename) if initial { @@ -973,6 +966,14 @@ func (server *Server) applyConfig(config *Config, initial bool) error { server.setupPprofListener(config) + // set RPL_ISUPPORT + var newISupportReplies [][]string + oldISupportList := server.ISupport() + server.setISupport() + if oldISupportList != nil { + newISupportReplies = oldISupportList.GetDifference(server.ISupport()) + } + // we are now open for business server.setupListeners(config) diff --git a/oragono.yaml b/oragono.yaml index 49f22cb5..2a438b1e 100644 --- a/oragono.yaml +++ b/oragono.yaml @@ -222,6 +222,7 @@ oper-classes: - "oper:local_kill" - "oper:local_ban" - "oper:local_unban" + - "nofakelag" # network operator "network-oper": @@ -387,3 +388,22 @@ limits: # rest of the message rest: 2048 + +# fakelag: prevents clients from spamming commands too rapidly +fakelag: + # whether to enforce fakelag + enabled: false + + # time unit for counting command rates + window: 1s + + # clients can send this many commands without fakelag being imposed + burst-limit: 5 + + # once clients have exceeded their burst allowance, they can send only + # this many commands per `window`: + messages-per-window: 2 + + # client status resets to the default state if they go this long without + # sending any commands: + cooldown: 1s