diff --git a/CHANGELOG.md b/CHANGELOG.md index d06a4a5c..b05e9f20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,15 +11,17 @@ New release of Oragono! ### Added * Added ARM build (for Raspberry PIs and similar). -* Added `KLINE` and `UNDLINE` commands. Complementing `KLINE`, this lets you ban masks from the server. +* Added automated connection throttling! To enable this, copy the `connection-throttling` section from the config. +* Added `KLINE` and `UNDLINE` commands. Complementing `DLINE`'s per-IP and per-network bans, this lets you ban masks from the server. ### Changed +* Connection limits can now be freely enabled or disabled. To enable automated limit handling, see the new `enabled` flag in the config, under `connection-limits`. ### Removed ### Fixed * Fixed an issue where `UNDLINE` didn't save across server launches. -* Removed several race conditions and made the server more resiliant to these bugs. +* Removed several race conditions which could result in server panics. ## [0.5.0] - 2016-12-10 diff --git a/irc/config.go b/irc/config.go index c8b08e77..4bf7e679 100644 --- a/irc/config.go +++ b/irc/config.go @@ -12,6 +12,7 @@ import ( "io/ioutil" "log" "strings" + "time" "gopkg.in/yaml.v2" ) @@ -95,12 +96,26 @@ type RestAPIConfig struct { } type ConnectionLimitsConfig struct { + Enabled bool CidrLenIPv4 int `yaml:"cidr-len-ipv4"` CidrLenIPv6 int `yaml:"cidr-len-ipv6"` IPsPerCidr int `yaml:"ips-per-subnet"` Exempted []string } +type ConnectionThrottleConfig struct { + Enabled bool + CidrLenIPv4 int `yaml:"cidr-len-ipv4"` + CidrLenIPv6 int `yaml:"cidr-len-ipv6"` + ConnectionsPerCidr int `yaml:"max-connections"` + DurationString string `yaml:"duration"` + Duration time.Duration `yaml:"duration-time"` + BanDurationString string `yaml:"ban-duration"` + BanDuration time.Duration + BanMessage string `yaml:"ban-message"` + Exempted []string +} + type Config struct { Network struct { Name string @@ -108,16 +123,17 @@ type Config struct { Server struct { PassConfig - Password string - Name string - Listen []string - Wslisten string `yaml:"ws-listen"` - TLSListeners map[string]*TLSListenConfig `yaml:"tls-listeners"` - RestAPI RestAPIConfig `yaml:"rest-api"` - CheckIdent bool `yaml:"check-ident"` - Log string - MOTD string - ConnectionLimits ConnectionLimitsConfig `yaml:"connection-limits"` + Password string + Name string + Listen []string + Wslisten string `yaml:"ws-listen"` + TLSListeners map[string]*TLSListenConfig `yaml:"tls-listeners"` + RestAPI RestAPIConfig `yaml:"rest-api"` + CheckIdent bool `yaml:"check-ident"` + Log string + MOTD string + ConnectionLimits ConnectionLimitsConfig `yaml:"connection-limits"` + ConnectionThrottle ConnectionThrottleConfig `yaml:"connection-throttling"` } Datastore struct { @@ -309,6 +325,16 @@ func LoadConfig(filename string) (config *Config, err error) { if config.Limits.NickLen < 1 || config.Limits.ChannelLen < 2 || config.Limits.AwayLen < 1 || config.Limits.KickLen < 1 || config.Limits.TopicLen < 1 { return nil, errors.New("Limits aren't setup properly, check them and make them sane") } + if config.Server.ConnectionThrottle.Enabled { + config.Server.ConnectionThrottle.Duration, err = time.ParseDuration(config.Server.ConnectionThrottle.DurationString) + if err != nil { + return nil, fmt.Errorf("Could not parse connection-throttle duration: %s", err.Error()) + } + config.Server.ConnectionThrottle.BanDuration, err = time.ParseDuration(config.Server.ConnectionThrottle.BanDurationString) + if err != nil { + return nil, fmt.Errorf("Could not parse connection-throttle ban-duration: %s", err.Error()) + } + } return config, nil } diff --git a/irc/connection_limits.go b/irc/connection_limits.go index 332a3737..8c5572d1 100644 --- a/irc/connection_limits.go +++ b/irc/connection_limits.go @@ -15,6 +15,7 @@ var ( // ConnectionLimits manages the automated client connection limits. type ConnectionLimits struct { + enabled bool ipv4Mask net.IPMask ipv6Mask net.IPMask // subnetLimit is the maximum number of clients per subnet @@ -44,6 +45,10 @@ func (cl *ConnectionLimits) maskAddr(addr net.IP) net.IP { // AddClient adds a client to our population if possible. If we can't, throws an error instead. // 'force' is used to add already-existing clients (i.e. ones that are already on the network). func (cl *ConnectionLimits) AddClient(addr net.IP, force bool) error { + if !cl.enabled { + return nil + } + // check exempted lists // we don't track populations for exempted addresses or nets - this is by design if cl.exemptedIPs[addr.String()] { @@ -70,6 +75,10 @@ func (cl *ConnectionLimits) AddClient(addr net.IP, force bool) error { // RemoveClient removes the given address from our population func (cl *ConnectionLimits) RemoveClient(addr net.IP) { + if !cl.enabled { + return + } + addrString := addr.String() cl.population[addrString] = cl.population[addrString] - 1 @@ -82,6 +91,8 @@ func (cl *ConnectionLimits) RemoveClient(addr net.IP) { // NewConnectionLimits returns a new connection limit handler. func NewConnectionLimits(config ConnectionLimitsConfig) (*ConnectionLimits, error) { var cl ConnectionLimits + cl.enabled = config.Enabled + cl.population = make(map[string]int) cl.exemptedIPs = make(map[string]bool) diff --git a/irc/connection_throttling.go b/irc/connection_throttling.go new file mode 100644 index 00000000..b9988df9 --- /dev/null +++ b/irc/connection_throttling.go @@ -0,0 +1,142 @@ +// Copyright (c) 2016- Daniel Oaks +// released under the MIT license + +package irc + +import ( + "fmt" + "net" + "time" + + "github.com/DanielOaks/girc-go/ircmsg" +) + +// ThrottleDetails holds the connection-throttling details for a subnet/IP. +type ThrottleDetails struct { + Start time.Time + ClientCount int +} + +// ConnectionThrottle manages automated client connection throttling. +type ConnectionThrottle struct { + enabled bool + ipv4Mask net.IPMask + ipv6Mask net.IPMask + subnetLimit int + duration time.Duration + population map[string]ThrottleDetails + + // used by the server to ban clients that go over this limit + BanDuration time.Duration + BanMessage string + BanMessageBytes []byte + + // exemptedIPs holds IPs that are exempt from limits + exemptedIPs map[string]bool + // exemptedNets holds networks that are exempt from limits + exemptedNets []net.IPNet +} + +// maskAddr masks the given IPv4/6 address with our cidr limit masks. +func (ct *ConnectionThrottle) maskAddr(addr net.IP) net.IP { + if addr.To4() == nil { + // IPv6 addr + addr = addr.Mask(ct.ipv6Mask) + } else { + // IPv4 addr + addr = addr.Mask(ct.ipv4Mask) + } + + return addr +} + +// ResetFor removes any existing count for the given address. +func (ct *ConnectionThrottle) ResetFor(addr net.IP) { + if !ct.enabled { + return + } + + // remove + ct.maskAddr(addr) + addrString := addr.String() + delete(ct.population, addrString) +} + +// AddClient introduces a new client connection if possible. If we can't, throws an error instead. +func (ct *ConnectionThrottle) AddClient(addr net.IP) error { + if !ct.enabled { + return nil + } + + // check exempted lists + if ct.exemptedIPs[addr.String()] { + return nil + } + for _, ex := range ct.exemptedNets { + if ex.Contains(addr) { + return nil + } + } + + // check throttle + ct.maskAddr(addr) + addrString := addr.String() + + details, exists := ct.population[addrString] + if !exists || details.Start.Add(ct.duration).Before(time.Now()) { + details = ThrottleDetails{ + Start: time.Now(), + } + } + + if details.ClientCount+1 > ct.subnetLimit { + return errTooManyClients + } + + details.ClientCount++ + ct.population[addrString] = details + + return nil +} + +// NewConnectionThrottle returns a new client connection throttler. +func NewConnectionThrottle(config ConnectionThrottleConfig) (*ConnectionThrottle, error) { + var ct ConnectionThrottle + ct.enabled = config.Enabled + + ct.population = make(map[string]ThrottleDetails) + ct.exemptedIPs = make(map[string]bool) + + ct.ipv4Mask = net.CIDRMask(config.CidrLenIPv4, 32) + ct.ipv6Mask = net.CIDRMask(config.CidrLenIPv6, 128) + ct.subnetLimit = config.ConnectionsPerCidr + + ct.duration = config.Duration + + ct.BanDuration = config.BanDuration + ct.BanMessage = config.BanMessage + ircmsgOutput := ircmsg.MakeMessage(nil, "", "ERROR", ct.BanMessage) + msg, err := ircmsgOutput.Line() + if err != nil { + return nil, fmt.Errorf("Could not make error message: %s", err.Error()) + } + ct.BanMessageBytes = []byte(msg) + + // assemble exempted nets + for _, cidr := range config.Exempted { + ipaddr := net.ParseIP(cidr) + _, netaddr, err := net.ParseCIDR(cidr) + + if ipaddr == nil && err != nil { + return nil, fmt.Errorf("Could not parse exempted IP/network [%s]", cidr) + } + + if ipaddr != nil { + ct.exemptedIPs[ipaddr.String()] = true + } else { + ct.exemptedNets = append(ct.exemptedNets, *netaddr) + } + } + + return &ct, nil +} diff --git a/irc/server.go b/irc/server.go index eabfa4e1..b64b95a0 100644 --- a/irc/server.go +++ b/irc/server.go @@ -28,7 +28,7 @@ import ( var ( // cached because this may be used lots - tooManyClientsMsg = ircmsg.MakeMessage(nil, "", "ERROR", "Too many clients from your IP or network") + tooManyClientsMsg = ircmsg.MakeMessage(nil, "", "ERROR", "Too many clients from your network") tooManyClientsBytes, _ = tooManyClientsMsg.Line() bannedFromServerMsg = ircmsg.MakeMessage(nil, "", "ERROR", "You are banned from this server (%s)") @@ -72,42 +72,44 @@ type ListenerEvent struct { // Server is the main Oragono server. type Server struct { - accountRegistration *AccountRegistration - accounts map[string]*ClientAccount - authenticationEnabled bool - channels ChannelNameMap - checkIdent bool - clients *ClientLookupSet - commands chan Command - configFilename string - connectionLimits *ConnectionLimits - connectionLimitsMutex sync.Mutex // used when affecting the connection limiter, to make sure rehashing doesn't make things go out-of-whack - ctime time.Time - currentOpers map[*Client]bool - dlines *DLineManager - idle chan *Client - isupport *ISupportList - klines *KLineManager - limits Limits - listenerEventActMutex sync.Mutex - listeners map[string]ListenerInterface - listenerUpdateMutex sync.Mutex - monitoring map[string][]Client - motdLines []string - name string - nameCasefolded string - networkName string - newConns chan clientConn - operators map[string]Oper - operclasses map[string]OperClass - password []byte - passwords *PasswordManager - rehashMutex sync.Mutex - rehashSignal chan os.Signal - restAPI *RestAPIConfig - signals chan os.Signal - store *buntdb.DB - whoWas *WhoWasList + accountRegistration *AccountRegistration + accounts map[string]*ClientAccount + authenticationEnabled bool + channels ChannelNameMap + checkIdent bool + clients *ClientLookupSet + commands chan Command + configFilename string + connectionThrottle *ConnectionThrottle + connectionThrottleMutex sync.Mutex // used when affecting the connection limiter, to make sure rehashing doesn't make things go out-of-whack + connectionLimits *ConnectionLimits + connectionLimitsMutex sync.Mutex // used when affecting the connection limiter, to make sure rehashing doesn't make things go out-of-whack + ctime time.Time + currentOpers map[*Client]bool + dlines *DLineManager + idle chan *Client + isupport *ISupportList + klines *KLineManager + limits Limits + listenerEventActMutex sync.Mutex + listeners map[string]ListenerInterface + listenerUpdateMutex sync.Mutex + monitoring map[string][]Client + motdLines []string + name string + nameCasefolded string + networkName string + newConns chan clientConn + operators map[string]Oper + operclasses map[string]OperClass + password []byte + passwords *PasswordManager + rehashMutex sync.Mutex + rehashSignal chan os.Signal + restAPI *RestAPIConfig + signals chan os.Signal + store *buntdb.DB + whoWas *WhoWasList } var ( @@ -157,6 +159,10 @@ func NewServer(configFilename string, config *Config) *Server { if err != nil { log.Fatal("Error loading connection limits:", err.Error()) } + connectionThrottle, err := NewConnectionThrottle(config.Server.ConnectionThrottle) + if err != nil { + log.Fatal("Error loading connection throttler:", err.Error()) + } server := &Server{ accounts: make(map[string]*ClientAccount), @@ -166,6 +172,7 @@ func NewServer(configFilename string, config *Config) *Server { commands: make(chan Command), configFilename: configFilename, connectionLimits: connectionLimits, + connectionThrottle: connectionThrottle, ctime: time.Now(), currentOpers: make(map[*Client]bool), idle: make(chan *Client), @@ -403,6 +410,27 @@ func (server *Server) Run() { continue } + // check connection throttle + server.connectionThrottleMutex.Lock() + err = server.connectionThrottle.AddClient(ipaddr) + server.connectionThrottleMutex.Unlock() + if err != nil { + // too many connections too quickly from client, tell them and close the connection + length := &IPRestrictTime{ + Duration: server.connectionThrottle.BanDuration, + Expires: time.Now().Add(server.connectionThrottle.BanDuration), + } + server.dlines.AddIP(ipaddr, length, server.connectionThrottle.BanMessage, "Exceeded automated connection throttle") + + // reset ban on connectionThrottle + server.connectionThrottle.ResetFor(ipaddr) + + // this might not show up properly on some clients, but our objective here is just to close it out before it has a load impact on us + conn.Conn.Write([]byte(server.connectionThrottle.BanMessageBytes)) + conn.Conn.Close() + continue + } + go NewClient(server, conn.Conn, conn.IsTLS) continue } @@ -1066,23 +1094,29 @@ func (server *Server) rehash() error { config, err := LoadConfig(server.configFilename) if err != nil { - return fmt.Errorf("Error rehashing config file: %s", err.Error()) + return fmt.Errorf("Error rehashing config file config: %s", err.Error()) } // confirm connectionLimits are fine connectionLimits, err := NewConnectionLimits(config.Server.ConnectionLimits) if err != nil { - return fmt.Errorf("Error rehashing config file: %s", err.Error()) + return fmt.Errorf("Error rehashing config file connection-limits: %s", err.Error()) + } + + // confirm connectionThrottler is fine + connectionThrottle, err := NewConnectionThrottle(config.Server.ConnectionThrottle) + if err != nil { + return fmt.Errorf("Error rehashing config file connection-throttle: %s", err.Error()) } // confirm operator stuff all exists and is fine operclasses, err := config.OperatorClasses() if err != nil { - return fmt.Errorf("Error rehashing config file: %s", err.Error()) + return fmt.Errorf("Error rehashing config file operclasses: %s", err.Error()) } opers, err := config.Operators(operclasses) if err != nil { - return fmt.Errorf("Error rehashing config file: %s", err.Error()) + return fmt.Errorf("Error rehashing config file opers: %s", err.Error()) } for client := range server.currentOpers { _, exists := opers[client.operName] @@ -1094,6 +1128,8 @@ func (server *Server) rehash() error { // apply new connectionlimits server.connectionLimitsMutex.Lock() server.connectionLimits = connectionLimits + server.connectionThrottleMutex.Lock() + server.connectionThrottle = connectionThrottle server.clients.ByNickMutex.RLock() for _, client := range server.clients.ByNick { diff --git a/oragono.yaml b/oragono.yaml index 17f0bf95..e862df92 100644 --- a/oragono.yaml +++ b/oragono.yaml @@ -51,6 +51,9 @@ server: # maximum number of connections per subnet connection-limits: + # whether to throttle limits or not + enabled: true + # how wide the cidr should be for IPv4 cidr-len-ipv4: 24 @@ -66,6 +69,34 @@ server: - "127.0.0.1/8" - "::1/128" + # automated connection throttling + connection-throttling: + # whether to throttle connections or not + enabled: true + + # how wide the cidr should be for IPv4 + cidr-len-ipv4: 32 + + # how wide the cidr should be for IPv6 + cidr-len-ipv6: 128 + + # how long to keep track of connections for + duration: 10m + + # maximum number of connections, per subnet, within the given duration + max-connections: 12 + + # how long to ban offenders for, and the message to use + # after banning them, the number of connections is reset (which lets you use UNDLINE to unban people) + ban-duration: 10m + ban-message: You have attempted to connect too many times within a short duration. Wait a while, and you will be able to connect. + + # IPs/networks which are exempted from connection limits + exempted: + - "127.0.0.1" + - "127.0.0.1/8" + - "::1/128" + # account/channel registration registration: # account registration