diff --git a/conventional.yaml b/conventional.yaml index 1a976932..ddabe3c6 100644 --- a/conventional.yaml +++ b/conventional.yaml @@ -243,6 +243,22 @@ server: # max-concurrent-connections: 128 # max-connections-per-window: 1024 + # pluggable IP ban mechanism, via subprocess invocation + # this can be used to check new connections against a DNSBL, for example + # see the manual for details on how to write an IP ban checking script + ip-check-script: + enabled: false + command: "/usr/local/bin/check-ip-ban" + # constant list of args to pass to the command; the actual query + # and result are transmitted over stdin/stdout: + args: [] + # timeout for process execution, after which we send a SIGTERM: + timeout: 9s + # how long after the SIGTERM before we follow up with a SIGKILL: + kill-timeout: 1s + # how many scripts are allowed to run at once? 0 for no limit: + max-concurrency: 64 + # IP cloaking hides users' IP addresses from other users and from channel admins # (but not from server admins), while still allowing channel admins to ban # offending IP addresses or networks. In place of hostnames derived from reverse @@ -483,6 +499,8 @@ accounts: timeout: 9s # how long after the SIGTERM before we follow up with a SIGKILL: kill-timeout: 1s + # how many scripts are allowed to run at once? 0 for no limit: + max-concurrency: 64 # channel options channels: diff --git a/default.yaml b/default.yaml index 519dc331..c6d5e8ce 100644 --- a/default.yaml +++ b/default.yaml @@ -270,6 +270,22 @@ server: # max-concurrent-connections: 128 # max-connections-per-window: 1024 + # pluggable IP ban mechanism, via subprocess invocation + # this can be used to check new connections against a DNSBL, for example + # see the manual for details on how to write an IP ban checking script + ip-check-script: + enabled: false + command: "/usr/local/bin/check-ip-ban" + # constant list of args to pass to the command; the actual query + # and result are transmitted over stdin/stdout: + args: [] + # timeout for process execution, after which we send a SIGTERM: + timeout: 9s + # how long after the SIGTERM before we follow up with a SIGKILL: + kill-timeout: 1s + # how many scripts are allowed to run at once? 0 for no limit: + max-concurrency: 64 + # IP cloaking hides users' IP addresses from other users and from channel admins # (but not from server admins), while still allowing channel admins to ban # offending IP addresses or networks. In place of hostnames derived from reverse @@ -511,6 +527,8 @@ accounts: timeout: 9s # how long after the SIGTERM before we follow up with a SIGKILL: kill-timeout: 1s + # how many scripts are allowed to run at once? 0 for no limit: + max-concurrency: 64 # channel options channels: diff --git a/irc/accounts.go b/irc/accounts.go index e1e5c54c..7a408658 100644 --- a/irc/accounts.go +++ b/irc/accounts.go @@ -1095,7 +1095,7 @@ func (am *AccountManager) AuthenticateByPassphrase(client *Client, accountName s config := am.server.Config() if config.Accounts.AuthScript.Enabled { var output AuthScriptOutput - output, err = CheckAuthScript(config.Accounts.AuthScript, + output, err = CheckAuthScript(am.server.semaphores.AuthScript, config.Accounts.AuthScript.ScriptConfig, AuthScriptInput{AccountName: accountName, Passphrase: passphrase, IP: client.IP().String()}) if err != nil { am.server.logger.Error("internal", "failed shell auth invocation", err.Error()) @@ -1494,7 +1494,7 @@ func (am *AccountManager) AuthenticateByCertFP(client *Client, certfp, authzid s config := am.server.Config() if config.Accounts.AuthScript.Enabled { var output AuthScriptOutput - output, err = CheckAuthScript(config.Accounts.AuthScript, + output, err = CheckAuthScript(am.server.semaphores.AuthScript, config.Accounts.AuthScript.ScriptConfig, AuthScriptInput{Certfp: certfp, IP: client.IP().String()}) if err != nil { am.server.logger.Error("internal", "failed shell auth invocation", err.Error()) diff --git a/irc/authscript.go b/irc/authscript.go index 4bf6bb1e..38083a6e 100644 --- a/irc/authscript.go +++ b/irc/authscript.go @@ -4,13 +4,11 @@ package irc import ( - "bufio" "encoding/json" "fmt" - "io" - "os/exec" - "syscall" - "time" + "net" + + "github.com/oragono/oragono/irc/utils" ) // JSON-serializable input and output types for the script @@ -27,84 +25,77 @@ type AuthScriptOutput struct { Error string `json:"error"` } -// internal tupling of output and error for passing over a channel -type authScriptResponse struct { - output AuthScriptOutput - err error -} +func CheckAuthScript(sem utils.Semaphore, config ScriptConfig, input AuthScriptInput) (output AuthScriptOutput, err error) { + if sem != nil { + sem.Acquire() + defer sem.Release() + } -func CheckAuthScript(config AuthScriptConfig, input AuthScriptInput) (output AuthScriptOutput, err error) { inputBytes, err := json.Marshal(input) if err != nil { return } - cmd := exec.Command(config.Command, config.Args...) - stdin, err := cmd.StdinPipe() + outBytes, err := RunScript(config.Command, config.Args, inputBytes, config.Timeout, config.KillTimeout) if err != nil { return } - stdout, err := cmd.StdoutPipe() + err = json.Unmarshal(outBytes, &output) if err != nil { return } - channel := make(chan authScriptResponse, 1) - err = cmd.Start() - if err != nil { - return + if output.Error != "" { + err = fmt.Errorf("Authentication process reported error: %s", output.Error) } - stdin.Write(inputBytes) - stdin.Write([]byte{'\n'}) - - // lots of potential race conditions here. we want to ensure that Wait() - // will be called, and will return, on the other goroutine, no matter - // where it is blocked. If it's blocked on ReadBytes(), we will kill it - // (first with SIGTERM, then with SIGKILL) and ReadBytes will return - // with EOF. If it's blocked on Wait(), then one of the kill signals - // will succeed and unblock it. - go processAuthScriptOutput(cmd, stdout, channel) - outputTimer := time.NewTimer(config.Timeout) - select { - case response := <-channel: - return response.output, response.err - case <-outputTimer.C: - } - - err = errTimedOut - cmd.Process.Signal(syscall.SIGTERM) - termTimer := time.NewTimer(config.Timeout) - select { - case <-channel: - return - case <-termTimer.C: - } - - cmd.Process.Kill() return } -func processAuthScriptOutput(cmd *exec.Cmd, stdout io.Reader, channel chan authScriptResponse) { - var response authScriptResponse - var out AuthScriptOutput +type IPScriptResult uint - reader := bufio.NewReader(stdout) - outBytes, err := reader.ReadBytes('\n') - if err == nil { - err = json.Unmarshal(outBytes, &out) - if err == nil { - response.output = out - if out.Error != "" { - err = fmt.Errorf("Authentication process reported error: %s", out.Error) - } - } - } - response.err = err +const ( + IPNotChecked IPScriptResult = 0 + IPAccepted IPScriptResult = 1 + IPBanned IPScriptResult = 2 + IPRequireSASL IPScriptResult = 3 +) - // always call Wait() to ensure resource cleanup - err = cmd.Wait() - if err != nil { - response.err = err - } - - channel <- response +type IPScriptInput struct { + IP string `json:"ip"` +} + +type IPScriptOutput struct { + Result IPScriptResult `json:"result"` + BanMessage string `json:"banMessage"` + // for caching: the network to which this result is applicable, and a TTL in seconds: + CacheNet string `json:"cacheNet"` + CacheSeconds int `json:"cacheSeconds"` + Error string `json:"error"` +} + +func CheckIPBan(sem utils.Semaphore, config ScriptConfig, addr net.IP) (output IPScriptOutput, err error) { + if sem != nil { + sem.Acquire() + defer sem.Release() + } + + inputBytes, err := json.Marshal(IPScriptInput{IP: addr.String()}) + if err != nil { + return + } + outBytes, err := RunScript(config.Command, config.Args, inputBytes, config.Timeout, config.KillTimeout) + if err != nil { + return + } + err = json.Unmarshal(outBytes, &output) + if err != nil { + return + } + + if output.Error != "" { + err = fmt.Errorf("IP ban process reported error: %s", output.Error) + } else if !(IPAccepted <= output.Result && output.Result <= IPRequireSASL) { + err = fmt.Errorf("Invalid result from IP checking script: %d", output.Result) + } + + return } diff --git a/irc/client.go b/irc/client.go index ab24c6b7..090ab35b 100644 --- a/irc/client.go +++ b/irc/client.go @@ -101,6 +101,7 @@ type Client struct { cloakedHostname string realname string realIP net.IP + requireSASL bool registered bool registrationTimer *time.Timer resumeID string @@ -297,8 +298,9 @@ type ClientDetails struct { // RunClient sets up a new client and runs its goroutine. func (server *Server) RunClient(conn IRCConn) { + config := server.Config() wConn := conn.UnderlyingConn() - var isBanned bool + var isBanned, requireSASL bool var banMsg string realIP := utils.AddrToIP(wConn.RemoteAddr()) var proxiedIP net.IP @@ -313,7 +315,10 @@ func (server *Server) RunClient(conn IRCConn) { proxiedIP = wConn.ProxiedIP ipToCheck = proxiedIP } - isBanned, banMsg = server.checkBans(ipToCheck) + // XXX only run the check script now if the IP cannot be replaced by PROXY or WEBIRC, + // otherwise we'll do it in ApplyProxiedIP. + checkScripts := proxiedIP != nil || !utils.IPInNets(realIP, config.Server.proxyAllowedFromNets) + isBanned, requireSASL, banMsg = server.checkBans(config, ipToCheck, checkScripts) } if isBanned { @@ -327,7 +332,6 @@ func (server *Server) RunClient(conn IRCConn) { server.logger.Info("connect-ip", fmt.Sprintf("Client connecting: real IP %v, proxied IP %v", realIP, proxiedIP)) now := time.Now().UTC() - config := server.Config() // give them 1k of grace over the limit: socket := NewSocket(conn, config.Server.MaxSendQBytes) client := &Client{ @@ -347,6 +351,7 @@ func (server *Server) RunClient(conn IRCConn) { nickMaskString: "*", // * is used until actual nick is given realIP: realIP, proxiedIP: proxiedIP, + requireSASL: requireSASL, } client.writerSemaphore.Initialize(1) client.history.Initialize(config.History.ClientLength, time.Duration(config.History.AutoresizeWindow)) @@ -554,7 +559,7 @@ const ( authFailSaslRequired ) -func (client *Client) isAuthorized(server *Server, config *Config, session *Session) AuthOutcome { +func (client *Client) isAuthorized(server *Server, config *Config, session *Session, forceRequireSASL bool) AuthOutcome { saslSent := client.account != "" // PASS requirement if (config.Server.passwordBytes != nil) && session.passStatus != serverPassSuccessful && !(config.Accounts.SkipServerPassword && saslSent) { @@ -565,7 +570,7 @@ func (client *Client) isAuthorized(server *Server, config *Config, session *Sess return authFailTorSaslRequired } // finally, enforce require-sasl - if !saslSent && (config.Accounts.RequireSasl.Enabled || server.Defcon() <= 2) && + if !saslSent && (forceRequireSASL || config.Accounts.RequireSasl.Enabled || server.Defcon() <= 2) && !utils.IPInNets(session.IP(), config.Accounts.RequireSasl.exemptedNets) { return authFailSaslRequired } diff --git a/irc/config.go b/irc/config.go index e04e0e4f..c0d94656 100644 --- a/irc/config.go +++ b/irc/config.go @@ -282,13 +282,18 @@ type AccountConfig struct { AuthScript AuthScriptConfig `yaml:"auth-script"` } +type ScriptConfig struct { + Enabled bool + Command string + Args []string + Timeout time.Duration + KillTimeout time.Duration `yaml:"kill-timeout"` + MaxConcurrency uint `yaml:"max-concurrency"` +} + type AuthScriptConfig struct { - Enabled bool - Command string - Args []string - Autocreate bool - Timeout time.Duration - KillTimeout time.Duration `yaml:"kill-timeout"` + ScriptConfig `yaml:",inline"` + Autocreate bool } // AccountRegistrationConfig controls account registration. @@ -526,8 +531,9 @@ type Config struct { supportedCaps *caps.Set capValues caps.Values Casemapping Casemapping - EnforceUtf8 bool `yaml:"enforce-utf8"` - OutputPath string `yaml:"output-path"` + EnforceUtf8 bool `yaml:"enforce-utf8"` + OutputPath string `yaml:"output-path"` + IPCheckScript ScriptConfig `yaml:"ip-check-script"` } Roleplay struct { diff --git a/irc/gateways.go b/irc/gateways.go index f7238b75..2997143f 100644 --- a/irc/gateways.go +++ b/irc/gateways.go @@ -77,10 +77,11 @@ func (client *Client) ApplyProxiedIP(session *Session, proxiedIP net.IP, tls boo } proxiedIP = proxiedIP.To16() - isBanned, banMsg := client.server.checkBans(proxiedIP) + isBanned, requireSASL, banMsg := client.server.checkBans(client.server.Config(), proxiedIP, true) if isBanned { return errBanned, banMsg } + client.requireSASL = requireSASL // successfully added a limiter entry for the proxied IP; // remove the entry for the real IP if applicable (#197) client.server.connectionLimiter.RemoveClient(session.realIP) diff --git a/irc/script.go b/irc/script.go new file mode 100644 index 00000000..b1511f63 --- /dev/null +++ b/irc/script.go @@ -0,0 +1,83 @@ +// Copyright (c) 2020 Shivaram Lingamneni +// released under the MIT license + +package irc + +import ( + "bufio" + "io" + "os/exec" + "syscall" + "time" +) + +// general-purpose scripting API for oragono "plugins" +// invoke a command, send it a single newline-terminated string of bytes (typically JSON) +// get back another newline-terminated string of bytes (or an error) + +// internal tupling of output and error for passing over a channel +type scriptResponse struct { + output []byte + err error +} + +func RunScript(command string, args []string, input []byte, timeout, killTimeout time.Duration) (output []byte, err error) { + cmd := exec.Command(command, args...) + stdin, err := cmd.StdinPipe() + if err != nil { + return + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return + } + + channel := make(chan scriptResponse, 1) + err = cmd.Start() + if err != nil { + return + } + stdin.Write(input) + stdin.Write([]byte{'\n'}) + + // lots of potential race conditions here. we want to ensure that Wait() + // will be called, and will return, on the other goroutine, no matter + // where it is blocked. If it's blocked on ReadBytes(), we will kill it + // (first with SIGTERM, then with SIGKILL) and ReadBytes will return + // with EOF. If it's blocked on Wait(), then one of the kill signals + // will succeed and unblock it. + go processScriptOutput(cmd, stdout, channel) + outputTimer := time.NewTimer(timeout) + select { + case response := <-channel: + return response.output, response.err + case <-outputTimer.C: + } + + err = errTimedOut + cmd.Process.Signal(syscall.SIGTERM) + termTimer := time.NewTimer(killTimeout) + select { + case <-channel: + return + case <-termTimer.C: + } + + cmd.Process.Kill() + return +} + +func processScriptOutput(cmd *exec.Cmd, stdout io.Reader, channel chan scriptResponse) { + var response scriptResponse + + reader := bufio.NewReader(stdout) + response.output, response.err = reader.ReadBytes('\n') + + // always call Wait() to ensure resource cleanup + err := cmd.Wait() + if err != nil { + response.err = err + } + + channel <- response +} diff --git a/irc/semaphores.go b/irc/semaphores.go index 1ee4df2c..2aa5a165 100644 --- a/irc/semaphores.go +++ b/irc/semaphores.go @@ -27,6 +27,8 @@ type ServerSemaphores struct { // each distinct operation MUST have its own semaphore; // methods that acquire a semaphore MUST NOT call methods that acquire another ClientDestroy utils.Semaphore + IPCheckScript utils.Semaphore + AuthScript utils.Semaphore } // Initialize initializes a set of server semaphores. diff --git a/irc/server.go b/irc/server.go index 4b178f18..6c9ac5cb 100644 --- a/irc/server.go +++ b/irc/server.go @@ -150,10 +150,10 @@ func (server *Server) Run() { } } -func (server *Server) checkBans(ipaddr net.IP) (banned bool, message string) { +func (server *Server) checkBans(config *Config, ipaddr net.IP, checkScripts bool) (banned bool, requireSASL bool, message string) { if server.Defcon() == 1 { if !(ipaddr.IsLoopback() || utils.IPInNets(ipaddr, server.Config().Server.secureNets)) { - return true, "New connections to this server are temporarily restricted" + return true, false, "New connections to this server are temporarily restricted" } } @@ -161,7 +161,7 @@ func (server *Server) checkBans(ipaddr net.IP) (banned bool, message string) { isBanned, info := server.dlines.CheckIP(ipaddr) if isBanned { server.logger.Info("connect-ip", fmt.Sprintf("Client from %v rejected by d-line", ipaddr)) - return true, info.BanMessage("You are banned from this server (%s)") + return true, false, info.BanMessage("You are banned from this server (%s)") } // check connection limits @@ -169,27 +169,55 @@ func (server *Server) checkBans(ipaddr net.IP) (banned bool, message string) { if err == connection_limits.ErrLimitExceeded { // too many connections from one client, tell the client and close the connection server.logger.Info("connect-ip", fmt.Sprintf("Client from %v rejected for connection limit", ipaddr)) - return true, "Too many clients from your network" + return true, false, "Too many clients from your network" } else if err == connection_limits.ErrThrottleExceeded { - duration := server.Config().Server.IPLimits.BanDuration - if duration == 0 { - return false, "" + duration := config.Server.IPLimits.BanDuration + if duration != 0 { + server.dlines.AddIP(ipaddr, duration, throttleMessage, + "Exceeded automated connection throttle", "auto.connection.throttler") + // they're DLINE'd for 15 minutes or whatever, so we can reset the connection throttle now, + // and once their temporary DLINE is finished they can fill up the throttler again + server.connectionLimiter.ResetThrottle(ipaddr) } - server.dlines.AddIP(ipaddr, duration, throttleMessage, "Exceeded automated connection throttle", "auto.connection.throttler") - // they're DLINE'd for 15 minutes or whatever, so we can reset the connection throttle now, - // and once their temporary DLINE is finished they can fill up the throttler again - server.connectionLimiter.ResetThrottle(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 server.logger.Info( "connect-ip", fmt.Sprintf("Client from %v exceeded connection throttle, d-lining for %v", ipaddr, duration)) - return true, throttleMessage + return true, false, throttleMessage } else if err != nil { server.logger.Warning("internal", "unexpected ban result", err.Error()) } - return false, "" + if checkScripts && config.Server.IPCheckScript.Enabled { + output, err := CheckIPBan(server.semaphores.IPCheckScript, config.Server.IPCheckScript, ipaddr) + if err != nil { + server.logger.Error("internal", "couldn't check IP ban script", ipaddr.String(), err.Error()) + return false, false, "" + } + // TODO: currently no way to cache results other than IPBanned + if output.Result == IPBanned && output.CacheSeconds != 0 { + network, err := utils.NormalizedNetFromString(output.CacheNet) + if err != nil { + server.logger.Error("internal", "invalid dline net from IP ban script", ipaddr.String(), output.CacheNet) + } else { + dlineDuration := time.Duration(output.CacheSeconds) * time.Second + err := server.dlines.AddNetwork(network, dlineDuration, output.BanMessage, "", "") + if err != nil { + server.logger.Error("internal", "couldn't set dline from IP ban script", ipaddr.String(), err.Error()) + } + } + } + if output.Result == IPBanned { + // XXX roll back IP connection/throttling addition for the IP + server.connectionLimiter.RemoveClient(ipaddr) + server.logger.Info("connect-ip", "Rejected client due to ip-check-script", ipaddr.String()) + return true, false, output.BanMessage + } else if output.Result == IPRequireSASL { + server.logger.Info("connect-ip", "Requiring SASL from client due to ip-check-script", ipaddr.String()) + return false, true, "" + } + } + + return false, false, "" } func (server *Server) checkTorLimits() (banned bool, message string) { @@ -214,6 +242,12 @@ func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) { return // whether we succeeded or failed, either way `c` is not getting registered } + // XXX PROXY or WEBIRC MUST be sent as the first line of the session; + // if we are here at all that means we have the final value of the IP + if session.rawHostname == "" { + session.client.lookupHostname(session, false) + } + // try to complete registration normally // XXX(#1057) username can be filled in by an ident query without the client // having sent USER: check for both username and realname to ensure they did @@ -229,7 +263,7 @@ func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) { // client MUST send PASS if necessary, or authenticate with SASL if necessary, // before completing the other registration commands config := server.Config() - authOutcome := c.isAuthorized(server, config, session) + authOutcome := c.isAuthorized(server, config, session, c.requireSASL) var quitMessage string switch authOutcome { case authFailPass: @@ -244,12 +278,6 @@ func (server *Server) tryRegister(c *Client, session *Session) (exiting bool) { return true } - // we have the final value of the IP address: do the hostname lookup - // (nickmask will be set below once nickname assignment succeeds) - if session.rawHostname == "" { - session.client.lookupHostname(session, false) - } - rb := NewResponseBuffer(session) nickError := performNickChange(server, c, c, session, c.preregNick, rb) rb.Send(true) @@ -489,6 +517,9 @@ func (server *Server) applyConfig(config *Config) (err error) { return fmt.Errorf("Cannot enable or disable relaying after launching the server, rehash aborted") } else if oldConfig.Server.Relaymsg.Separators != config.Server.Relaymsg.Separators { return fmt.Errorf("Cannot change relaying separators after launching the server, rehash aborted") + } else if oldConfig.Server.IPCheckScript.MaxConcurrency != config.Server.IPCheckScript.MaxConcurrency || + oldConfig.Accounts.AuthScript.MaxConcurrency != config.Accounts.AuthScript.MaxConcurrency { + return fmt.Errorf("Cannot change max-concurrency for scripts after launching the server, rehash aborted") } } @@ -513,6 +544,17 @@ func (server *Server) applyConfig(config *Config) (err error) { server.logger.Debug("server", "Regenerating HELP indexes for new languages") server.helpIndexManager.GenerateIndices(config.languageManager) + if initial { + maxIPConc := int(config.Server.IPCheckScript.MaxConcurrency) + if maxIPConc != 0 { + server.semaphores.IPCheckScript.Initialize(maxIPConc) + } + maxAuthConc := int(config.Accounts.AuthScript.MaxConcurrency) + if maxAuthConc != 0 { + server.semaphores.AuthScript.Initialize(maxAuthConc) + } + } + if oldConfig != nil { // if certain features were enabled by rehash, we need to load the corresponding data // from the store