From 1bf5e2a7c8c3b561060726dc0c72f5a15b36fd3f Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Thu, 22 Mar 2018 11:04:21 -0400 Subject: [PATCH] implement fakelag (#189) --- irc/client.go | 23 +++++++++ irc/commands.go | 6 ++- irc/config.go | 9 ++++ irc/fakelag.go | 92 +++++++++++++++++++++++++++++++++++ irc/fakelag_test.go | 114 ++++++++++++++++++++++++++++++++++++++++++++ irc/getters.go | 14 +++++- irc/handlers.go | 6 ++- irc/server.go | 31 ++++++------ oragono.yaml | 17 +++++++ 9 files changed, 293 insertions(+), 19 deletions(-) create mode 100644 irc/fakelag.go create mode 100644 irc/fakelag_test.go diff --git a/irc/client.go b/irc/client.go index 67b95a10..240763f7 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) + }() + + 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..84a565b2 100644 --- a/irc/config.go +++ b/irc/config.go @@ -189,6 +189,13 @@ 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"` +} + // Config defines the overall configuration. type Config struct { Network struct { @@ -255,6 +262,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..2b13b9f5 --- /dev/null +++ b/irc/fakelag.go @@ -0,0 +1,92 @@ +// 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 + 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) *Fakelag { + return &Fakelag{ + window: window, + burstLimit: burstLimit, + throttleMessagesPerWindow: throttleMessagesPerWindow, + 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 + // (we could use 2*window instead) + if elapsed > fl.window { + 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.window { + // let them burst again (as above, we could use 2*window instead) + 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..957ee595 --- /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) (*Fakelag, *mockTime) { + fl := NewFakelag(window, burstLimit, throttleMessagesPerWindow) + 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) + + 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 9e6e0a22..7a8e1bc8 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..bf027489 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,19 @@ limits: # rest of the message rest: 2048 + +# fakelag: prevents clients from spamming commands too rapidly +fakelag: + # whether to enforce fakelag + enabled: true + + # time unit for counting command rates + window: 1s + + # clients can send this many commands without fakelag being imposed + # (resets after a period of `window` elapses without any commands) + burst-limit: 5 + + # once clients have exceeded their burst allowance, they can send only + # this many commands per `window`: + messages-per-window: 2