From 5863040f9323e210df0908333c6e85d63af000c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=99emysl=20Eric=20Janouch?= Date: Mon, 26 Sep 2022 12:39:26 +0200 Subject: Update documentation, clean up --- NEWS | 2 + README.adoc | 5 +- xD-gen-replies.awk | 10 +- xP/Makefile | 7 +- xS/.gitignore | 2 + xS/Makefile | 13 + xS/go.mod | 3 + xS/main.go | 3529 ------------------------------------------------- xS/main_test.go | 168 --- xS/xS-gen-replies.awk | 14 +- xS/xS.go | 3527 ++++++++++++++++++++++++++++++++++++++++++++++++ xS/xS_test.go | 168 +++ 12 files changed, 3736 insertions(+), 3712 deletions(-) create mode 100644 xS/.gitignore create mode 100644 xS/Makefile create mode 100644 xS/go.mod delete mode 100644 xS/main.go delete mode 100644 xS/main_test.go create mode 100644 xS/xS.go create mode 100644 xS/xS_test.go diff --git a/NEWS b/NEWS index 1d3cd4a..803dc03 100644 --- a/NEWS +++ b/NEWS @@ -29,6 +29,8 @@ * Added a web frontend for xC called xP + * Added a Go port of xD called xS + 1.5.0 (2021-12-21) "The Show Must Go On" diff --git a/README.adoc b/README.adoc index 3c09ba7..8d61855 100644 --- a/README.adoc +++ b/README.adoc @@ -47,8 +47,9 @@ What it notably doesn't support is online changes to configuration, any limits besides the total number of connections and mode `+l`, or server linking (which also means no services). -This program has been https://git.janouch.name/p/haven/src/branch/master/hid[ -ported to Go] in a different project, and development continues over there. +xS +-- +The IRC daemon again, this time ported to Go, additionally supporting WEBIRC. xB -- diff --git a/xD-gen-replies.awk b/xD-gen-replies.awk index c9e8882..015e743 100755 --- a/xD-gen-replies.awk +++ b/xD-gen-replies.awk @@ -2,14 +2,14 @@ BEGIN { # The message catalog is a by-product msg = "xD.msg" - print "$quote \"" > msg; - print "$set 1" > msg; + print "$quote \"" > msg + print "$set 1" > msg } /^[0-9]+ *IRC_(ERR|RPL)_[A-Z]+ *".*"$/ { - match($0, /".*"/); - ids[$1] = $2; - texts[$2] = substr($0, RSTART, RLENGTH); + match($0, /".*"/) + ids[$1] = $2 + texts[$2] = substr($0, RSTART, RLENGTH) print $1 " " texts[$2] > msg } diff --git a/xP/Makefile b/xP/Makefile index 34de55a..c0198aa 100644 --- a/xP/Makefile +++ b/xP/Makefile @@ -1,5 +1,6 @@ .POSIX: .SUFFIXES: +AWK = env LC_ALL=C awk outputs = xP proto.go public/proto.js public/mithril.js all: $(outputs) public/ircfmt.woff2 @@ -7,11 +8,11 @@ all: $(outputs) public/ircfmt.woff2 xP: xP.go proto.go go build -o $@ proto.go: ../xC-gen-proto.awk ../xC-gen-proto-go.awk ../xC-proto - awk -f ../xC-gen-proto.awk -f ../xC-gen-proto-go.awk ../xC-proto > $@ + $(AWK) -f ../xC-gen-proto.awk -f ../xC-gen-proto-go.awk ../xC-proto > $@ public/proto.js: ../xC-gen-proto.awk ../xC-gen-proto-js.awk ../xC-proto - awk -f ../xC-gen-proto.awk -f ../xC-gen-proto-js.awk ../xC-proto > $@ + $(AWK) -f ../xC-gen-proto.awk -f ../xC-gen-proto-js.awk ../xC-proto > $@ public/ircfmt.woff2: gen-ircfmt.awk - awk -v Output=$@ -f gen-ircfmt.awk + $(AWK) -v Output=$@ -f gen-ircfmt.awk public/mithril.js: curl -Lo $@ https://unpkg.com/mithril/mithril.js clean: diff --git a/xS/.gitignore b/xS/.gitignore new file mode 100644 index 0000000..4f7b84d --- /dev/null +++ b/xS/.gitignore @@ -0,0 +1,2 @@ +/xS +/xS-replies.go diff --git a/xS/Makefile b/xS/Makefile new file mode 100644 index 0000000..55ad4d2 --- /dev/null +++ b/xS/Makefile @@ -0,0 +1,13 @@ +.POSIX: +.SUFFIXES: +AWK = env LC_ALL=C awk + +outputs = xS xS-replies.go +all: $(outputs) + +xS: xS.go xS-replies.go + go build -o $@ +xS-replies.go: xS-gen-replies.awk xS-replies + $(AWK) -f xS-gen-replies.awk xS-replies > $@ +clean: + rm -f $(outputs) diff --git a/xS/go.mod b/xS/go.mod new file mode 100644 index 0000000..9752c95 --- /dev/null +++ b/xS/go.mod @@ -0,0 +1,3 @@ +module janouch.name/xK/xS + +go 1.19 diff --git a/xS/main.go b/xS/main.go deleted file mode 100644 index 21851f1..0000000 --- a/xS/main.go +++ /dev/null @@ -1,3529 +0,0 @@ -// -// Copyright (c) 2014 - 2022, Přemysl Eric Janouch -// -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY -// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION -// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN -// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -// - -// xS is a straight-forward port of xD IRCd from C. -package main - -import ( - "bufio" - "bytes" - "crypto/sha256" - "crypto/tls" - "encoding/hex" - "errors" - "flag" - "fmt" - "io" - "io/ioutil" - "log/syslog" - "net" - "os" - "os/signal" - "os/user" - "path/filepath" - "regexp" - "strconv" - "strings" - "syscall" - "time" -) - -var debugMode = false - -const ( - projectName = "xS" - // TODO: Consider using the same version number for all subprojects. - projectVersion = "0" -) - -// --- Logging ----------------------------------------------------------------- - -type logPrio int - -const ( - prioFatal logPrio = iota - prioError - prioWarning - prioStatus - prioDebug -) - -func (lp logPrio) prefix() string { - switch lp { - case prioFatal: - return "fatal: " - case prioError: - return "error: " - case prioWarning: - return "warning: " - case prioStatus: - return "" - case prioDebug: - return "debug: " - default: - panic("unhandled log priority") - } -} - -func (lp logPrio) syslogPrio() syslog.Priority { - switch lp { - case prioFatal: - return syslog.LOG_ERR - case prioError: - return syslog.LOG_ERR - case prioWarning: - return syslog.LOG_WARNING - case prioStatus: - return syslog.LOG_INFO - case prioDebug: - return syslog.LOG_DEBUG - default: - panic("unhandled log priority") - } -} - -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -func logMessageStdio(prio logPrio, format string, args ...interface{}) { - // TODO: isatty-enabled colors based on prio. - os.Stderr.WriteString(time.Now().Format("2006-01-02 15:04:05 ") + - prio.prefix() + fmt.Sprintf(format, args...) + "\n") -} - -func logMessageSystemd(prio logPrio, format string, args ...interface{}) { - if prio == prioFatal { - // There is no corresponding syslog severity. - format = "fatal: " + format - } - fmt.Fprintf(os.Stderr, "<%d>%s\n", - prio.syslogPrio(), fmt.Sprintf(format, args...)) -} - -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -var logMessage = logMessageStdio - -func printDebug(format string, args ...interface{}) { - if debugMode { - logMessage(prioDebug, format, args...) - } -} - -func printStatus(format string, args ...interface{}) { - logMessage(prioStatus, format, args...) -} -func printWarning(format string, args ...interface{}) { - logMessage(prioWarning, format, args...) -} -func printError(format string, args ...interface{}) { - logMessage(prioError, format, args...) -} - -// "fatal" is reserved for failures that would harm further operation. - -func printFatal(format string, args ...interface{}) { - logMessage(prioFatal, format, args...) -} - -func exitFatal(format string, args ...interface{}) { - printFatal(format, args...) - os.Exit(1) -} - -// --- Utilities --------------------------------------------------------------- - -// Split a string by a set of UTF-8 delimiters, optionally ignoring empty items. -func splitString(s, delims string, ignoreEmpty bool) (result []string) { - for { - end := strings.IndexAny(s, delims) - if end < 0 { - break - } - if !ignoreEmpty || end != 0 { - result = append(result, s[:end]) - } - s = s[end+1:] - } - if !ignoreEmpty || s != "" { - result = append(result, s) - } - return -} - -// Trivial SSL/TLS autodetection. The first block of data returned by Recvfrom -// must be at least three octets long for this to work reliably, but that should -// not pose a problem in practice. We might try waiting for them. -// -// SSL2: 1xxx xxxx | xxxx xxxx | <1> -// (message length) (client hello) -// SSL3/TLS: <22> | <3> | xxxx xxxx -// (handshake)| (protocol version) -// -// Note that Go 1.12's crypto/tls offers a slightly more straight-forward -// solution: "If a client sends an initial message that does not look like TLS, -// the server will no longer reply with an alert, and it will expose the -// underlying net.Conn in the new field Conn of RecordHeaderError." -func detectTLS(sysconn syscall.RawConn) (isTLS bool) { - sysconn.Read(func(fd uintptr) (done bool) { - var buf [3]byte - n, _, err := syscall.Recvfrom(int(fd), buf[:], syscall.MSG_PEEK) - switch { - case n == 3: - isTLS = buf[0]&0x80 != 0 && buf[2] == 1 - fallthrough - case n == 2: - isTLS = isTLS || buf[0] == 22 && buf[1] == 3 - case n == 1: - isTLS = buf[0] == 22 - case err == syscall.EAGAIN: - return false - } - return true - }) - return isTLS -} - -// --- File system ------------------------------------------------------------- - -// Look up the value of an XDG path from environment, or fall back to a default. -func getXDGHomeDir(name, def string) string { - env := os.Getenv(name) - if env != "" && env[0] == filepath.Separator { - return env - } - - home := "" - if v, ok := os.LookupEnv("HOME"); ok { - home = v - } else if u, _ := user.Current(); u != nil { - home = u.HomeDir - } - return filepath.Join(home, def) -} - -func resolveRelativeFilenameGeneric(paths []string, filename string) string { - for _, path := range paths { - // As per XDG spec, relative paths are ignored. - if path == "" || path[0] != filepath.Separator { - continue - } - - file := filepath.Join(path, filename) - if _, err := os.Stat(file); err == nil { - return file - } - } - return "" -} - -// Retrieve all XDG base directories for configuration files. -func getXDGConfigDirs() (result []string) { - home := getXDGHomeDir("XDG_CONFIG_HOME", ".config") - if home != "" { - result = append(result, home) - } - dirs := os.Getenv("XDG_CONFIG_DIRS") - if dirs == "" { - dirs = "/etc/xdg" - } - for _, path := range strings.Split(dirs, ":") { - if path != "" { - result = append(result, path) - } - } - return -} - -func resolveRelativeConfigFilename(filename string) string { - return resolveRelativeFilenameGeneric(getXDGConfigDirs(), - filepath.Join(projectName, filename)) -} - -func findTildeHome(username string) string { - if username != "" { - if u, _ := user.Lookup(username); u != nil { - return u.HomeDir - } - } else if u, _ := user.Current(); u != nil { - return u.HomeDir - } else if v, ok := os.LookupEnv("HOME"); ok { - return v - } - printDebug("failed to expand the home directory for %s", username) - return "~" + username -} - -func resolveFilename(filename string, relativeCB func(string) string) string { - // Absolute path is absolute. - if filename == "" || filename[0] == filepath.Separator { - return filename - } - if filename[0] != '~' { - return relativeCB(filename) - } - - // Paths to home directories ought to be absolute. - var n int - for n = 0; n < len(filename); n++ { - if filename[n] == filepath.Separator { - break - } - } - return findTildeHome(filename[1:n]) + filename[n:] -} - -// --- Simple file I/O --------------------------------------------------------- - -// Overwrites filename contents with data; creates directories as needed. -func writeFile(path string, data []byte) error { - if dir := filepath.Dir(path); dir != "." { - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - } - return ioutil.WriteFile(path, data, 0644) -} - -// Wrapper for writeFile that makes sure that the new data has been written -// to disk in its entirety before overriding the old file. -func writeFileSafe(path string, data []byte) error { - temp := path + ".new" - if err := writeFile(temp, data); err != nil { - return err - } - return os.Rename(temp, path) -} - -// --- Simple configuration ---------------------------------------------------- - -// This is the bare minimum to make an application configurable. -// Keys are stripped of surrounding whitespace, values are not. - -type simpleConfigItem struct { - key string // INI key - def string // default value - description string // documentation -} - -type simpleConfig map[string]string - -func (sc simpleConfig) loadDefaults(table []simpleConfigItem) { - for _, item := range table { - sc[item.key] = item.def - } -} - -func (sc simpleConfig) updateFromFile() error { - basename := projectName + ".conf" - path := resolveFilename(basename, resolveRelativeConfigFilename) - if path == "" { - return &os.PathError{ - Op: "cannot find", - Path: basename, - Err: os.ErrNotExist, - } - } - - f, err := os.Open(path) - if err != nil { - return err - } - defer f.Close() - - scanner := bufio.NewScanner(f) - for lineNo := 1; scanner.Scan(); lineNo++ { - line := strings.TrimLeft(scanner.Text(), " \t") - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - equals := strings.IndexByte(line, '=') - if equals <= 0 { - return fmt.Errorf("%s:%d: malformed line", path, lineNo) - } - - sc[strings.TrimRight(line[:equals], " \t")] = line[equals+1:] - } - return scanner.Err() -} - -func writeConfigurationFile(pathHint string, data []byte) (string, error) { - path := pathHint - if path == "" { - path = filepath.Join(getXDGHomeDir("XDG_CONFIG_HOME", ".config"), - projectName, projectName+".conf") - } - - if err := writeFileSafe(path, data); err != nil { - return "", err - } - return path, nil -} - -func simpleConfigWriteDefault(pathHint string, prolog string, - table []simpleConfigItem) (string, error) { - data := []byte(prolog) - for _, item := range table { - data = append(data, fmt.Sprintf("# %s\n%s=%s\n", - item.description, item.key, item.def)...) - } - return writeConfigurationFile(pathHint, data) -} - -// Convenience wrapper suitable for most simple applications. -func callSimpleConfigWriteDefault(pathHint string, table []simpleConfigItem) { - prologLines := []string{ - `# ` + projectName + ` ` + projectVersion + ` configuration file`, - "#", - `# Relative paths are searched for in ${XDG_CONFIG_HOME:-~/.config}`, - `# /` + projectName + ` as well as in $XDG_CONFIG_DIRS/` + projectName, - ``, - ``, - } - - path, err := simpleConfigWriteDefault( - pathHint, strings.Join(prologLines, "\n"), table) - if err != nil { - exitFatal("%s", err) - } - - printStatus("configuration written to `%s'", path) -} - -// --- Configuration ----------------------------------------------------------- - -var configTable = []simpleConfigItem{ - {"server_name", "", "Server name"}, - {"server_info", "My server", "Brief server description"}, - {"motd", "", "MOTD filename"}, - {"catalog", "", "Localisation catalog"}, - - {"bind", ":6667", "Bind addresses of the IRC server"}, - {"tls_cert", "", "Server TLS certificate (PEM)"}, - {"tls_key", "", "Server TLS private key (PEM)"}, - {"webirc_password", "", "Password for WebIRC"}, - - {"operators", "", "IRCop TLS certificate SHA-256 fingerprints"}, - - {"max_connections", "0", "Global connection limit"}, - {"ping_interval", "180", "Interval between PINGs (sec)"}, -} - -// --- Rate limiter ------------------------------------------------------------ - -type floodDetector struct { - interval time.Duration // interval for the limit in seconds - limit uint // maximum number of events allowed - timestamps []time.Time // timestamps of last events - pos uint // index of the oldest event -} - -func newFloodDetector(interval time.Duration, limit uint) *floodDetector { - return &floodDetector{ - interval: interval, - limit: limit, - timestamps: make([]time.Time, limit+1), - pos: 0, - } -} - -func (fd *floodDetector) check() bool { - now := time.Now() - fd.timestamps[fd.pos] = now - - fd.pos++ - if fd.pos > fd.limit { - fd.pos = 0 - } - - var count uint - begin := now.Add(-fd.interval) - for _, ts := range fd.timestamps { - if ts.After(begin) { - count++ - } - } - return count <= fd.limit -} - -// --- IRC protocol ------------------------------------------------------------ - -//go:generate sh -c "LC_ALL=C awk -f xS-gen-replies.awk > xS-replies.go < xS-replies" - -func ircToLower(c byte) byte { - switch c { - case '[': - return '{' - case ']': - return '}' - case '\\': - return '|' - case '~': - return '^' - } - if c >= 'A' && c <= 'Z' { - return c + ('a' - 'A') - } - return c -} - -func ircToUpper(c byte) byte { - switch c { - case '{': - return '[' - case '}': - return ']' - case '|': - return '\\' - case '^': - return '~' - } - if c >= 'a' && c <= 'z' { - return c - ('a' - 'A') - } - return c -} - -// Convert identifier to a canonical form for case-insensitive comparisons. -// ircToUpper is used so that statically initialized maps can be in uppercase. -func ircToCanon(ident string) string { - var canon []byte - for _, c := range []byte(ident) { - canon = append(canon, ircToUpper(c)) - } - return string(canon) -} - -func ircEqual(s1, s2 string) bool { - return ircToCanon(s1) == ircToCanon(s2) -} - -func ircFnmatch(pattern string, s string) bool { - pattern, s = ircToCanon(pattern), ircToCanon(s) - // FIXME: This should not support [] ranges and handle '/' specially. - // We could translate the pattern to a regular expression. - matched, _ := filepath.Match(pattern, s) - return matched -} - -var reMsg = regexp.MustCompile( - `^(?:@([^ ]*) +)?(?::([^! ]*)(?:!([^@]*)@([^ ]*))? +)?([^ ]+)(.*)?$`) -var reArgs = regexp.MustCompile(`:.*| [^: ][^ ]*`) - -type message struct { - tags map[string]string // IRC 3.2 message tags - nick string // optional nickname - user string // optional username - host string // optional hostname or IP address - command string // command name - params []string // arguments -} - -func ircUnescapeMessageTag(value string) string { - var buf []byte - escape := false - for i := 0; i < len(value); i++ { - if escape { - switch value[i] { - case ':': - buf = append(buf, ';') - case 's': - buf = append(buf, ' ') - case 'r': - buf = append(buf, '\r') - case 'n': - buf = append(buf, '\n') - default: - buf = append(buf, value[i]) - } - escape = false - } else if value[i] == '\\' { - escape = true - } else { - buf = append(buf, value[i]) - } - } - return string(buf) -} - -func ircParseMessageTags(tags string, out map[string]string) { - for _, tag := range splitString(tags, ";", true /* ignoreEmpty */) { - if equal := strings.IndexByte(tag, '='); equal < 0 { - out[tag] = "" - } else { - out[tag[:equal]] = ircUnescapeMessageTag(tag[equal+1:]) - } - } -} - -func ircParseMessage(line string) *message { - m := reMsg.FindStringSubmatch(line) - if m == nil { - return nil - } - - msg := message{nil, m[2], m[3], m[4], m[5], nil} - if m[1] != "" { - msg.tags = make(map[string]string) - ircParseMessageTags(m[1], msg.tags) - } - for _, x := range reArgs.FindAllString(m[6], -1) { - msg.params = append(msg.params, x[1:]) - } - return &msg - -} - -// --- IRC token validation ---------------------------------------------------- - -// Everything as per RFC 2812 -const ( - ircMaxNickname = 9 - ircMaxHostname = 63 - ircMaxChannelName = 50 - ircMaxMessageLength = 510 -) - -const ( - reClassSpecial = "\\[\\]\\\\`_^{|}" - // "shortname" from RFC 2812 doesn't work how its author thought it would. - reShortname = "[0-9A-Za-z](-*[0-9A-Za-z])*" -) - -var ( - reHostname = regexp.MustCompile( - `^` + reShortname + `(\.` + reShortname + `)*$`) - - // Extending ASCII to the whole range of Unicode letters. - reNickname = regexp.MustCompile( - `^[\pL` + reClassSpecial + `][\pL` + reClassSpecial + `0-9-]*$`) - - // Notably, this won't match invalid UTF-8 characters, although the - // behaviour seems to be unstated in the documentation. - reUsername = regexp.MustCompile(`^[^\0\r\n @]+$`) - - reChannelName = regexp.MustCompile(`^[^\0\007\r\n ,:]+$`) - reKey = regexp.MustCompile(`^[^\r\n\f\t\v ]{1,23}$`) - reUserMask = regexp.MustCompile(`^[^!@]+![^!@]+@[^@!]+$`) - reFingerprint = regexp.MustCompile(`^[a-fA-F0-9]{64}$`) -) - -func ircValidateHostname(hostname string) error { - if hostname == "" { - return errors.New("the value is empty") - } - if !reHostname.MatchString(hostname) { - return errors.New("invalid format") - } - if len(hostname) > ircMaxHostname { - return errors.New("the value is too long") - } - return nil -} - -func ircIsValidNickname(nickname string) bool { - return len(nickname) <= ircMaxNickname && reNickname.MatchString(nickname) -} - -func ircIsValidUsername(username string) bool { - // XXX: We really should put a limit on this - // despite the RFC not mentioning one. - return reUsername.MatchString(username) -} - -func ircIsValidChannelName(name string) bool { - return len(name) <= ircMaxChannelName && reChannelName.MatchString(name) -} - -func ircIsValidKey(key string) bool { - // XXX: Should be 7-bit as well but whatever. - return reKey.MatchString(key) -} - -func ircIsValidUserMask(mask string) bool { - return reUserMask.MatchString(mask) -} - -func ircIsValidFingerprint(fp string) bool { - return reFingerprint.MatchString(fp) -} - -// --- Clients (equals users) -------------------------------------------------- - -type connCloseWriter interface { - net.Conn - CloseWrite() error -} - -const ircSupportedUserModes = "aiwros" - -const ( - ircUserModeInvisible uint = 1 << iota - ircUserModeRxWallops - ircUserModeRestricted - ircUserModeOperator - ircUserModeRxServerNotices -) - -const ( - ircCapMultiPrefix uint = 1 << iota - ircCapInviteNotify - ircCapEchoMessage - ircCapUserhostInNames - ircCapServerTime -) - -type client struct { - transport net.Conn // underlying connection - tls *tls.Conn // TLS, if detected - conn connCloseWriter // high-level connection - recvQ []byte // unprocessed input - sendQ []byte // unprocessed output - reading bool // whether a reading goroutine is running - writing bool // whether a writing goroutine is running - closing bool // whether we're closing the connection - killTimer *time.Timer // hard kill timeout - - opened time.Time // when the connection was opened - nSentMessages uint // number of sent messages total - sentBytes int // number of sent bytes total - nReceivedMessages uint // number of received messages total - receivedBytes int // number of received bytes total - - hostname string // hostname or IP shown to the network - port string // port of the peer as a string - address string // full network address - - pingTimer *time.Timer // we should send a PING - timeoutTimer *time.Timer // connection seems to be dead - registered bool // the user has registered - capNegotiating bool // negotiating capabilities - capsEnabled uint // enabled capabilities - capVersion uint // CAP protocol version - - tlsCertFingerprint string // client certificate fingerprint - - nickname string // IRC nickname (main identifier) - username string // IRC username - realname string // IRC realname (or e-mail) - - mode uint // user's mode - awayMessage string // away message - lastActive time.Time // last PRIVMSG, to get idle time - invites map[string]bool // channel invitations by operators - antiflood *floodDetector // flood detector -} - -// --- Channels ---------------------------------------------------------------- - -const ircSupportedChanModes = "ov" + "beI" + "imnqpst" + "kl" - -const ( - ircChanModeInviteOnly uint = 1 << iota - ircChanModeModerated - ircChanModeNoOutsideMsgs - ircChanModeQuiet - ircChanModePrivate - ircChanModeSecret - ircChanModeProtectedTopic - - ircChanModeOperator - ircChanModeVoice -) - -type channel struct { - name string // channel name - modes uint // channel modes - key string // channel key - userLimit int // user limit or -1 - created time.Time // creation time - - topic string // channel topic - topicWho string // who set the topic - topicTime time.Time // when the topic was set - - userModes map[*client]uint // modes for all channel users - - banList []string // ban list - exceptionList []string // exceptions from bans - inviteList []string // exceptions from +I -} - -func (ch *channel) getMode(discloseSecrets bool) string { - var buf []byte - if 0 != ch.modes&ircChanModeInviteOnly { - buf = append(buf, 'i') - } - if 0 != ch.modes&ircChanModeModerated { - buf = append(buf, 'm') - } - if 0 != ch.modes&ircChanModeNoOutsideMsgs { - buf = append(buf, 'n') - } - if 0 != ch.modes&ircChanModeQuiet { - buf = append(buf, 'q') - } - if 0 != ch.modes&ircChanModePrivate { - buf = append(buf, 'p') - } - if 0 != ch.modes&ircChanModeSecret { - buf = append(buf, 's') - } - if 0 != ch.modes&ircChanModeProtectedTopic { - buf = append(buf, 'r') - } - - if ch.userLimit != -1 { - buf = append(buf, 'l') - } - if ch.key != "" { - buf = append(buf, 'k') - } - - // XXX: Is it correct to split it? Try it on an existing implementation. - if discloseSecrets { - if ch.userLimit != -1 { - buf = append(buf, fmt.Sprintf(" %d", ch.userLimit)...) - } - if ch.key != "" { - buf = append(buf, fmt.Sprintf(" %s", ch.key)...) - } - } - return string(buf) -} - -// --- IRC server context ------------------------------------------------------ - -type whowasInfo struct { - nickname, username, realname, hostname string -} - -func newWhowasInfo(c *client) *whowasInfo { - return &whowasInfo{ - nickname: c.nickname, - username: c.username, - realname: c.realname, - hostname: c.hostname, - } -} - -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -type ircCommand struct { - requiresRegistration bool - handler func(*message, *client) - - nReceived uint // number of commands received - bytesReceived int // number of bytes received total -} - -type preparedEvent struct { - client *client - host string // client's hostname or literal IP address - isTLS bool // the client seems to use TLS -} - -type readEvent struct { - client *client - data []byte // new data from the client - err error // read error -} - -type writeEvent struct { - client *client - written int // amount of bytes written - err error // write error -} - -// TODO: Maybe we want to keep it in a struct? -// A better question might be: can we run multiple instances of it? -var ( - // network - - listeners []net.Listener - clients = make(map[*client]bool) - - // IRC state - - // XXX: Beware that maps with identifier keys need to be indexed correctly. - // We might want to enforce accessor functions for users and channels. - - started time.Time // when the server has been started - users = make(map[string]*client) // maps nicknames to clients - channels = make(map[string]*channel) // maps channel names to data - whowas = make(map[string]*whowasInfo) // WHOWAS registry - - // event loop - - quitting bool - quitTimer <-chan time.Time - - sigs = make(chan os.Signal, 1) - conns = make(chan net.Conn) - prepared = make(chan preparedEvent) - reads = make(chan readEvent) - writes = make(chan writeEvent) - timers = make(chan func()) - - // configuration - - config simpleConfig // server configuration - tlsConf *tls.Config // TLS connection configuration - serverName string // our server name - pingInterval time.Duration // ping interval - maxConnections int // max connections allowed or 0 - motd []string // MOTD (none if empty) - catalog map[int]string // message catalog for server msgs - operators map[string]bool // TLS certificate fingerprints for IRCops -) - -// Forcefully tear down all connections. -func forceQuit(reason string) { - if !quitting { - exitFatal("forceQuit called without initiateQuit") - } - - printStatus("forced shutdown (%s)", reason) - for c := range clients { - // initiateQuit has already unregistered the client. - c.kill("Shutting down") - } -} - -// Initiate a clean shutdown of the whole daemon. -func initiateQuit() { - printStatus("shutting down") - for _, ln := range listeners { - if err := ln.Close(); err != nil { - printError("%s", err) - } - } - for c := range clients { - c.closeLink("Shutting down") - } - - quitTimer = time.After(5 * time.Second) - quitting = true -} - -func ircChannelCreate(name string) *channel { - ch := &channel{ - name: name, - userLimit: -1, - created: time.Now(), - userModes: make(map[*client]uint), - } - channels[ircToCanon(name)] = ch - return ch -} - -func ircChannelDestroyIfEmpty(ch *channel) { - if len(ch.userModes) == 0 { - delete(channels, ircToCanon(ch.name)) - } -} - -func ircNotifyRoommates(c *client, message string) { - targets := make(map[*client]bool) - for _, ch := range channels { - _, present := ch.userModes[c] - if !present || 0 != ch.modes&ircChanModeQuiet { - continue - } - for client := range ch.userModes { - targets[client] = true - } - } - - for roommate := range targets { - if roommate != c { - roommate.send(message) - } - } -} - -// --- Clients (continued) ----------------------------------------------------- - -func (c *client) printDebug(format string, args ...interface{}) { - if debugMode { - printDebug("(%s) %s", c.address, fmt.Sprintf(format, args...)) - } -} - -func ircAppendClientModes(m uint, mode []byte) []byte { - if 0 != m&ircUserModeInvisible { - mode = append(mode, 'i') - } - if 0 != m&ircUserModeRxWallops { - mode = append(mode, 'w') - } - if 0 != m&ircUserModeRestricted { - mode = append(mode, 'r') - } - if 0 != m&ircUserModeOperator { - mode = append(mode, 'o') - } - if 0 != m&ircUserModeRxServerNotices { - mode = append(mode, 's') - } - return mode -} - -func (c *client) getMode() string { - var mode []byte - if c.awayMessage != "" { - mode = append(mode, 'a') - } - return string(ircAppendClientModes(c.mode, mode)) -} - -func (c *client) send(line string) { - if c.conn == nil || c.closing { - return - } - - oldSendQLen := len(c.sendQ) - - // So far there's only one message tag we use, so we can do it simple; - // note that a 1024-character limit applies to messages with tags on. - if 0 != c.capsEnabled&ircCapServerTime { - c.sendQ = time.Now().UTC(). - AppendFormat(c.sendQ, "@time=2006-01-02T15:04:05.000Z ") - } - - bytes := []byte(line) - if len(bytes) > ircMaxMessageLength { - bytes = bytes[:ircMaxMessageLength] - } - - c.printDebug("<- %s", bytes) - - // TODO: Kill the connection above some "SendQ" threshold (careful!) - c.sendQ = append(c.sendQ, bytes...) - c.sendQ = append(c.sendQ, "\r\n"...) - c.flushSendQ() - - // Technically we haven't sent it yet but that's a minor detail - c.nSentMessages++ - c.sentBytes += len(c.sendQ) - oldSendQLen -} - -func (c *client) sendf(format string, a ...interface{}) { - c.send(fmt.Sprintf(format, a...)) -} - -func (c *client) addToWhowas() { - // Only keeping one entry for each nickname. - // TODO: Make sure this list doesn't get too long, for example by - // putting them in a linked list ordered by time. - whowas[ircToCanon(c.nickname)] = newWhowasInfo(c) -} - -func (c *client) nicknameOrStar() string { - if c.nickname == "" { - return "*" - } - return c.nickname -} - -func (c *client) unregister(reason string) { - if !c.registered { - return - } - - ircNotifyRoommates(c, fmt.Sprintf(":%s!%s@%s QUIT :%s", - c.nickname, c.username, c.hostname, reason)) - - // The QUIT message will take care of state on clients. - for _, ch := range channels { - delete(ch.userModes, c) - ircChannelDestroyIfEmpty(ch) - } - - c.addToWhowas() - delete(users, ircToCanon(c.nickname)) - c.nickname = "" - c.registered = false -} - -// Close the connection and forget about the client. -func (c *client) kill(reason string) { - if reason == "" { - c.unregister("Client exited") - } else { - c.unregister(reason) - } - - c.printDebug("client destroyed (%s)", reason) - - // Try to send a "close notify" alert if the TLS object is ready, - // otherwise just tear down the transport. - if c.conn != nil { - _ = c.conn.Close() - } else { - _ = c.transport.Close() - } - - c.cancelTimers() - delete(clients, c) -} - -// Tear down the client connection, trying to do so in a graceful manner. -func (c *client) closeLink(reason string) { - // Let's just cut the connection, the client can try again later. - // We also want to avoid accidentally writing to the socket before - // address resolution has finished. - if c.conn == nil { - c.kill(reason) - return - } - if c.closing { - return - } - - // We push an "ERROR" message to the write buffer and let the writer send - // it, with some arbitrary timeout. The "closing" state makes sure - // that a/ we ignore any successive messages, and b/ that the connection - // is killed after the write buffer is transferred and emptied. - // (Since we send this message, we don't need to call CloseWrite here.) - c.sendf("ERROR :Closing link: %s[%s] (%s)", - c.nicknameOrStar(), c.hostname /* TODO host IP? */, reason) - c.closing = true - - c.unregister(reason) - c.setKillTimer() -} - -func (c *client) inMaskList(masks []string) bool { - client := fmt.Sprintf("%s!%s@%s", c.nickname, c.username, c.hostname) - for _, mask := range masks { - if ircFnmatch(mask, client) { - return true - } - } - return false -} - -func (c *client) getTLSCertFingerprint() string { - if c.tls == nil { - return "" - } - - peerCerts := c.tls.ConnectionState().PeerCertificates - if len(peerCerts) == 0 { - return "" - } - - hash := sha256.Sum256(peerCerts[0].Raw) - return hex.EncodeToString(hash[:]) -} - -// --- Timers ------------------------------------------------------------------ - -// Free the resources of timers that haven't fired yet and for timers that are -// in between firing and being collected by the event loop, mark that the event -// should not be acted upon. -func (c *client) cancelTimers() { - for _, timer := range []**time.Timer{ - &c.killTimer, &c.timeoutTimer, &c.pingTimer, - } { - if *timer != nil { - (*timer).Stop() - *timer = nil - } - } -} - -// Arrange for a function to be called later from the main goroutine. -func (c *client) setTimer(timer **time.Timer, delay time.Duration, cb func()) { - c.cancelTimers() - - var identityCapture *time.Timer - identityCapture = time.AfterFunc(delay, func() { - timers <- func() { - // The timer might have been cancelled or even replaced. - // When the client is killed, this will be nil. - if *timer == identityCapture { - cb() - } - } - }) - - *timer = identityCapture -} - -func (c *client) setKillTimer() { - c.setTimer(&c.killTimer, pingInterval, func() { - c.kill("Timeout") - }) -} - -func (c *client) setTimeoutTimer() { - c.setTimer(&c.timeoutTimer, pingInterval, func() { - c.closeLink(fmt.Sprintf("Ping timeout: >%d seconds", - pingInterval/time.Second)) - }) -} - -func (c *client) setPingTimer() { - c.setTimer(&c.pingTimer, pingInterval, func() { - c.sendf("PING :%s", serverName) - c.setTimeoutTimer() - }) -} - -// --- IRC command handling ---------------------------------------------------- - -func (c *client) makeReply(id int, ap ...interface{}) string { - s := fmt.Sprintf(":%s %03d %s ", serverName, id, c.nicknameOrStar()) - if reply, ok := catalog[id]; ok { - return s + fmt.Sprintf(reply, ap...) - } - return s + fmt.Sprintf(defaultReplies[id], ap...) -} - -// XXX: This way simple static analysis cannot typecheck the arguments, so we -// need to be careful. -func (c *client) sendReply(id int, args ...interface{}) { - c.send(c.makeReply(id, args...)) -} - -// Send a space-separated list of words across as many replies as needed. -func (c *client) sendReplyVector(id int, items []string, args ...interface{}) { - common := c.makeReply(id, args...) - - // We always send at least one message (there might be a client that - // expects us to send this message at least once). - if len(items) == 0 { - items = append(items, "") - } - - for len(items) > 0 { - // If not even a single item fits in the limit (which may happen, - // in theory) it just gets cropped. We could also skip it. - reply := append([]byte(common), items[0]...) - items = items[1:] - - // Append as many items as fits in a single message. - for len(items) > 0 && - len(reply)+1+len(items[0]) <= ircMaxMessageLength { - reply = append(reply, ' ') - reply = append(reply, items[0]...) - items = items[1:] - } - - c.send(string(reply)) - } -} - -func (c *client) sendMOTD() { - if len(motd) == 0 { - c.sendReply(ERR_NOMOTD) - return - } - - c.sendReply(RPL_MOTDSTART, serverName) - for _, line := range motd { - c.sendReply(RPL_MOTD, line) - } - c.sendReply(RPL_ENDOFMOTD) -} - -func (c *client) sendLUSERS() { - nUsers, nServices, nOpers, nUnknown := 0, 0, 0, 0 - for c := range clients { - if c.registered { - nUsers++ - } else { - nUnknown++ - } - if 0 != c.mode&ircUserModeOperator { - nOpers++ - } - } - - nChannels := 0 - for _, ch := range channels { - if 0 != ch.modes&ircChanModeSecret { - nChannels++ - } - } - - c.sendReply(RPL_LUSERCLIENT, nUsers, nServices, 1 /* servers total */) - if nOpers != 0 { - c.sendReply(RPL_LUSEROP, nOpers) - } - if nUnknown != 0 { - c.sendReply(RPL_LUSERUNKNOWN, nUnknown) - } - if nChannels != 0 { - c.sendReply(RPL_LUSERCHANNELS, nChannels) - } - c.sendReply(RPL_LUSERME, nUsers+nServices+nUnknown, 0 /* peer servers */) -} - -func ircIsThisMe(target string) bool { - // Target servers can also be matched by their users - if ircFnmatch(target, serverName) { - return true - } - _, ok := users[ircToCanon(target)] - return ok -} - -func (c *client) sendISUPPORT() { - // Only # channels, +e supported, +I supported, unlimited arguments to MODE. - c.sendReply(RPL_ISUPPORT, fmt.Sprintf("CHANTYPES=# EXCEPTS INVEX MODES"+ - " TARGMAX=WHOIS:,LIST:,NAMES:,PRIVMSG:1,NOTICE:1,KICK:"+ - " NICKLEN=%d CHANNELLEN=%d", ircMaxNickname, ircMaxChannelName)) -} - -func (c *client) tryFinishRegistration() { - if c.registered || c.capNegotiating { - return - } - if c.nickname == "" || c.username == "" { - return - } - - c.registered = true - c.sendReply(RPL_WELCOME, c.nickname, c.username, c.hostname) - - c.sendReply(RPL_YOURHOST, serverName, projectVersion) - // The purpose of this message eludes me. - c.sendReply(RPL_CREATED, started.Format("Mon, 02 Jan 2006")) - c.sendReply(RPL_MYINFO, serverName, projectVersion, - ircSupportedUserModes, ircSupportedChanModes) - - c.sendISUPPORT() - c.sendLUSERS() - c.sendMOTD() - - if mode := c.getMode(); mode != "" { - c.sendf(":%s MODE %s :+%s", c.nickname, c.nickname, mode) - } - - c.tlsCertFingerprint = c.getTLSCertFingerprint() - if c.tlsCertFingerprint != "" { - c.sendf(":%s NOTICE %s :Your TLS client certificate fingerprint is %s", - serverName, c.nickname, c.tlsCertFingerprint) - } - - delete(whowas, ircToCanon(c.nickname)) -} - -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -// IRCv3 capability negotiation. See http://ircv3.org for details. - -type ircCapArgs struct { - subcommand string // the subcommand being processed - fullParams string // whole parameter string - params []string // split parameters - target string // target parameter for replies -} - -var ircCapTable = []struct { - flag uint // flag - name string // name of the capability -}{ - {ircCapMultiPrefix, "multi-prefix"}, - {ircCapInviteNotify, "invite-notify"}, - {ircCapEchoMessage, "echo-message"}, - {ircCapUserhostInNames, "userhost-in-names"}, - {ircCapServerTime, "server-time"}, -} - -func (c *client) handleCAPLS(a *ircCapArgs) { - if len(a.params) == 1 { - if ver, err := strconv.ParseUint(a.params[0], 10, 32); err != nil { - c.sendReply(ERR_INVALIDCAPCMD, a.subcommand, - "Ignoring invalid protocol version number") - } else { - c.capVersion = uint(ver) - } - } - - c.capNegotiating = true - c.sendf(":%s CAP %s LS :multi-prefix invite-notify echo-message"+ - " userhost-in-names server-time", serverName, a.target) -} - -func (c *client) handleCAPLIST(a *ircCapArgs) { - caps := []string{} - for _, cap := range ircCapTable { - if 0 != c.capsEnabled&cap.flag { - caps = append(caps, cap.name) - } - } - - c.sendf(":%s CAP %s LIST :%s", serverName, a.target, - strings.Join(caps, " ")) -} - -func ircDecodeCapability(name string) uint { - for _, cap := range ircCapTable { - if cap.name == name { - return cap.flag - } - } - return 0 -} - -func (c *client) handleCAPREQ(a *ircCapArgs) { - c.capNegotiating = true - - newCaps := c.capsEnabled - ok := true - for _, param := range a.params { - removing := false - name := param - if name[:1] == "-" { - removing = true - name = name[1:] - } - - if cap := ircDecodeCapability(name); cap == 0 { - ok = false - } else if removing { - newCaps &= ^cap - } else { - newCaps |= cap - } - } - - if ok { - c.capsEnabled = newCaps - c.sendf(":%s CAP %s ACK :%s", serverName, a.target, a.fullParams) - } else { - c.sendf(":%s CAP %s NAK :%s", serverName, a.target, a.fullParams) - } -} - -func (c *client) handleCAPACK(a *ircCapArgs) { - if len(a.params) > 0 { - c.sendReply(ERR_INVALIDCAPCMD, a.subcommand, - "No acknowledgable capabilities supported") - } -} - -func (c *client) handleCAPEND(a *ircCapArgs) { - c.capNegotiating = false - c.tryFinishRegistration() -} - -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -var ircCapHandlers = map[string]func(*client, *ircCapArgs){ - "LS": (*client).handleCAPLS, - "LIST": (*client).handleCAPLIST, - "REQ": (*client).handleCAPREQ, - "ACK": (*client).handleCAPACK, - "END": (*client).handleCAPEND, -} - -// XXX: Maybe these also deserve to be methods for client? They operate on -// global state, though. - -func ircParseWEBIRCOptions(options string, out map[string]string) { - for _, option := range strings.Split(options, " ") { - if equal := strings.IndexByte(option, '='); equal < 0 { - out[option] = "" - } else { - out[option[:equal]] = ircUnescapeMessageTag(option[equal+1:]) - } - } -} - -func ircHandleWEBIRC(msg *message, c *client) { - if len(msg.params) < 4 { - c.sendReply(ERR_NEEDMOREPARAMS, msg.command) - return - } - - password, gateway, hostname := msg.params[0], msg.params[1], msg.params[2] - if config["webirc_password"] != password { - c.closeLink("Invalid WebIRC password") - return - } - - options := make(map[string]string) - if len(msg.params) >= 5 { - ircParseWEBIRCOptions(msg.params[4], options) - } - - c.hostname = hostname - c.port = "WebIRC-" + gateway - c.address = net.JoinHostPort(hostname, c.port) - - // Note that this overrides the gateway's certificate, conditionally. - fp, _ := options["certfp-sha-256"] - if _, secure := options["secure"]; secure && ircIsValidFingerprint(fp) { - c.tlsCertFingerprint = strings.ToLower(fp) - } -} - -func ircHandleCAP(msg *message, c *client) { - if len(msg.params) < 1 { - c.sendReply(ERR_NEEDMOREPARAMS, msg.command) - return - } - - args := &ircCapArgs{ - target: c.nicknameOrStar(), - subcommand: msg.params[0], - fullParams: "", - params: []string{}, - } - - if len(msg.params) > 1 { - args.fullParams = msg.params[1] - args.params = splitString(args.fullParams, " ", true) - } - - if fn, ok := ircCapHandlers[ircToCanon(args.subcommand)]; !ok { - c.sendReply(ERR_INVALIDCAPCMD, args.subcommand, - "Invalid CAP subcommand") - } else { - fn(c, args) - } -} - -func ircHandlePASS(msg *message, c *client) { - if c.registered { - c.sendReply(ERR_ALREADYREGISTERED) - } else if len(msg.params) < 1 { - c.sendReply(ERR_NEEDMOREPARAMS, msg.command) - } - - // We have TLS client certificates for this purpose; ignoring. -} - -func ircHandleNICK(msg *message, c *client) { - if len(msg.params) < 1 { - c.sendReply(ERR_NONICKNAMEGIVEN) - return - } - - nickname := msg.params[0] - if !ircIsValidNickname(nickname) { - c.sendReply(ERR_ERRONEOUSNICKNAME, nickname) - return - } - - nicknameCanon := ircToCanon(nickname) - if client, ok := users[nicknameCanon]; ok && client != c { - c.sendReply(ERR_NICKNAMEINUSE, nickname) - return - } - - if c.registered { - c.addToWhowas() - - message := fmt.Sprintf(":%s!%s@%s NICK :%s", - c.nickname, c.username, c.hostname, nickname) - ircNotifyRoommates(c, message) - c.send(message) - } - - // Release the old nickname and allocate a new one. - if c.nickname != "" { - delete(users, ircToCanon(c.nickname)) - } - - c.nickname = nickname - users[nicknameCanon] = c - - c.tryFinishRegistration() -} - -func ircHandleUSER(msg *message, c *client) { - if c.registered { - c.sendReply(ERR_ALREADYREGISTERED) - return - } - if len(msg.params) < 4 { - c.sendReply(ERR_NEEDMOREPARAMS, msg.command) - return - } - - username, mode, realname := msg.params[0], msg.params[1], msg.params[3] - - // Unfortunately, the protocol doesn't give us any means of rejecting it. - if !ircIsValidUsername(username) { - username = "*" - } - - c.username = username - c.realname = realname - - c.mode = 0 - if m, err := strconv.ParseUint(mode, 10, 32); err != nil { - if 0 != m&4 { - c.mode |= ircUserModeRxWallops - } - if 0 != m&8 { - c.mode |= ircUserModeInvisible - } - } - - c.tryFinishRegistration() -} - -func ircHandleUSERHOST(msg *message, c *client) { - if len(msg.params) < 1 { - c.sendReply(ERR_NEEDMOREPARAMS, msg.command) - return - } - - var reply []byte - for i := 0; i < 5 && i < len(msg.params); i++ { - nick := msg.params[i] - target := users[ircToCanon(nick)] - if target == nil { - continue - } - if i != 0 { - reply = append(reply, ' ') - } - - reply = append(reply, nick...) - if 0 != target.mode&ircUserModeOperator { - reply = append(reply, '*') - } - - if target.awayMessage != "" { - reply = append(reply, "=-"...) - } else { - reply = append(reply, "=+"...) - } - reply = append(reply, (target.username + "@" + target.hostname)...) - } - c.sendReply(RPL_USERHOST, string(reply)) -} - -func ircHandleLUSERS(msg *message, c *client) { - if len(msg.params) > 1 && !ircIsThisMe(msg.params[1]) { - c.sendReply(ERR_NOSUCHSERVER, msg.params[1]) - return - } - c.sendLUSERS() -} - -func ircHandleMOTD(msg *message, c *client) { - if len(msg.params) > 0 && !ircIsThisMe(msg.params[0]) { - c.sendReply(ERR_NOSUCHSERVER, msg.params[1]) - return - } - c.sendMOTD() -} - -func ircHandlePING(msg *message, c *client) { - // XXX: The RFC is pretty incomprehensible about the exact usage. - if len(msg.params) > 1 && !ircIsThisMe(msg.params[1]) { - c.sendReply(ERR_NOSUCHSERVER, msg.params[1]) - } else if len(msg.params) < 1 { - c.sendReply(ERR_NOORIGIN) - } else { - c.sendf(":%s PONG :%s", serverName, msg.params[0]) - } -} - -func ircHandlePONG(msg *message, c *client) { - // We are the only server, so we don't have to care too much. - if len(msg.params) < 1 { - c.sendReply(ERR_NOORIGIN) - return - } - - // Set a new timer to send another PING - c.setPingTimer() -} - -func ircHandleQUIT(msg *message, c *client) { - reason := c.nickname - if len(msg.params) > 0 { - reason = msg.params[0] - } - - c.closeLink("Quit: " + reason) -} - -func ircHandleTIME(msg *message, c *client) { - if len(msg.params) > 0 && !ircIsThisMe(msg.params[0]) { - c.sendReply(ERR_NOSUCHSERVER, msg.params[0]) - return - } - - c.sendReply(RPL_TIME, serverName, - time.Now().Format("Mon Jan _2 2006 15:04:05")) -} - -func ircHandleVERSION(msg *message, c *client) { - if len(msg.params) > 0 && !ircIsThisMe(msg.params[0]) { - c.sendReply(ERR_NOSUCHSERVER, msg.params[0]) - return - } - - postVersion := 0 - if debugMode { - postVersion = 1 - } - - c.sendReply(RPL_VERSION, projectVersion, postVersion, serverName, - projectName+" "+projectVersion) - c.sendISUPPORT() -} - -func ircChannelMulticast(ch *channel, msg string, except *client) { - for c := range ch.userModes { - if c != except { - c.send(msg) - } - } -} - -func ircModifyMode(mask *uint, mode uint, add bool) bool { - orig := *mask - if add { - *mask |= mode - } else { - *mask &= ^mode - } - return *mask != orig -} - -func ircUpdateUserMode(c *client, newMode uint) { - oldMode := c.mode - c.mode = newMode - - added, removed := newMode & ^oldMode, oldMode & ^newMode - - var diff []byte - if added != 0 { - diff = append(diff, '+') - diff = ircAppendClientModes(added, diff) - } - if removed != 0 { - diff = append(diff, '-') - diff = ircAppendClientModes(removed, diff) - } - - if len(diff) > 0 { - c.sendf(":%s MODE %s :%s", c.nickname, c.nickname, string(diff)) - } -} - -func ircHandleUserModeChange(c *client, modeString string) { - newMode := c.mode - adding := true - - for _, flag := range modeString { - switch flag { - case '+': - adding = true - case '-': - adding = false - - case 'a': - // Ignore, the client should use AWAY. - case 'i': - ircModifyMode(&newMode, ircUserModeInvisible, adding) - case 'w': - ircModifyMode(&newMode, ircUserModeRxWallops, adding) - case 'r': - // It's not possible ot un-restrict yourself. - if adding { - newMode |= ircUserModeRestricted - } - case 'o': - if !adding { - newMode &= ^ircUserModeOperator - } else if operators[c.tlsCertFingerprint] { - newMode |= ircUserModeOperator - } else { - c.sendf(":%s NOTICE %s :Either you're not using an TLS"+ - " client certificate, or the fingerprint doesn't match", - serverName, c.nickname) - } - case 's': - ircModifyMode(&newMode, ircUserModeRxServerNotices, adding) - default: - c.sendReply(ERR_UMODEUNKNOWNFLAG) - return - } - } - ircUpdateUserMode(c, newMode) -} - -func ircSendChannelList(c *client, channelName string, list []string, - reply, endReply int) { - for _, line := range list { - c.sendReply(reply, channelName, line) - } - c.sendReply(endReply, channelName) -} - -func ircCheckExpandUserMask(mask string) string { - var result []byte - result = append(result, mask...) - - // Make sure it is a complete mask. - if bytes.IndexByte(result, '!') < 0 { - result = append(result, "!*"...) - } - if bytes.IndexByte(result, '@') < 0 { - result = append(result, "@*"...) - } - - // And validate whatever the result is. - s := string(result) - if !ircIsValidUserMask(s) { - return "" - } - - return s -} - -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -// Channel MODE command handling. This is by far the worst command to implement -// from the whole RFC; don't blame me if it doesn't work exactly as expected. - -type modeProcessor struct { - params []string // mode string parameters - - c *client // who does the changes - ch *channel // the channel we're modifying - present bool // c present on ch - modes uint // channel user modes - - adding bool // currently adding modes - modeChar byte // currently processed mode char - - added []byte // added modes - removed []byte // removed modes - output *[]byte // "added" or "removed" - - addedParams []string // params for added modes - removedParams []string // params for removed modes - outputParams *[]string // "addedParams" or "removedParams" -} - -func (mp *modeProcessor) nextParam() string { - if len(mp.params) == 0 { - return "" - } - - param := mp.params[0] - mp.params = mp.params[1:] - return param -} - -func (mp *modeProcessor) checkOperator() bool { - if (mp.present && 0 != mp.modes&ircChanModeOperator) || - 0 != mp.c.mode&ircUserModeOperator { - return true - } - - mp.c.sendReply(ERR_CHANOPRIVSNEEDED, mp.ch.name) - return false -} - -func (mp *modeProcessor) doUser(mode uint) { - target := mp.nextParam() - if !mp.checkOperator() || target == "" { - return - } - - if client := users[ircToCanon(target)]; client == nil { - mp.c.sendReply(ERR_NOSUCHNICK, target) - } else if modes, present := mp.ch.userModes[client]; !present { - mp.c.sendReply(ERR_USERNOTINCHANNEL, target, mp.ch.name) - } else if ircModifyMode(&modes, mode, mp.adding) { - mp.ch.userModes[client] = modes - *mp.output = append(*mp.output, mp.modeChar) - *mp.outputParams = append(*mp.outputParams, client.nickname) - } -} - -func (mp *modeProcessor) doChan(mode uint) bool { - if !mp.checkOperator() || !ircModifyMode(&mp.ch.modes, mode, mp.adding) { - return false - } - *mp.output = append(*mp.output, mp.modeChar) - return true -} - -func (mp *modeProcessor) doChanRemove(modeChar byte, mode uint) { - if mp.adding && ircModifyMode(&mp.ch.modes, mode, false) { - mp.removed = append(mp.removed, modeChar) - } -} - -func (mp *modeProcessor) doList(list *[]string, listMsg, endMsg int) { - target := mp.nextParam() - if target == "" { - if mp.adding { - ircSendChannelList(mp.c, mp.ch.name, *list, listMsg, endMsg) - } - return - } - - if !mp.checkOperator() { - return - } - - mask := ircCheckExpandUserMask(target) - if mask == "" { - return - } - - var i int - for i = 0; i < len(*list); i++ { - if ircEqual((*list)[i], mask) { - break - } - } - - found := i < len(*list) - if found != mp.adding { - if mp.adding { - *list = append(*list, mask) - } else { - *list = append((*list)[:i], (*list)[i+1:]...) - } - - *mp.output = append(*mp.output, mp.modeChar) - *mp.outputParams = append(*mp.outputParams, mask) - } -} - -func (mp *modeProcessor) doKey() { - target := mp.nextParam() - if !mp.checkOperator() || target == "" { - return - } - - if !mp.adding { - if mp.ch.key == "" || !ircEqual(target, mp.ch.key) { - return - } - - mp.removed = append(mp.removed, mp.modeChar) - mp.removedParams = append(mp.removedParams, mp.ch.key) - mp.ch.key = "" - } else if !ircIsValidKey(target) { - // TODO: We should notify the user somehow. - return - } else if mp.ch.key != "" { - mp.c.sendReply(ERR_KEYSET, mp.ch.name) - } else { - mp.ch.key = target - mp.added = append(mp.added, mp.modeChar) - mp.addedParams = append(mp.addedParams, mp.ch.key) - } -} - -func (mp *modeProcessor) doLimit() { - if !mp.checkOperator() { - return - } - - if !mp.adding { - if mp.ch.userLimit == -1 { - return - } - - mp.ch.userLimit = -1 - mp.removed = append(mp.removed, mp.modeChar) - } else if target := mp.nextParam(); target != "" { - if x, err := strconv.ParseInt(target, 10, 32); err == nil && x > 0 { - mp.ch.userLimit = int(x) - mp.added = append(mp.added, mp.modeChar) - mp.addedParams = append(mp.addedParams, target) - } - } -} - -func (mp *modeProcessor) step(modeChar byte) bool { - mp.modeChar = modeChar - switch mp.modeChar { - case '+': - mp.adding = true - mp.output = &mp.added - mp.outputParams = &mp.addedParams - case '-': - mp.adding = false - mp.output = &mp.removed - mp.outputParams = &mp.removedParams - - case 'o': - mp.doUser(ircChanModeOperator) - case 'v': - mp.doUser(ircChanModeVoice) - - case 'i': - mp.doChan(ircChanModeInviteOnly) - case 'm': - mp.doChan(ircChanModeModerated) - case 'n': - mp.doChan(ircChanModeNoOutsideMsgs) - case 'q': - mp.doChan(ircChanModeQuiet) - case 't': - mp.doChan(ircChanModeProtectedTopic) - - case 'p': - if mp.doChan(ircChanModePrivate) { - mp.doChanRemove('s', ircChanModeSecret) - } - case 's': - if mp.doChan(ircChanModeSecret) { - mp.doChanRemove('p', ircChanModePrivate) - } - - case 'b': - mp.doList(&mp.ch.banList, RPL_BANLIST, RPL_ENDOFBANLIST) - case 'e': - mp.doList(&mp.ch.banList, RPL_EXCEPTLIST, RPL_ENDOFEXCEPTLIST) - case 'I': - mp.doList(&mp.ch.banList, RPL_INVITELIST, RPL_ENDOFINVITELIST) - - case 'k': - mp.doKey() - case 'l': - mp.doLimit() - - default: - // It's not safe to continue, results could be undesired. - mp.c.sendReply(ERR_UNKNOWNMODE, modeChar, mp.ch.name) - return false - } - return true -} - -func ircHandleChanModeChange(c *client, ch *channel, params []string) { - modes, present := ch.userModes[c] - mp := &modeProcessor{ - c: c, - ch: ch, - present: present, - modes: modes, - params: params, - } - -Outer: - for { - modeString := mp.nextParam() - if modeString == "" { - break - } - - mp.step('+') - for _, modeChar := range []byte(modeString) { - if !mp.step(modeChar) { - break Outer - } - } - } - - // TODO: Limit to three changes with parameter per command. - if len(mp.added) > 0 || len(mp.removed) > 0 { - buf := []byte(fmt.Sprintf(":%s!%s@%s MODE %s ", - mp.c.nickname, mp.c.username, mp.c.hostname, mp.ch.name)) - if len(mp.added) > 0 { - buf = append(buf, '+') - buf = append(buf, mp.added...) - } - if len(mp.removed) > 0 { - buf = append(buf, '-') - buf = append(buf, mp.removed...) - } - for _, param := range mp.addedParams { - buf = append(buf, ' ') - buf = append(buf, param...) - } - for _, param := range mp.removedParams { - buf = append(buf, ' ') - buf = append(buf, param...) - } - ircChannelMulticast(mp.ch, string(buf), nil) - } -} - -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -func ircHandleMODE(msg *message, c *client) { - if len(msg.params) < 1 { - c.sendReply(ERR_NEEDMOREPARAMS, msg.command) - return - } - - target := msg.params[0] - client := users[ircToCanon(target)] - ch := channels[ircToCanon(target)] - - if client != nil { - if !ircEqual(target, c.nickname) { - c.sendReply(ERR_USERSDONTMATCH) - return - } - - if len(msg.params) < 2 { - c.sendReply(RPL_UMODEIS, c.getMode()) - } else { - ircHandleUserModeChange(c, msg.params[1]) - } - } else if ch != nil { - if len(msg.params) < 2 { - _, present := ch.userModes[c] - c.sendReply(RPL_CHANNELMODEIS, target, ch.getMode(present)) - c.sendReply(RPL_CREATIONTIME, target, ch.created.Unix()) - } else { - ircHandleChanModeChange(c, ch, msg.params[1:]) - } - } else { - c.sendReply(ERR_NOSUCHNICK, target) - } -} - -func ircHandleUserMessage(msg *message, c *client, - command string, allowAwayReply bool) { - if len(msg.params) < 1 { - c.sendReply(ERR_NORECIPIENT, msg.command) - return - } - if len(msg.params) < 2 || msg.params[1] == "" { - c.sendReply(ERR_NOTEXTTOSEND) - return - } - - target, text := msg.params[0], msg.params[1] - message := fmt.Sprintf(":%s!%s@%s %s %s :%s", - c.nickname, c.username, c.hostname, command, target, text) - - if client := users[ircToCanon(target)]; client != nil { - client.send(message) - if allowAwayReply && client.awayMessage != "" { - c.sendReply(RPL_AWAY, target, client.awayMessage) - } - - // Acknowledging a message from the client to itself would be silly. - if client != c && (0 != c.capsEnabled&ircCapEchoMessage) { - c.send(message) - } - } else if ch := channels[ircToCanon(target)]; ch != nil { - modes, present := ch.userModes[c] - - outsider := !present && 0 != ch.modes&ircChanModeNoOutsideMsgs - moderated := 0 != ch.modes&ircChanModeModerated && - 0 == modes&(ircChanModeVoice|ircChanModeOperator) - banned := c.inMaskList(ch.banList) && !c.inMaskList(ch.exceptionList) - - if outsider || moderated || banned { - c.sendReply(ERR_CANNOTSENDTOCHAN, target) - return - } - - except := c - if 0 != c.capsEnabled&ircCapEchoMessage { - except = nil - } - - ircChannelMulticast(ch, message, except) - } else { - c.sendReply(ERR_NOSUCHNICK, target) - } -} - -func ircHandlePRIVMSG(msg *message, c *client) { - ircHandleUserMessage(msg, c, "PRIVMSG", true /* allowAwayReply */) - c.lastActive = time.Now() -} - -func ircHandleNOTICE(msg *message, c *client) { - ircHandleUserMessage(msg, c, "NOTICE", false /* allowAwayReply */) -} - -func ircHandleLIST(msg *message, c *client) { - if len(msg.params) > 1 && !ircIsThisMe(msg.params[1]) { - c.sendReply(ERR_NOSUCHSERVER, msg.params[1]) - return - } - - // XXX: Maybe we should skip ircUserModeInvisible from user counts. - if len(msg.params) == 0 { - for _, ch := range channels { - if _, present := ch.userModes[c]; present || - 0 == ch.modes&(ircChanModePrivate|ircChanModeSecret) { - c.sendReply(RPL_LIST, ch.name, len(ch.userModes), ch.topic) - } - } - } else { - for _, target := range splitString(msg.params[0], ",", true) { - if ch := channels[ircToCanon(target)]; ch != nil && - 0 == ch.modes&ircChanModeSecret { - c.sendReply(RPL_LIST, ch.name, len(ch.userModes), ch.topic) - } - } - } - c.sendReply(RPL_LISTEND) -} - -func ircAppendPrefixes(c *client, modes uint, buf []byte) []byte { - var all []byte - if 0 != modes&ircChanModeOperator { - all = append(all, '@') - } - if 0 != modes&ircChanModeVoice { - all = append(all, '+') - } - - if len(all) > 0 { - if 0 != c.capsEnabled&ircCapMultiPrefix { - buf = append(buf, all...) - } else { - buf = append(buf, all[0]) - } - } - return buf -} - -func ircMakeRPLNAMREPLYItem(c, target *client, modes uint) string { - result := string(ircAppendPrefixes(c, modes, nil)) + target.nickname - if 0 != c.capsEnabled&ircCapUserhostInNames { - result += fmt.Sprintf("!%s@%s", target.username, target.hostname) - } - return result -} - -func ircSendRPLNAMREPLY(c *client, ch *channel, usedNicks map[*client]bool) { - kind := '=' - if 0 != ch.modes&ircChanModeSecret { - kind = '@' - } else if 0 != ch.modes&ircChanModePrivate { - kind = '*' - } - - _, present := ch.userModes[c] - - var nicks []string - for client, modes := range ch.userModes { - if !present && 0 != client.mode&ircUserModeInvisible { - continue - } - if usedNicks != nil { - usedNicks[client] = true - } - nicks = append(nicks, ircMakeRPLNAMREPLYItem(c, client, modes)) - } - c.sendReplyVector(RPL_NAMREPLY, nicks, kind, ch.name, "") -} - -func ircSendDisassociatedNames(c *client, usedNicks map[*client]bool) { - var nicks []string - for _, client := range users { - if 0 == client.mode&ircUserModeInvisible && !usedNicks[client] { - nicks = append(nicks, ircMakeRPLNAMREPLYItem(c, client, 0)) - } - } - if len(nicks) > 0 { - c.sendReplyVector(RPL_NAMREPLY, nicks, '*', "*", "") - } -} - -func ircHandleNAMES(msg *message, c *client) { - if len(msg.params) > 1 && !ircIsThisMe(msg.params[1]) { - c.sendReply(ERR_NOSUCHSERVER, msg.params[1]) - return - } - - if len(msg.params) == 0 { - usedNicks := make(map[*client]bool) - for _, ch := range channels { - if _, present := ch.userModes[c]; present || - 0 == ch.modes&(ircChanModePrivate|ircChanModeSecret) { - ircSendRPLNAMREPLY(c, ch, usedNicks) - } - } - - // Also send all visible users we haven't listed yet. - ircSendDisassociatedNames(c, usedNicks) - c.sendReply(RPL_ENDOFNAMES, "*") - } else { - for _, target := range splitString(msg.params[0], ",", true) { - if ch := channels[ircToCanon(target)]; ch == nil { - } else if _, present := ch.userModes[c]; present || - 0 == ch.modes&ircChanModeSecret { - ircSendRPLNAMREPLY(c, ch, nil) - c.sendReply(RPL_ENDOFNAMES, target) - } - } - } -} - -func ircSendRPLWHOREPLY(c *client, ch *channel, target *client) { - var chars []byte - if target.awayMessage != "" { - chars = append(chars, 'G') - } else { - chars = append(chars, 'H') - } - - if 0 != target.mode&ircUserModeOperator { - chars = append(chars, '*') - } - - channelName := "*" - if ch != nil { - channelName = ch.name - if modes, present := ch.userModes[target]; present { - chars = ircAppendPrefixes(c, modes, chars) - } - } - - c.sendReply(RPL_WHOREPLY, channelName, - target.username, target.hostname, serverName, - target.nickname, string(chars), 0 /* hop count */, target.realname) -} - -func ircMatchSendRPLWHOREPLY(c, target *client, mask string) { - isRoommate := false - for _, ch := range channels { - _, presentClient := ch.userModes[c] - _, presentTarget := ch.userModes[target] - if presentClient && presentTarget { - isRoommate = true - break - } - } - if !isRoommate && 0 != target.mode&ircUserModeInvisible { - return - } - - if !ircFnmatch(mask, target.hostname) && - !ircFnmatch(mask, target.nickname) && - !ircFnmatch(mask, target.realname) && - !ircFnmatch(mask, serverName) { - return - } - - // Try to find a channel they're on that's visible to us. - var userCh *channel - for _, ch := range channels { - _, presentClient := ch.userModes[c] - _, presentTarget := ch.userModes[target] - if presentTarget && (presentClient || - 0 == ch.modes&(ircChanModePrivate|ircChanModeSecret)) { - userCh = ch - break - } - } - ircSendRPLWHOREPLY(c, userCh, target) -} - -func ircHandleWHO(msg *message, c *client) { - onlyOps := len(msg.params) > 1 && msg.params[1] == "o" - - shownMask, usedMask := "*", "*" - if len(msg.params) > 0 { - shownMask = msg.params[0] - if shownMask != "0" { - usedMask = shownMask - } - } - - if ch := channels[ircToCanon(usedMask)]; ch != nil { - _, present := ch.userModes[c] - if present || 0 == ch.modes&ircChanModeSecret { - for client := range ch.userModes { - if (present || 0 == client.mode&ircUserModeInvisible) && - (!onlyOps || 0 != client.mode&ircUserModeOperator) { - ircSendRPLWHOREPLY(c, ch, client) - } - } - } - } else { - for _, client := range users { - if !onlyOps || 0 != client.mode&ircUserModeOperator { - ircMatchSendRPLWHOREPLY(c, client, usedMask) - } - } - } - c.sendReply(RPL_ENDOFWHO, shownMask) -} - -func ircSendWHOISReply(c, target *client) { - nick := target.nickname - c.sendReply(RPL_WHOISUSER, nick, - target.username, target.hostname, target.realname) - c.sendReply(RPL_WHOISSERVER, nick, serverName, config["server_info"]) - if 0 != target.mode&ircUserModeOperator { - c.sendReply(RPL_WHOISOPERATOR, nick) - } - c.sendReply(RPL_WHOISIDLE, nick, - time.Now().Sub(target.lastActive)/time.Second) - if target.awayMessage != "" { - c.sendReply(RPL_AWAY, nick, target.awayMessage) - } - - var chans []string - for _, ch := range channels { - _, presentClient := ch.userModes[c] - modes, presentTarget := ch.userModes[target] - if presentTarget && (presentClient || - 0 == ch.modes&(ircChanModePrivate|ircChanModeSecret)) { - // TODO: Deduplicate, ircAppendPrefixes just also cuts prefixes. - var all []byte - if 0 != modes&ircChanModeOperator { - all = append(all, '@') - } - if 0 != modes&ircChanModeVoice { - all = append(all, '+') - } - chans = append(chans, string(all)+ch.name) - } - } - c.sendReplyVector(RPL_WHOISCHANNELS, chans, nick, "") - c.sendReply(RPL_ENDOFWHOIS, nick) -} - -func ircHandleWHOIS(msg *message, c *client) { - if len(msg.params) < 1 { - c.sendReply(ERR_NEEDMOREPARAMS, msg.command) - return - } - if len(msg.params) > 1 && !ircIsThisMe(msg.params[0]) { - c.sendReply(ERR_NOSUCHSERVER, msg.params[0]) - return - } - - masksStr := msg.params[0] - if len(msg.params) > 1 { - masksStr = msg.params[1] - } - - for _, mask := range splitString(masksStr, ",", true /* ignoreEmpty */) { - if strings.IndexAny(mask, "*?") < 0 { - if target := users[ircToCanon(mask)]; target == nil { - c.sendReply(ERR_NOSUCHNICK, mask) - } else { - ircSendWHOISReply(c, target) - } - } else { - found := false - for _, target := range users { - if ircFnmatch(mask, target.nickname) { - ircSendWHOISReply(c, target) - found = true - } - } - if !found { - c.sendReply(ERR_NOSUCHNICK, mask) - } - } - } -} - -func ircHandleWHOWAS(msg *message, c *client) { - if len(msg.params) < 1 { - c.sendReply(ERR_NEEDMOREPARAMS, msg.command) - return - } - if len(msg.params) > 2 && !ircIsThisMe(msg.params[2]) { - c.sendReply(ERR_NOSUCHSERVER, msg.params[2]) - return - } - // The "count" parameter is ignored, we only store one entry for a nick. - - for _, nick := range splitString(msg.params[0], ",", true) { - if info := whowas[ircToCanon(nick)]; info == nil { - c.sendReply(ERR_WASNOSUCHNICK, nick) - } else { - c.sendReply(RPL_WHOWASUSER, nick, - info.username, info.hostname, info.realname) - c.sendReply(RPL_WHOISSERVER, nick, - serverName, config["server_info"]) - } - c.sendReply(RPL_ENDOFWHOWAS, nick) - } -} - -func ircSendRPLTOPIC(c *client, ch *channel) { - if ch.topic == "" { - c.sendReply(RPL_NOTOPIC, ch.name) - } else { - c.sendReply(RPL_TOPIC, ch.name, ch.topic) - c.sendReply(RPL_TOPICWHOTIME, - ch.name, ch.topicWho, ch.topicTime.Unix()) - } -} - -func ircHandleTOPIC(msg *message, c *client) { - if len(msg.params) < 1 { - c.sendReply(ERR_NEEDMOREPARAMS, msg.command) - return - } - - channelName := msg.params[0] - ch := channels[ircToCanon(channelName)] - if ch == nil { - c.sendReply(ERR_NOSUCHCHANNEL, channelName) - return - } - - if len(msg.params) < 2 { - ircSendRPLTOPIC(c, ch) - return - } - - modes, present := ch.userModes[c] - if !present { - c.sendReply(ERR_NOTONCHANNEL, channelName) - return - } - - if 0 != ch.modes&ircChanModeProtectedTopic && - 0 == modes&ircChanModeOperator { - c.sendReply(ERR_CHANOPRIVSNEEDED, channelName) - return - } - - ch.topic = msg.params[1] - ch.topicWho = fmt.Sprintf("%s@%s@%s", c.nickname, c.username, c.hostname) - ch.topicTime = time.Now() - - message := fmt.Sprintf(":%s!%s@%s TOPIC %s :%s", - c.nickname, c.username, c.hostname, channelName, ch.topic) - ircChannelMulticast(ch, message, nil) -} - -func ircTryPart(c *client, channelName string, reason string) { - if reason == "" { - reason = c.nickname - } - - ch := channels[ircToCanon(channelName)] - if ch == nil { - c.sendReply(ERR_NOSUCHCHANNEL, channelName) - return - } - - if _, present := ch.userModes[c]; !present { - c.sendReply(ERR_NOTONCHANNEL, channelName) - return - } - - message := fmt.Sprintf(":%s@%s@%s PART %s :%s", - c.nickname, c.username, c.hostname, channelName, reason) - if 0 == ch.modes&ircChanModeQuiet { - ircChannelMulticast(ch, message, nil) - } else { - c.send(message) - } - - delete(ch.userModes, c) - ircChannelDestroyIfEmpty(ch) -} - -func ircPartAllChannels(c *client) { - for _, ch := range channels { - if _, present := ch.userModes[c]; present { - ircTryPart(c, ch.name, "") - } - } -} - -func ircHandlePART(msg *message, c *client) { - if len(msg.params) < 1 { - c.sendReply(ERR_NEEDMOREPARAMS, msg.command) - return - } - - reason := "" - if len(msg.params) > 1 { - reason = msg.params[1] - } - - for _, channelName := range splitString(msg.params[0], ",", true) { - ircTryPart(c, channelName, reason) - } -} - -func ircTryKick(c *client, channelName, nick, reason string) { - ch := channels[ircToCanon(channelName)] - if ch == nil { - c.sendReply(ERR_NOSUCHCHANNEL, channelName) - return - } - - if modes, present := ch.userModes[c]; !present { - c.sendReply(ERR_NOTONCHANNEL, channelName) - return - } else if 0 == modes&ircChanModeOperator { - c.sendReply(ERR_CHANOPRIVSNEEDED, channelName) - return - } - - client := users[ircToCanon(nick)] - if _, present := ch.userModes[client]; client == nil || !present { - c.sendReply(ERR_USERNOTINCHANNEL, nick, channelName) - return - } - - message := fmt.Sprintf(":%s@%s@%s KICK %s %s :%s", - c.nickname, c.username, c.hostname, channelName, nick, reason) - if 0 == ch.modes&ircChanModeQuiet { - ircChannelMulticast(ch, message, nil) - } else { - c.send(message) - } - - delete(ch.userModes, client) - ircChannelDestroyIfEmpty(ch) -} - -func ircHandleKICK(msg *message, c *client) { - if len(msg.params) < 2 { - c.sendReply(ERR_NEEDMOREPARAMS, msg.command) - return - } - - reason := c.nickname - if len(msg.params) > 2 { - reason = msg.params[2] - } - - targetChannels := splitString(msg.params[0], ",", true) - targetUsers := splitString(msg.params[1], ",", true) - - if len(channels) == 1 { - for i := 0; i < len(targetUsers); i++ { - ircTryKick(c, targetChannels[0], targetUsers[i], reason) - } - } else { - for i := 0; i < len(channels) && i < len(targetUsers); i++ { - ircTryKick(c, targetChannels[i], targetUsers[i], reason) - } - } -} - -func ircSendInviteNotifications(ch *channel, c, target *client) { - for client := range ch.userModes { - if client != target && 0 != client.capsEnabled&ircCapInviteNotify { - client.sendf(":%s!%s@%s INVITE %s %s", - c.nickname, c.username, c.hostname, target.nickname, ch.name) - } - } -} - -func ircHandleINVITE(msg *message, c *client) { - if len(msg.params) < 2 { - c.sendReply(ERR_NEEDMOREPARAMS, msg.command) - return - } - - target, channelName := msg.params[0], msg.params[1] - client := users[ircToCanon(target)] - if client == nil { - c.sendReply(ERR_NOSUCHNICK, target) - return - } - - if ch := channels[ircToCanon(channelName)]; ch != nil { - invitingModes, invitingPresent := ch.userModes[c] - if !invitingPresent { - c.sendReply(ERR_NOTONCHANNEL, channelName) - return - } - if _, present := ch.userModes[client]; present { - c.sendReply(ERR_USERONCHANNEL, target, channelName) - return - } - - if 0 != invitingModes&ircChanModeOperator { - client.invites[ircToCanon(channelName)] = true - } else if 0 != ch.modes&ircChanModeInviteOnly { - c.sendReply(ERR_CHANOPRIVSNEEDED, channelName) - return - } - - // It's not specified when and how we should send out invite-notify. - if 0 != ch.modes&ircChanModeInviteOnly { - ircSendInviteNotifications(ch, c, client) - } - } - - client.sendf(":%s!%s@%s INVITE %s %s", - c.nickname, c.username, c.hostname, client.nickname, channelName) - if client.awayMessage != "" { - c.sendReply(RPL_AWAY, client.nickname, client.awayMessage) - } - c.sendReply(RPL_INVITING, client.nickname, channelName) -} - -func ircTryJoin(c *client, channelName, key string) { - ch := channels[ircToCanon(channelName)] - var userMode uint - if ch == nil { - if !ircIsValidChannelName(channelName) { - c.sendReply(ERR_BADCHANMASK, channelName) - return - } - ch = ircChannelCreate(channelName) - userMode = ircChanModeOperator - } else if _, present := ch.userModes[c]; present { - return - } - - _, invitedByChanop := c.invites[ircToCanon(channelName)] - if 0 != ch.modes&ircChanModeInviteOnly && c.inMaskList(ch.inviteList) && - !invitedByChanop { - c.sendReply(ERR_INVITEONLYCHAN, channelName) - return - } - if ch.key != "" && (key == "" || key != ch.key) { - c.sendReply(ERR_BADCHANNELKEY, channelName) - return - } - if ch.userLimit != -1 && len(ch.userModes) >= ch.userLimit { - c.sendReply(ERR_CHANNELISFULL, channelName) - return - } - if c.inMaskList(ch.banList) && !c.inMaskList(ch.exceptionList) && - !invitedByChanop { - c.sendReply(ERR_BANNEDFROMCHAN, channelName) - return - } - - // Destroy any invitation as there's no other way to get rid of it. - delete(c.invites, ircToCanon(channelName)) - - ch.userModes[c] = userMode - - message := fmt.Sprintf(":%s!%s@%s JOIN %s", - c.nickname, c.username, c.hostname, channelName) - if 0 == ch.modes&ircChanModeQuiet { - ircChannelMulticast(ch, message, nil) - } else { - c.send(message) - } - - ircSendRPLTOPIC(c, ch) - ircSendRPLNAMREPLY(c, ch, nil) - c.sendReply(RPL_ENDOFNAMES, ch.name) -} - -func ircHandleJOIN(msg *message, c *client) { - if len(msg.params) < 1 { - c.sendReply(ERR_NEEDMOREPARAMS, msg.command) - return - } - - if msg.params[0] == "0" { - ircPartAllChannels(c) - return - } - - targetChannels := splitString(msg.params[0], ",", true) - - var keys []string - if len(msg.params) > 1 { - keys = splitString(msg.params[1], ",", true) - } - - for i, name := range targetChannels { - key := "" - if i < len(keys) { - key = keys[i] - } - ircTryJoin(c, name, key) - } -} - -func ircHandleSUMMON(msg *message, c *client) { - c.sendReply(ERR_SUMMONDISABLED) -} - -func ircHandleUSERS(msg *message, c *client) { - c.sendReply(ERR_USERSDISABLED) -} - -func ircHandleAWAY(msg *message, c *client) { - if len(msg.params) < 1 { - c.awayMessage = "" - c.sendReply(RPL_UNAWAY) - } else { - c.awayMessage = msg.params[0] - c.sendReply(RPL_NOWAWAY) - } -} - -func ircHandleISON(msg *message, c *client) { - if len(msg.params) < 1 { - c.sendReply(ERR_NEEDMOREPARAMS, msg.command) - return - } - - var on []string - for _, nick := range msg.params { - if client := users[ircToCanon(nick)]; client != nil { - on = append(on, nick) - } - } - c.sendReply(RPL_ISON, strings.Join(on, " ")) -} - -func ircHandleADMIN(msg *message, c *client) { - if len(msg.params) > 0 && !ircIsThisMe(msg.params[0]) { - c.sendReply(ERR_NOSUCHSERVER, msg.params[0]) - return - } - c.sendReply(ERR_NOADMININFO, serverName) -} - -func ircHandleStatsLinks(c *client, msg *message) { - // There is only an "l" query in RFC 2812 but we cannot link, - // so instead we provide the "L" query giving information for all users. - filter := "" - if len(msg.params) > 1 { - filter = msg.params[1] - } - - for _, client := range users { - if filter != "" && !ircEqual(client.nickname, filter) { - continue - } - c.sendReply(RPL_STATSLINKINFO, - client.address, // linkname - len(client.sendQ), // sendq - client.nSentMessages, client.sentBytes/1024, - client.nReceivedMessages, client.receivedBytes/1024, - time.Now().Sub(client.opened)/time.Second) - } -} - -func ircHandleStatsCommands(c *client) { - for name, handler := range ircHandlers { - if handler.nReceived > 0 { - c.sendReply(RPL_STATSCOMMANDS, name, - handler.nReceived, handler.bytesReceived, 0) - } - } -} - -// We need to do it this way because of an initialization loop concerning -// ircHandlers. Workaround proposed by rsc in go #1817. -var ircHandleStatsCommandsIndirect func(c *client) - -func init() { - ircHandleStatsCommandsIndirect = ircHandleStatsCommands -} - -func ircHandleStatsUptime(c *client) { - uptime := time.Now().Sub(started) / time.Second - - days := uptime / 60 / 60 / 24 - hours := (uptime % (60 * 60 * 24)) / 60 / 60 - mins := (uptime % (60 * 60)) / 60 - secs := uptime % 60 - - c.sendReply(RPL_STATSUPTIME, days, hours, mins, secs) -} - -func ircHandleSTATS(msg *message, c *client) { - var query byte - if len(msg.params) > 0 && len(msg.params[0]) > 0 { - query = msg.params[0][0] - } - - if len(msg.params) > 1 && !ircIsThisMe(msg.params[1]) { - c.sendReply(ERR_NOSUCHSERVER, msg.params[0]) - return - } - if 0 == c.mode&ircUserModeOperator { - c.sendReply(ERR_NOPRIVILEGES) - return - } - - switch query { - case 'L': - ircHandleStatsLinks(c, msg) - case 'm': - ircHandleStatsCommandsIndirect(c) - case 'u': - ircHandleStatsUptime(c) - } - c.sendReply(RPL_ENDOFSTATS, query) -} - -func ircHandleLINKS(msg *message, c *client) { - if len(msg.params) > 1 && !ircIsThisMe(msg.params[0]) { - c.sendReply(ERR_NEEDMOREPARAMS, msg.command) - return - } - - mask := "*" - if len(msg.params) > 0 { - if len(msg.params) > 1 { - mask = msg.params[1] - } else { - mask = msg.params[0] - } - } - - if ircFnmatch(mask, serverName) { - c.sendReply(RPL_LINKS, mask, serverName, - 0 /* hop count */, config["server_info"]) - } - c.sendReply(RPL_ENDOFLINKS, mask) -} - -func ircHandleWALLOPS(msg *message, c *client) { - if len(msg.params) < 1 { - c.sendReply(ERR_NEEDMOREPARAMS, msg.command) - return - } - if 0 == c.mode&ircUserModeOperator { - c.sendReply(ERR_NOPRIVILEGES) - return - } - - // Our interpretation: anonymize the sender, - // and target all users who want to receive these messages. - for _, client := range users { - if client == c || 0 != client.mode&ircUserModeRxWallops { - client.sendf(":%s WALLOPS :%s", serverName, msg.params[0]) - } - } -} - -func ircHandleKILL(msg *message, c *client) { - if len(msg.params) < 2 { - c.sendReply(ERR_NEEDMOREPARAMS, msg.command) - return - } - if 0 == c.mode&ircUserModeOperator { - c.sendReply(ERR_NOPRIVILEGES) - return - } - - target := users[ircToCanon(msg.params[0])] - if target == nil { - c.sendReply(ERR_NOSUCHNICK, msg.params[0]) - return - } - - c.sendf(":%s!%s@%s KILL %s :%s", - c.nickname, c.username, c.hostname, target.nickname, msg.params[1]) - target.closeLink(fmt.Sprintf("Killed by %s: %s", c.nickname, msg.params[1])) -} - -func ircHandleDIE(msg *message, c *client) { - if 0 == c.mode&ircUserModeOperator { - c.sendReply(ERR_NOPRIVILEGES) - } else if !quitting { - initiateQuit() - } -} - -// ----------------------------------------------------------------------------- - -// TODO: Add an index for ERR_NOSUCHSERVER validation? -// TODO: Add a minimal parameter count? -// TODO: Add a field for oper-only commands? Use flags? -var ircHandlers = map[string]*ircCommand{ - "WEBIRC": {false, ircHandleWEBIRC, 0, 0}, - "CAP": {false, ircHandleCAP, 0, 0}, - "PASS": {false, ircHandlePASS, 0, 0}, - "NICK": {false, ircHandleNICK, 0, 0}, - "USER": {false, ircHandleUSER, 0, 0}, - - "USERHOST": {true, ircHandleUSERHOST, 0, 0}, - "LUSERS": {true, ircHandleLUSERS, 0, 0}, - "MOTD": {true, ircHandleMOTD, 0, 0}, - "PING": {true, ircHandlePING, 0, 0}, - "PONG": {false, ircHandlePONG, 0, 0}, - "QUIT": {false, ircHandleQUIT, 0, 0}, - "TIME": {true, ircHandleTIME, 0, 0}, - "VERSION": {true, ircHandleVERSION, 0, 0}, - "USERS": {true, ircHandleUSERS, 0, 0}, - "SUMMON": {true, ircHandleSUMMON, 0, 0}, - "AWAY": {true, ircHandleAWAY, 0, 0}, - "ADMIN": {true, ircHandleADMIN, 0, 0}, - "STATS": {true, ircHandleSTATS, 0, 0}, - "LINKS": {true, ircHandleLINKS, 0, 0}, - "WALLOPS": {true, ircHandleWALLOPS, 0, 0}, - - "MODE": {true, ircHandleMODE, 0, 0}, - "PRIVMSG": {true, ircHandlePRIVMSG, 0, 0}, - "NOTICE": {true, ircHandleNOTICE, 0, 0}, - "JOIN": {true, ircHandleJOIN, 0, 0}, - "PART": {true, ircHandlePART, 0, 0}, - "KICK": {true, ircHandleKICK, 0, 0}, - "INVITE": {true, ircHandleINVITE, 0, 0}, - "TOPIC": {true, ircHandleTOPIC, 0, 0}, - "LIST": {true, ircHandleLIST, 0, 0}, - "NAMES": {true, ircHandleNAMES, 0, 0}, - "WHO": {true, ircHandleWHO, 0, 0}, - "WHOIS": {true, ircHandleWHOIS, 0, 0}, - "WHOWAS": {true, ircHandleWHOWAS, 0, 0}, - "ISON": {true, ircHandleISON, 0, 0}, - - "KILL": {true, ircHandleKILL, 0, 0}, - "DIE": {true, ircHandleDIE, 0, 0}, -} - -func ircProcessMessage(c *client, msg *message, raw string) { - if c.closing { - return - } - - c.nReceivedMessages++ - c.receivedBytes += len(raw) + 2 - - if !c.antiflood.check() { - c.closeLink("Excess flood") - return - } - - if cmd, ok := ircHandlers[ircToCanon(msg.command)]; !ok { - c.sendReply(ERR_UNKNOWNCOMMAND, msg.command) - } else { - cmd.nReceived++ - cmd.bytesReceived += len(raw) + 2 - - if cmd.requiresRegistration && !c.registered { - c.sendReply(ERR_NOTREGISTERED) - } else { - cmd.handler(msg, c) - } - } -} - -// --- Network I/O ------------------------------------------------------------- - -// Handle the results from initializing the client's connection. -func (c *client) onPrepared(host string, isTLS bool) { - c.printDebug("client resolved to %s, TLS %t", host, isTLS) - if !isTLS { - c.conn = c.transport.(connCloseWriter) - } else if tlsConf != nil { - c.tls = tls.Server(c.transport, tlsConf) - c.conn = c.tls - } else { - c.printDebug("could not initialize TLS: disabled") - c.kill("TLS support disabled") - return - } - - c.hostname = host - c.address = net.JoinHostPort(host, c.port) - - // If we tried to send any data before now, we would need to flushSendQ. - go read(c) - c.reading = true - c.setPingTimer() -} - -// Handle the results from trying to read from the client connection. -func (c *client) onRead(data []byte, readErr error) { - if !c.reading { - // Abusing the flag to emulate CloseRead and skip over data, see below. - return - } - - c.recvQ = append(c.recvQ, data...) - for { - // XXX: This accepts even simple LF newlines, even though they're not - // really allowed by the protocol. - advance, token, _ := bufio.ScanLines(c.recvQ, false /* atEOF */) - if advance == 0 { - break - } - - // XXX: And since it accepts LF, we miscalculate receivedBytes within. - c.recvQ = c.recvQ[advance:] - line := string(token) - c.printDebug("-> %s", line) - - if msg := ircParseMessage(line); msg == nil { - c.printDebug("error: invalid line") - } else { - ircProcessMessage(c, msg, line) - } - } - - if readErr != nil { - c.reading = false - - if readErr != io.EOF { - c.printDebug("%s", readErr) - c.kill(readErr.Error()) - } else if c.closing { - // Disregarding whether a clean shutdown has happened or not. - c.printDebug("client finished shutdown") - c.kill("") - } else { - c.printDebug("client EOF") - c.closeLink("") - } - } else if len(c.recvQ) > 8192 { - c.closeLink("recvQ overrun") - - // tls.Conn doesn't have the CloseRead method (and it needs to be able - // to read from the TCP connection even for writes, so there isn't much - // sense in expecting the implementation to do anything useful), - // otherwise we'd use it to block incoming packet data. - c.reading = false - } -} - -// Spawn a goroutine to flush the sendQ if possible and necessary. -func (c *client) flushSendQ() { - if !c.writing && c.conn != nil { - go write(c, c.sendQ) - c.writing = true - } -} - -// Handle the results from trying to write to the client connection. -func (c *client) onWrite(written int, writeErr error) { - c.sendQ = c.sendQ[written:] - c.writing = false - - if writeErr != nil { - c.printDebug("%s", writeErr) - c.kill(writeErr.Error()) - } else if len(c.sendQ) > 0 { - c.flushSendQ() - } else if c.closing { - if c.reading { - c.conn.CloseWrite() - } else { - c.kill("") - } - } -} - -// --- Worker goroutines ------------------------------------------------------- - -func accept(ln net.Listener) { - for { - // Error handling here may be tricky, see go #6163, #24808. - if conn, err := ln.Accept(); err != nil { - // See go #4373, they're being dicks. Another solution would be to - // pass a done channel to this function and close it before closing - // all the listeners, returning from here if it's readable. - if strings.Contains(err.Error(), - "use of closed network connection") { - return - } - // XXX: net.Error.Temporary() has been deprecated in 1.18. - if op, ok := err.(net.Error); !ok || !op.Temporary() { - exitFatal("%s", err) - } else { - printError("%s", err) - } - } else { - // TCP_NODELAY is set by default on TCPConns. - conns <- conn - } - } -} - -func prepare(client *client) { - conn, host := client.transport, client.hostname - - // The Cgo resolver doesn't pthread_cancel getnameinfo threads, so not - // bothering with pointless contexts. - ch := make(chan string, 1) - go func() { - defer close(ch) - if names, err := net.LookupAddr(host); err != nil { - printError("%s", err) - } else { - ch <- names[0] - } - }() - - // While we can't cancel it, we still want to set a timeout on it. - select { - case <-time.After(5 * time.Second): - case resolved, ok := <-ch: - if ok { - host = resolved - } - } - - // Note that in this demo application the autodetection prevents non-TLS - // clients from receiving any messages until they send something. - isTLS := false - if sysconn, err := conn.(syscall.Conn).SyscallConn(); err != nil { - // This is just for the TLS detection and doesn't need to be fatal. - printError("%s", err) - } else { - isTLS = detectTLS(sysconn) - } - - prepared <- preparedEvent{client, host, isTLS} -} - -func read(client *client) { - // A new buffer is allocated each time we receive some bytes, because of - // thread-safety. Therefore the buffer shouldn't be too large, or we'd - // need to copy it each time into a precisely sized new buffer. - var err error - for err == nil { - var ( - buf [512]byte - n int - ) - n, err = client.conn.Read(buf[:]) - reads <- readEvent{client, buf[:n], err} - } -} - -// Flush sendQ, which is passed by parameter so that there are no data races. -func write(client *client, data []byte) { - // We just write as much as we can, the main goroutine does the looping. - n, err := client.conn.Write(data) - writes <- writeEvent{client, n, err} -} - -// --- Event loop -------------------------------------------------------------- - -func processOneEvent() { - select { - case <-sigs: - if quitting { - forceQuit("requested by user") - } else { - initiateQuit() - } - - case <-quitTimer: - forceQuit("timeout") - - case callback := <-timers: - callback() - - case conn := <-conns: - if maxConnections > 0 && len(clients) >= maxConnections { - printDebug("connection limit reached, refusing connection") - conn.Close() - break - } - - address := conn.RemoteAddr().String() - host, port, err := net.SplitHostPort(address) - if err != nil { - // In effect, we require TCP/UDP, as they have port numbers. - exitFatal("%s", err) - } - - c := &client{ - transport: conn, - address: address, - hostname: host, - port: port, - capVersion: 301, - opened: time.Now(), - lastActive: time.Now(), - // TODO: Make this configurable and more fine-grained. - antiflood: newFloodDetector(10*time.Second, 20), - } - - clients[c] = true - c.printDebug("new client") - go prepare(c) - - // The TLS autodetection in prepare needs to have a timeout. - c.setKillTimer() - - case ev := <-prepared: - if _, ok := clients[ev.client]; ok { - ev.client.onPrepared(ev.host, ev.isTLS) - } - - case ev := <-reads: - if _, ok := clients[ev.client]; ok { - ev.client.onRead(ev.data, ev.err) - } - - case ev := <-writes: - if _, ok := clients[ev.client]; ok { - ev.client.onWrite(ev.written, ev.err) - } - } -} - -// --- Application setup ------------------------------------------------------- - -func ircInitializeTLS() error { - configCert, configKey := config["tls_cert"], config["tls_key"] - - // Only try to enable SSL support if the user configures it; it is not - // a failure if no one has requested it. - if configCert == "" && configKey == "" { - return nil - } else if configCert == "" { - return errors.New("no TLS certificate set") - } else if configKey == "" { - return errors.New("no TLS private key set") - } - - pathCert := resolveFilename(configCert, resolveRelativeConfigFilename) - if pathCert == "" { - return fmt.Errorf("cannot find file: %s", configCert) - } - - pathKey := resolveFilename(configKey, resolveRelativeConfigFilename) - if pathKey == "" { - return fmt.Errorf("cannot find file: %s", configKey) - } - - cert, err := tls.LoadX509KeyPair(pathCert, pathKey) - if err != nil { - return err - } - - tlsConf = &tls.Config{ - Certificates: []tls.Certificate{cert}, - ClientAuth: tls.RequestClientCert, - SessionTicketsDisabled: true, - } - return nil -} - -func ircInitializeCatalog() error { - configCatalog := config["catalog"] - if configCatalog == "" { - return nil - } - - path := resolveFilename(configCatalog, resolveRelativeConfigFilename) - if path == "" { - return fmt.Errorf("cannot find file: %s", configCatalog) - } - - f, err := os.Open(path) - if err != nil { - return fmt.Errorf("failed reading the MOTD file: %s", err) - } - defer f.Close() - - scanner := bufio.NewScanner(f) - catalog = make(map[int]string) - for lineNo := 1; scanner.Scan(); lineNo++ { - line := strings.TrimLeft(scanner.Text(), " \t") - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - delim := strings.IndexAny(line, " \t") - if delim < 0 { - return fmt.Errorf("%s:%d: malformed line", path, lineNo) - } - - id, err := strconv.ParseUint(line[:delim], 10, 16) - if err != nil { - return fmt.Errorf("%s:%d: %s", path, lineNo, err) - } - - catalog[int(id)] = line[delim+1:] - } - return scanner.Err() -} - -func ircInitializeMOTD() error { - configMOTD := config["motd"] - if configMOTD == "" { - return nil - } - - path := resolveFilename(configMOTD, resolveRelativeConfigFilename) - if path == "" { - return fmt.Errorf("cannot find file: %s", configMOTD) - } - - f, err := os.Open(path) - if err != nil { - return fmt.Errorf("failed reading the MOTD file: %s", err) - } - defer f.Close() - - scanner := bufio.NewScanner(f) - motd = nil - for scanner.Scan() { - motd = append(motd, scanner.Text()) - } - return scanner.Err() -} - -type configProcessor struct { - err error // any error that has occurred so far -} - -func (cp *configProcessor) read(name string, process func(string) string) { - if cp.err != nil { - return - } - if err := process(config[name]); err != "" { - cp.err = fmt.Errorf("invalid configuration value for `%s': %s", - name, err) - } -} - -// This function handles values that require validation before their first use, -// or some kind of a transformation (such as conversion to an integer) needs -// to be done before they can be used directly. -func ircParseConfig() error { - cp := &configProcessor{} - cp.read("ping_interval", func(value string) string { - if u, err := strconv.ParseUint( - config["ping_interval"], 10, 32); err != nil { - return err.Error() - } else if u < 1 { - return "the value is out of range" - } else { - pingInterval = time.Second * time.Duration(u) - } - return "" - }) - cp.read("max_connections", func(value string) string { - if i, err := strconv.ParseInt( - value, 10, 32); err != nil { - return err.Error() - } else if i < 0 { - return "the value is out of range" - } else { - maxConnections = int(i) - } - return "" - }) - cp.read("operators", func(value string) string { - operators = make(map[string]bool) - for _, fp := range splitString(value, ",", true) { - if !ircIsValidFingerprint(fp) { - return "invalid fingerprint value" - } - operators[strings.ToLower(fp)] = true - } - return "" - }) - return cp.err -} - -func ircInitializeServerName() error { - if value := config["server_name"]; value != "" { - if err := ircValidateHostname(value); err != nil { - return err - } - serverName = value - return nil - } - - if hostname, err := os.Hostname(); err != nil { - return err - } else if err := ircValidateHostname(hostname); err != nil { - return err - } else { - serverName = hostname - } - return nil -} - -func ircSetupListenFDs() error { - for _, address := range splitString(config["bind"], ",", true) { - ln, err := net.Listen("tcp", address) - if err != nil { - return err - } - listeners = append(listeners, ln) - printStatus("listening on %s", address) - } - if len(listeners) == 0 { - return errors.New("network setup failed: no ports to listen on") - } - for _, ln := range listeners { - go accept(ln) - } - return nil -} - -// --- Main -------------------------------------------------------------------- - -func main() { - flag.BoolVar(&debugMode, "debug", false, "run in verbose debug mode") - version := flag.Bool("version", false, "show version and exit") - writeDefaultCfg := flag.Bool("writedefaultcfg", false, - "write a default configuration file and exit") - systemd := flag.Bool("systemd", false, "log in systemd format") - - flag.Parse() - - if *version { - fmt.Printf("%s %s\n", projectName, projectVersion) - return - } - if *writeDefaultCfg { - callSimpleConfigWriteDefault("", configTable) - return - } - if *systemd { - logMessage = logMessageSystemd - } - if flag.NArg() > 0 { - flag.Usage() - os.Exit(2) - } - - // Note that this has become unnecessary since Go 1.19. - var limit syscall.Rlimit - if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &limit); err == nil && - limit.Cur != limit.Max { - limit.Cur = limit.Max - syscall.Setrlimit(syscall.RLIMIT_NOFILE, &limit) - } - - config = make(simpleConfig) - config.loadDefaults(configTable) - if err := config.updateFromFile(); err != nil && !os.IsNotExist(err) { - printError("error loading configuration: %s", err) - os.Exit(1) - } - - started = time.Now() - signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) - - for _, fn := range []func() error{ - ircInitializeTLS, - ircInitializeServerName, - ircInitializeMOTD, - ircInitializeCatalog, - ircParseConfig, - ircSetupListenFDs, - } { - if err := fn(); err != nil { - exitFatal("%s", err) - } - } - - for !quitting || len(clients) > 0 { - processOneEvent() - } -} diff --git a/xS/main_test.go b/xS/main_test.go deleted file mode 100644 index 8241b4e..0000000 --- a/xS/main_test.go +++ /dev/null @@ -1,168 +0,0 @@ -// -// Copyright (c) 2015 - 2018, Přemysl Eric Janouch -// -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY -// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION -// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN -// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -// - -package main - -import ( - "crypto/tls" - "net" - "os" - "reflect" - "syscall" - "testing" -) - -func TestSplitString(t *testing.T) { - var splitStringTests = []struct { - s, delims string - ignoreEmpty bool - result []string - }{ - {",a,,bc", ",", false, []string{"", "a", "", "bc"}}, - {",a,,bc", ",", true, []string{"a", "bc"}}, - {"a,;bc,", ",;", false, []string{"a", "", "bc", ""}}, - {"a,;bc,", ",;", true, []string{"a", "bc"}}, - {"", ",", false, []string{""}}, - {"", ",", true, nil}, - } - - for i, d := range splitStringTests { - got := splitString(d.s, d.delims, d.ignoreEmpty) - if !reflect.DeepEqual(got, d.result) { - t.Errorf("case %d: %v should be %v\n", i, got, d.result) - } - } -} - -func socketpair() (*os.File, *os.File, error) { - pair, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) - if err != nil { - return nil, nil, err - } - - // See go #24331, this makes 1.11 use the internal poller - // while there wasn't a way to achieve that before. - if err := syscall.SetNonblock(int(pair[0]), true); err != nil { - return nil, nil, err - } - if err := syscall.SetNonblock(int(pair[1]), true); err != nil { - return nil, nil, err - } - - fa := os.NewFile(uintptr(pair[0]), "a") - if fa == nil { - return nil, nil, os.ErrInvalid - } - - fb := os.NewFile(uintptr(pair[1]), "b") - if fb == nil { - fa.Close() - return nil, nil, os.ErrInvalid - } - - return fa, fb, nil -} - -func TestDetectTLS(t *testing.T) { - detectTLSFromFunc := func(t *testing.T, writer func(net.Conn)) bool { - // net.Pipe doesn't use file descriptors, we need a socketpair. - sockA, sockB, err := socketpair() - if err != nil { - t.Fatal(err) - } - defer sockA.Close() - defer sockB.Close() - - fcB, err := net.FileConn(sockB) - if err != nil { - t.Fatal(err) - } - go writer(fcB) - - fcA, err := net.FileConn(sockA) - if err != nil { - t.Fatal(err) - } - sc, err := fcA.(syscall.Conn).SyscallConn() - if err != nil { - t.Fatal(err) - } - return detectTLS(sc) - } - - t.Run("SSL_2.0", func(t *testing.T) { - if !detectTLSFromFunc(t, func(fc net.Conn) { - // The obsolete, useless, unsupported SSL 2.0 record format. - _, _ = fc.Write([]byte{0x80, 0x01, 0x01}) - }) { - t.Error("could not detect SSL") - } - }) - t.Run("crypto_tls", func(t *testing.T) { - if !detectTLSFromFunc(t, func(fc net.Conn) { - conn := tls.Client(fc, &tls.Config{InsecureSkipVerify: true}) - _ = conn.Handshake() - }) { - t.Error("could not detect TLS") - } - }) - t.Run("text", func(t *testing.T) { - if detectTLSFromFunc(t, func(fc net.Conn) { - _, _ = fc.Write([]byte("ПРЕВЕД")) - }) { - t.Error("detected UTF-8 as TLS") - } - }) - t.Run("EOF", func(t *testing.T) { - type connCloseWriter interface { - net.Conn - CloseWrite() error - } - if detectTLSFromFunc(t, func(fc net.Conn) { - _ = fc.(connCloseWriter).CloseWrite() - }) { - t.Error("detected EOF as TLS") - } - }) -} - -func TestIRC(t *testing.T) { - msg := ircParseMessage( - `@first=a\:\s\r\n\\;2nd :srv hi there :good m8 :how are you?`) - - if !reflect.DeepEqual(msg.tags, map[string]string{ - "first": "a; \r\n\\", - "2nd": "", - }) { - t.Error("tags parsed incorrectly") - } - - if msg.nick != "srv" || msg.user != "" || msg.host != "" { - t.Error("server name parsed incorrectly") - } - if msg.command != "hi" { - t.Error("command name parsed incorrectly") - } - if !reflect.DeepEqual(msg.params, - []string{"there", "good m8 :how are you?"}) { - t.Error("params parsed incorrectly") - } - - if !ircEqual("[fag]^", "{FAG}~") { - t.Error("string case comparison not according to RFC 2812") - } - - // TODO: More tests. -} diff --git a/xS/xS-gen-replies.awk b/xS/xS-gen-replies.awk index fce7b50..94a338f 100755 --- a/xS/xS-gen-replies.awk +++ b/xS/xS-gen-replies.awk @@ -1,15 +1,19 @@ #!/usr/bin/awk -f /^[0-9]+ *(ERR|RPL)_[A-Z]+ *".*"$/ { - match($0, /".*"/); - ids[$1] = $2; - texts[$2] = substr($0, RSTART, RLENGTH); + match($0, /".*"/) + ids[$1] = $2 + texts[$2] = substr($0, RSTART, RLENGTH) } END { - print "package " ENVIRON["GOPACKAGE"] "\n\nconst (" + print "package main" + print "" + print "const (" for (i in ids) printf("\t%s = %s\n", ids[i], i) - print ")\n\nvar defaultReplies = map[int]string{" + print ")" + print "" + print "var defaultReplies = map[int]string{" for (i in ids) print "\t" ids[i] ": " texts[ids[i]] "," print "}" diff --git a/xS/xS.go b/xS/xS.go new file mode 100644 index 0000000..e6c3b3c --- /dev/null +++ b/xS/xS.go @@ -0,0 +1,3527 @@ +// +// Copyright (c) 2014 - 2022, Přemysl Eric Janouch +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +// + +// xS is a straight-forward port of xD IRCd from C. +package main + +import ( + "bufio" + "bytes" + "crypto/sha256" + "crypto/tls" + "encoding/hex" + "errors" + "flag" + "fmt" + "io" + "io/ioutil" + "log/syslog" + "net" + "os" + "os/signal" + "os/user" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" + "time" +) + +var debugMode = false + +const ( + projectName = "xS" + // TODO: Consider using the same version number for all subprojects. + projectVersion = "0" +) + +// --- Logging ----------------------------------------------------------------- + +type logPrio int + +const ( + prioFatal logPrio = iota + prioError + prioWarning + prioStatus + prioDebug +) + +func (lp logPrio) prefix() string { + switch lp { + case prioFatal: + return "fatal: " + case prioError: + return "error: " + case prioWarning: + return "warning: " + case prioStatus: + return "" + case prioDebug: + return "debug: " + default: + panic("unhandled log priority") + } +} + +func (lp logPrio) syslogPrio() syslog.Priority { + switch lp { + case prioFatal: + return syslog.LOG_ERR + case prioError: + return syslog.LOG_ERR + case prioWarning: + return syslog.LOG_WARNING + case prioStatus: + return syslog.LOG_INFO + case prioDebug: + return syslog.LOG_DEBUG + default: + panic("unhandled log priority") + } +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +func logMessageStdio(prio logPrio, format string, args ...interface{}) { + // TODO: isatty-enabled colors based on prio. + os.Stderr.WriteString(time.Now().Format("2006-01-02 15:04:05 ") + + prio.prefix() + fmt.Sprintf(format, args...) + "\n") +} + +func logMessageSystemd(prio logPrio, format string, args ...interface{}) { + if prio == prioFatal { + // There is no corresponding syslog severity. + format = "fatal: " + format + } + fmt.Fprintf(os.Stderr, "<%d>%s\n", + prio.syslogPrio(), fmt.Sprintf(format, args...)) +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +var logMessage = logMessageStdio + +func printDebug(format string, args ...interface{}) { + if debugMode { + logMessage(prioDebug, format, args...) + } +} + +func printStatus(format string, args ...interface{}) { + logMessage(prioStatus, format, args...) +} +func printWarning(format string, args ...interface{}) { + logMessage(prioWarning, format, args...) +} +func printError(format string, args ...interface{}) { + logMessage(prioError, format, args...) +} + +// "fatal" is reserved for failures that would harm further operation. + +func printFatal(format string, args ...interface{}) { + logMessage(prioFatal, format, args...) +} + +func exitFatal(format string, args ...interface{}) { + printFatal(format, args...) + os.Exit(1) +} + +// --- Utilities --------------------------------------------------------------- + +// Split a string by a set of UTF-8 delimiters, optionally ignoring empty items. +func splitString(s, delims string, ignoreEmpty bool) (result []string) { + for { + end := strings.IndexAny(s, delims) + if end < 0 { + break + } + if !ignoreEmpty || end != 0 { + result = append(result, s[:end]) + } + s = s[end+1:] + } + if !ignoreEmpty || s != "" { + result = append(result, s) + } + return +} + +// Trivial SSL/TLS autodetection. The first block of data returned by Recvfrom +// must be at least three octets long for this to work reliably, but that should +// not pose a problem in practice. We might try waiting for them. +// +// SSL2: 1xxx xxxx | xxxx xxxx | <1> +// (message length) (client hello) +// SSL3/TLS: <22> | <3> | xxxx xxxx +// (handshake)| (protocol version) +// +// Note that Go 1.12's crypto/tls offers a slightly more straight-forward +// solution: "If a client sends an initial message that does not look like TLS, +// the server will no longer reply with an alert, and it will expose the +// underlying net.Conn in the new field Conn of RecordHeaderError." +func detectTLS(sysconn syscall.RawConn) (isTLS bool) { + sysconn.Read(func(fd uintptr) (done bool) { + var buf [3]byte + n, _, err := syscall.Recvfrom(int(fd), buf[:], syscall.MSG_PEEK) + switch { + case n == 3: + isTLS = buf[0]&0x80 != 0 && buf[2] == 1 + fallthrough + case n == 2: + isTLS = isTLS || buf[0] == 22 && buf[1] == 3 + case n == 1: + isTLS = buf[0] == 22 + case err == syscall.EAGAIN: + return false + } + return true + }) + return isTLS +} + +// --- File system ------------------------------------------------------------- + +// Look up the value of an XDG path from environment, or fall back to a default. +func getXDGHomeDir(name, def string) string { + env := os.Getenv(name) + if env != "" && env[0] == filepath.Separator { + return env + } + + home := "" + if v, ok := os.LookupEnv("HOME"); ok { + home = v + } else if u, _ := user.Current(); u != nil { + home = u.HomeDir + } + return filepath.Join(home, def) +} + +func resolveRelativeFilenameGeneric(paths []string, filename string) string { + for _, path := range paths { + // As per XDG spec, relative paths are ignored. + if path == "" || path[0] != filepath.Separator { + continue + } + + file := filepath.Join(path, filename) + if _, err := os.Stat(file); err == nil { + return file + } + } + return "" +} + +// Retrieve all XDG base directories for configuration files. +func getXDGConfigDirs() (result []string) { + home := getXDGHomeDir("XDG_CONFIG_HOME", ".config") + if home != "" { + result = append(result, home) + } + dirs := os.Getenv("XDG_CONFIG_DIRS") + if dirs == "" { + dirs = "/etc/xdg" + } + for _, path := range strings.Split(dirs, ":") { + if path != "" { + result = append(result, path) + } + } + return +} + +func resolveRelativeConfigFilename(filename string) string { + return resolveRelativeFilenameGeneric(getXDGConfigDirs(), + filepath.Join(projectName, filename)) +} + +func findTildeHome(username string) string { + if username != "" { + if u, _ := user.Lookup(username); u != nil { + return u.HomeDir + } + } else if u, _ := user.Current(); u != nil { + return u.HomeDir + } else if v, ok := os.LookupEnv("HOME"); ok { + return v + } + printDebug("failed to expand the home directory for %s", username) + return "~" + username +} + +func resolveFilename(filename string, relativeCB func(string) string) string { + // Absolute path is absolute. + if filename == "" || filename[0] == filepath.Separator { + return filename + } + if filename[0] != '~' { + return relativeCB(filename) + } + + // Paths to home directories ought to be absolute. + var n int + for n = 0; n < len(filename); n++ { + if filename[n] == filepath.Separator { + break + } + } + return findTildeHome(filename[1:n]) + filename[n:] +} + +// --- Simple file I/O --------------------------------------------------------- + +// Overwrites filename contents with data; creates directories as needed. +func writeFile(path string, data []byte) error { + if dir := filepath.Dir(path); dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + } + return ioutil.WriteFile(path, data, 0644) +} + +// Wrapper for writeFile that makes sure that the new data has been written +// to disk in its entirety before overriding the old file. +func writeFileSafe(path string, data []byte) error { + temp := path + ".new" + if err := writeFile(temp, data); err != nil { + return err + } + return os.Rename(temp, path) +} + +// --- Simple configuration ---------------------------------------------------- + +// This is the bare minimum to make an application configurable. +// Keys are stripped of surrounding whitespace, values are not. + +type simpleConfigItem struct { + key string // INI key + def string // default value + description string // documentation +} + +type simpleConfig map[string]string + +func (sc simpleConfig) loadDefaults(table []simpleConfigItem) { + for _, item := range table { + sc[item.key] = item.def + } +} + +func (sc simpleConfig) updateFromFile() error { + basename := projectName + ".conf" + path := resolveFilename(basename, resolveRelativeConfigFilename) + if path == "" { + return &os.PathError{ + Op: "cannot find", + Path: basename, + Err: os.ErrNotExist, + } + } + + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for lineNo := 1; scanner.Scan(); lineNo++ { + line := strings.TrimLeft(scanner.Text(), " \t") + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + equals := strings.IndexByte(line, '=') + if equals <= 0 { + return fmt.Errorf("%s:%d: malformed line", path, lineNo) + } + + sc[strings.TrimRight(line[:equals], " \t")] = line[equals+1:] + } + return scanner.Err() +} + +func writeConfigurationFile(pathHint string, data []byte) (string, error) { + path := pathHint + if path == "" { + path = filepath.Join(getXDGHomeDir("XDG_CONFIG_HOME", ".config"), + projectName, projectName+".conf") + } + + if err := writeFileSafe(path, data); err != nil { + return "", err + } + return path, nil +} + +func simpleConfigWriteDefault(pathHint string, prolog string, + table []simpleConfigItem) (string, error) { + data := []byte(prolog) + for _, item := range table { + data = append(data, fmt.Sprintf("# %s\n%s=%s\n", + item.description, item.key, item.def)...) + } + return writeConfigurationFile(pathHint, data) +} + +// Convenience wrapper suitable for most simple applications. +func callSimpleConfigWriteDefault(pathHint string, table []simpleConfigItem) { + prologLines := []string{ + `# ` + projectName + ` ` + projectVersion + ` configuration file`, + "#", + `# Relative paths are searched for in ${XDG_CONFIG_HOME:-~/.config}`, + `# /` + projectName + ` as well as in $XDG_CONFIG_DIRS/` + projectName, + ``, + ``, + } + + path, err := simpleConfigWriteDefault( + pathHint, strings.Join(prologLines, "\n"), table) + if err != nil { + exitFatal("%s", err) + } + + printStatus("configuration written to `%s'", path) +} + +// --- Configuration ----------------------------------------------------------- + +var configTable = []simpleConfigItem{ + {"server_name", "", "Server name"}, + {"server_info", "My server", "Brief server description"}, + {"motd", "", "MOTD filename"}, + {"catalog", "", "Localisation catalog"}, + + {"bind", ":6667", "Bind addresses of the IRC server"}, + {"tls_cert", "", "Server TLS certificate (PEM)"}, + {"tls_key", "", "Server TLS private key (PEM)"}, + {"webirc_password", "", "Password for WebIRC"}, + + {"operators", "", "IRCop TLS certificate SHA-256 fingerprints"}, + + {"max_connections", "0", "Global connection limit"}, + {"ping_interval", "180", "Interval between PINGs (sec)"}, +} + +// --- Rate limiter ------------------------------------------------------------ + +type floodDetector struct { + interval time.Duration // interval for the limit in seconds + limit uint // maximum number of events allowed + timestamps []time.Time // timestamps of last events + pos uint // index of the oldest event +} + +func newFloodDetector(interval time.Duration, limit uint) *floodDetector { + return &floodDetector{ + interval: interval, + limit: limit, + timestamps: make([]time.Time, limit+1), + pos: 0, + } +} + +func (fd *floodDetector) check() bool { + now := time.Now() + fd.timestamps[fd.pos] = now + + fd.pos++ + if fd.pos > fd.limit { + fd.pos = 0 + } + + var count uint + begin := now.Add(-fd.interval) + for _, ts := range fd.timestamps { + if ts.After(begin) { + count++ + } + } + return count <= fd.limit +} + +// --- IRC protocol ------------------------------------------------------------ + +func ircToLower(c byte) byte { + switch c { + case '[': + return '{' + case ']': + return '}' + case '\\': + return '|' + case '~': + return '^' + } + if c >= 'A' && c <= 'Z' { + return c + ('a' - 'A') + } + return c +} + +func ircToUpper(c byte) byte { + switch c { + case '{': + return '[' + case '}': + return ']' + case '|': + return '\\' + case '^': + return '~' + } + if c >= 'a' && c <= 'z' { + return c - ('a' - 'A') + } + return c +} + +// Convert identifier to a canonical form for case-insensitive comparisons. +// ircToUpper is used so that statically initialized maps can be in uppercase. +func ircToCanon(ident string) string { + var canon []byte + for _, c := range []byte(ident) { + canon = append(canon, ircToUpper(c)) + } + return string(canon) +} + +func ircEqual(s1, s2 string) bool { + return ircToCanon(s1) == ircToCanon(s2) +} + +func ircFnmatch(pattern string, s string) bool { + pattern, s = ircToCanon(pattern), ircToCanon(s) + // FIXME: This should not support [] ranges and handle '/' specially. + // We could translate the pattern to a regular expression. + matched, _ := filepath.Match(pattern, s) + return matched +} + +var reMsg = regexp.MustCompile( + `^(?:@([^ ]*) +)?(?::([^! ]*)(?:!([^@]*)@([^ ]*))? +)?([^ ]+)(.*)?$`) +var reArgs = regexp.MustCompile(`:.*| [^: ][^ ]*`) + +type message struct { + tags map[string]string // IRC 3.2 message tags + nick string // optional nickname + user string // optional username + host string // optional hostname or IP address + command string // command name + params []string // arguments +} + +func ircUnescapeMessageTag(value string) string { + var buf []byte + escape := false + for i := 0; i < len(value); i++ { + if escape { + switch value[i] { + case ':': + buf = append(buf, ';') + case 's': + buf = append(buf, ' ') + case 'r': + buf = append(buf, '\r') + case 'n': + buf = append(buf, '\n') + default: + buf = append(buf, value[i]) + } + escape = false + } else if value[i] == '\\' { + escape = true + } else { + buf = append(buf, value[i]) + } + } + return string(buf) +} + +func ircParseMessageTags(tags string, out map[string]string) { + for _, tag := range splitString(tags, ";", true /* ignoreEmpty */) { + if equal := strings.IndexByte(tag, '='); equal < 0 { + out[tag] = "" + } else { + out[tag[:equal]] = ircUnescapeMessageTag(tag[equal+1:]) + } + } +} + +func ircParseMessage(line string) *message { + m := reMsg.FindStringSubmatch(line) + if m == nil { + return nil + } + + msg := message{nil, m[2], m[3], m[4], m[5], nil} + if m[1] != "" { + msg.tags = make(map[string]string) + ircParseMessageTags(m[1], msg.tags) + } + for _, x := range reArgs.FindAllString(m[6], -1) { + msg.params = append(msg.params, x[1:]) + } + return &msg + +} + +// --- IRC token validation ---------------------------------------------------- + +// Everything as per RFC 2812 +const ( + ircMaxNickname = 9 + ircMaxHostname = 63 + ircMaxChannelName = 50 + ircMaxMessageLength = 510 +) + +const ( + reClassSpecial = "\\[\\]\\\\`_^{|}" + // "shortname" from RFC 2812 doesn't work how its author thought it would. + reShortname = "[0-9A-Za-z](-*[0-9A-Za-z])*" +) + +var ( + reHostname = regexp.MustCompile( + `^` + reShortname + `(\.` + reShortname + `)*$`) + + // Extending ASCII to the whole range of Unicode letters. + reNickname = regexp.MustCompile( + `^[\pL` + reClassSpecial + `][\pL` + reClassSpecial + `0-9-]*$`) + + // Notably, this won't match invalid UTF-8 characters, although the + // behaviour seems to be unstated in the documentation. + reUsername = regexp.MustCompile(`^[^\0\r\n @]+$`) + + reChannelName = regexp.MustCompile(`^[^\0\007\r\n ,:]+$`) + reKey = regexp.MustCompile(`^[^\r\n\f\t\v ]{1,23}$`) + reUserMask = regexp.MustCompile(`^[^!@]+![^!@]+@[^@!]+$`) + reFingerprint = regexp.MustCompile(`^[a-fA-F0-9]{64}$`) +) + +func ircValidateHostname(hostname string) error { + if hostname == "" { + return errors.New("the value is empty") + } + if !reHostname.MatchString(hostname) { + return errors.New("invalid format") + } + if len(hostname) > ircMaxHostname { + return errors.New("the value is too long") + } + return nil +} + +func ircIsValidNickname(nickname string) bool { + return len(nickname) <= ircMaxNickname && reNickname.MatchString(nickname) +} + +func ircIsValidUsername(username string) bool { + // XXX: We really should put a limit on this + // despite the RFC not mentioning one. + return reUsername.MatchString(username) +} + +func ircIsValidChannelName(name string) bool { + return len(name) <= ircMaxChannelName && reChannelName.MatchString(name) +} + +func ircIsValidKey(key string) bool { + // XXX: Should be 7-bit as well but whatever. + return reKey.MatchString(key) +} + +func ircIsValidUserMask(mask string) bool { + return reUserMask.MatchString(mask) +} + +func ircIsValidFingerprint(fp string) bool { + return reFingerprint.MatchString(fp) +} + +// --- Clients (equals users) -------------------------------------------------- + +type connCloseWriter interface { + net.Conn + CloseWrite() error +} + +const ircSupportedUserModes = "aiwros" + +const ( + ircUserModeInvisible uint = 1 << iota + ircUserModeRxWallops + ircUserModeRestricted + ircUserModeOperator + ircUserModeRxServerNotices +) + +const ( + ircCapMultiPrefix uint = 1 << iota + ircCapInviteNotify + ircCapEchoMessage + ircCapUserhostInNames + ircCapServerTime +) + +type client struct { + transport net.Conn // underlying connection + tls *tls.Conn // TLS, if detected + conn connCloseWriter // high-level connection + recvQ []byte // unprocessed input + sendQ []byte // unprocessed output + reading bool // whether a reading goroutine is running + writing bool // whether a writing goroutine is running + closing bool // whether we're closing the connection + killTimer *time.Timer // hard kill timeout + + opened time.Time // when the connection was opened + nSentMessages uint // number of sent messages total + sentBytes int // number of sent bytes total + nReceivedMessages uint // number of received messages total + receivedBytes int // number of received bytes total + + hostname string // hostname or IP shown to the network + port string // port of the peer as a string + address string // full network address + + pingTimer *time.Timer // we should send a PING + timeoutTimer *time.Timer // connection seems to be dead + registered bool // the user has registered + capNegotiating bool // negotiating capabilities + capsEnabled uint // enabled capabilities + capVersion uint // CAP protocol version + + tlsCertFingerprint string // client certificate fingerprint + + nickname string // IRC nickname (main identifier) + username string // IRC username + realname string // IRC realname (or e-mail) + + mode uint // user's mode + awayMessage string // away message + lastActive time.Time // last PRIVMSG, to get idle time + invites map[string]bool // channel invitations by operators + antiflood *floodDetector // flood detector +} + +// --- Channels ---------------------------------------------------------------- + +const ircSupportedChanModes = "ov" + "beI" + "imnqpst" + "kl" + +const ( + ircChanModeInviteOnly uint = 1 << iota + ircChanModeModerated + ircChanModeNoOutsideMsgs + ircChanModeQuiet + ircChanModePrivate + ircChanModeSecret + ircChanModeProtectedTopic + + ircChanModeOperator + ircChanModeVoice +) + +type channel struct { + name string // channel name + modes uint // channel modes + key string // channel key + userLimit int // user limit or -1 + created time.Time // creation time + + topic string // channel topic + topicWho string // who set the topic + topicTime time.Time // when the topic was set + + userModes map[*client]uint // modes for all channel users + + banList []string // ban list + exceptionList []string // exceptions from bans + inviteList []string // exceptions from +I +} + +func (ch *channel) getMode(discloseSecrets bool) string { + var buf []byte + if 0 != ch.modes&ircChanModeInviteOnly { + buf = append(buf, 'i') + } + if 0 != ch.modes&ircChanModeModerated { + buf = append(buf, 'm') + } + if 0 != ch.modes&ircChanModeNoOutsideMsgs { + buf = append(buf, 'n') + } + if 0 != ch.modes&ircChanModeQuiet { + buf = append(buf, 'q') + } + if 0 != ch.modes&ircChanModePrivate { + buf = append(buf, 'p') + } + if 0 != ch.modes&ircChanModeSecret { + buf = append(buf, 's') + } + if 0 != ch.modes&ircChanModeProtectedTopic { + buf = append(buf, 'r') + } + + if ch.userLimit != -1 { + buf = append(buf, 'l') + } + if ch.key != "" { + buf = append(buf, 'k') + } + + // XXX: Is it correct to split it? Try it on an existing implementation. + if discloseSecrets { + if ch.userLimit != -1 { + buf = append(buf, fmt.Sprintf(" %d", ch.userLimit)...) + } + if ch.key != "" { + buf = append(buf, fmt.Sprintf(" %s", ch.key)...) + } + } + return string(buf) +} + +// --- IRC server context ------------------------------------------------------ + +type whowasInfo struct { + nickname, username, realname, hostname string +} + +func newWhowasInfo(c *client) *whowasInfo { + return &whowasInfo{ + nickname: c.nickname, + username: c.username, + realname: c.realname, + hostname: c.hostname, + } +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +type ircCommand struct { + requiresRegistration bool + handler func(*message, *client) + + nReceived uint // number of commands received + bytesReceived int // number of bytes received total +} + +type preparedEvent struct { + client *client + host string // client's hostname or literal IP address + isTLS bool // the client seems to use TLS +} + +type readEvent struct { + client *client + data []byte // new data from the client + err error // read error +} + +type writeEvent struct { + client *client + written int // amount of bytes written + err error // write error +} + +// TODO: Maybe we want to keep it in a struct? +// A better question might be: can we run multiple instances of it? +var ( + // network + + listeners []net.Listener + clients = make(map[*client]bool) + + // IRC state + + // XXX: Beware that maps with identifier keys need to be indexed correctly. + // We might want to enforce accessor functions for users and channels. + + started time.Time // when the server has been started + users = make(map[string]*client) // maps nicknames to clients + channels = make(map[string]*channel) // maps channel names to data + whowas = make(map[string]*whowasInfo) // WHOWAS registry + + // event loop + + quitting bool + quitTimer <-chan time.Time + + sigs = make(chan os.Signal, 1) + conns = make(chan net.Conn) + prepared = make(chan preparedEvent) + reads = make(chan readEvent) + writes = make(chan writeEvent) + timers = make(chan func()) + + // configuration + + config simpleConfig // server configuration + tlsConf *tls.Config // TLS connection configuration + serverName string // our server name + pingInterval time.Duration // ping interval + maxConnections int // max connections allowed or 0 + motd []string // MOTD (none if empty) + catalog map[int]string // message catalog for server msgs + operators map[string]bool // TLS certificate fingerprints for IRCops +) + +// Forcefully tear down all connections. +func forceQuit(reason string) { + if !quitting { + exitFatal("forceQuit called without initiateQuit") + } + + printStatus("forced shutdown (%s)", reason) + for c := range clients { + // initiateQuit has already unregistered the client. + c.kill("Shutting down") + } +} + +// Initiate a clean shutdown of the whole daemon. +func initiateQuit() { + printStatus("shutting down") + for _, ln := range listeners { + if err := ln.Close(); err != nil { + printError("%s", err) + } + } + for c := range clients { + c.closeLink("Shutting down") + } + + quitTimer = time.After(5 * time.Second) + quitting = true +} + +func ircChannelCreate(name string) *channel { + ch := &channel{ + name: name, + userLimit: -1, + created: time.Now(), + userModes: make(map[*client]uint), + } + channels[ircToCanon(name)] = ch + return ch +} + +func ircChannelDestroyIfEmpty(ch *channel) { + if len(ch.userModes) == 0 { + delete(channels, ircToCanon(ch.name)) + } +} + +func ircNotifyRoommates(c *client, message string) { + targets := make(map[*client]bool) + for _, ch := range channels { + _, present := ch.userModes[c] + if !present || 0 != ch.modes&ircChanModeQuiet { + continue + } + for client := range ch.userModes { + targets[client] = true + } + } + + for roommate := range targets { + if roommate != c { + roommate.send(message) + } + } +} + +// --- Clients (continued) ----------------------------------------------------- + +func (c *client) printDebug(format string, args ...interface{}) { + if debugMode { + printDebug("(%s) %s", c.address, fmt.Sprintf(format, args...)) + } +} + +func ircAppendClientModes(m uint, mode []byte) []byte { + if 0 != m&ircUserModeInvisible { + mode = append(mode, 'i') + } + if 0 != m&ircUserModeRxWallops { + mode = append(mode, 'w') + } + if 0 != m&ircUserModeRestricted { + mode = append(mode, 'r') + } + if 0 != m&ircUserModeOperator { + mode = append(mode, 'o') + } + if 0 != m&ircUserModeRxServerNotices { + mode = append(mode, 's') + } + return mode +} + +func (c *client) getMode() string { + var mode []byte + if c.awayMessage != "" { + mode = append(mode, 'a') + } + return string(ircAppendClientModes(c.mode, mode)) +} + +func (c *client) send(line string) { + if c.conn == nil || c.closing { + return + } + + oldSendQLen := len(c.sendQ) + + // So far there's only one message tag we use, so we can do it simple; + // note that a 1024-character limit applies to messages with tags on. + if 0 != c.capsEnabled&ircCapServerTime { + c.sendQ = time.Now().UTC(). + AppendFormat(c.sendQ, "@time=2006-01-02T15:04:05.000Z ") + } + + bytes := []byte(line) + if len(bytes) > ircMaxMessageLength { + bytes = bytes[:ircMaxMessageLength] + } + + c.printDebug("<- %s", bytes) + + // TODO: Kill the connection above some "SendQ" threshold (careful!) + c.sendQ = append(c.sendQ, bytes...) + c.sendQ = append(c.sendQ, "\r\n"...) + c.flushSendQ() + + // Technically we haven't sent it yet but that's a minor detail + c.nSentMessages++ + c.sentBytes += len(c.sendQ) - oldSendQLen +} + +func (c *client) sendf(format string, a ...interface{}) { + c.send(fmt.Sprintf(format, a...)) +} + +func (c *client) addToWhowas() { + // Only keeping one entry for each nickname. + // TODO: Make sure this list doesn't get too long, for example by + // putting them in a linked list ordered by time. + whowas[ircToCanon(c.nickname)] = newWhowasInfo(c) +} + +func (c *client) nicknameOrStar() string { + if c.nickname == "" { + return "*" + } + return c.nickname +} + +func (c *client) unregister(reason string) { + if !c.registered { + return + } + + ircNotifyRoommates(c, fmt.Sprintf(":%s!%s@%s QUIT :%s", + c.nickname, c.username, c.hostname, reason)) + + // The QUIT message will take care of state on clients. + for _, ch := range channels { + delete(ch.userModes, c) + ircChannelDestroyIfEmpty(ch) + } + + c.addToWhowas() + delete(users, ircToCanon(c.nickname)) + c.nickname = "" + c.registered = false +} + +// Close the connection and forget about the client. +func (c *client) kill(reason string) { + if reason == "" { + c.unregister("Client exited") + } else { + c.unregister(reason) + } + + c.printDebug("client destroyed (%s)", reason) + + // Try to send a "close notify" alert if the TLS object is ready, + // otherwise just tear down the transport. + if c.conn != nil { + _ = c.conn.Close() + } else { + _ = c.transport.Close() + } + + c.cancelTimers() + delete(clients, c) +} + +// Tear down the client connection, trying to do so in a graceful manner. +func (c *client) closeLink(reason string) { + // Let's just cut the connection, the client can try again later. + // We also want to avoid accidentally writing to the socket before + // address resolution has finished. + if c.conn == nil { + c.kill(reason) + return + } + if c.closing { + return + } + + // We push an "ERROR" message to the write buffer and let the writer send + // it, with some arbitrary timeout. The "closing" state makes sure + // that a/ we ignore any successive messages, and b/ that the connection + // is killed after the write buffer is transferred and emptied. + // (Since we send this message, we don't need to call CloseWrite here.) + c.sendf("ERROR :Closing link: %s[%s] (%s)", + c.nicknameOrStar(), c.hostname /* TODO host IP? */, reason) + c.closing = true + + c.unregister(reason) + c.setKillTimer() +} + +func (c *client) inMaskList(masks []string) bool { + client := fmt.Sprintf("%s!%s@%s", c.nickname, c.username, c.hostname) + for _, mask := range masks { + if ircFnmatch(mask, client) { + return true + } + } + return false +} + +func (c *client) getTLSCertFingerprint() string { + if c.tls == nil { + return "" + } + + peerCerts := c.tls.ConnectionState().PeerCertificates + if len(peerCerts) == 0 { + return "" + } + + hash := sha256.Sum256(peerCerts[0].Raw) + return hex.EncodeToString(hash[:]) +} + +// --- Timers ------------------------------------------------------------------ + +// Free the resources of timers that haven't fired yet and for timers that are +// in between firing and being collected by the event loop, mark that the event +// should not be acted upon. +func (c *client) cancelTimers() { + for _, timer := range []**time.Timer{ + &c.killTimer, &c.timeoutTimer, &c.pingTimer, + } { + if *timer != nil { + (*timer).Stop() + *timer = nil + } + } +} + +// Arrange for a function to be called later from the main goroutine. +func (c *client) setTimer(timer **time.Timer, delay time.Duration, cb func()) { + c.cancelTimers() + + var identityCapture *time.Timer + identityCapture = time.AfterFunc(delay, func() { + timers <- func() { + // The timer might have been cancelled or even replaced. + // When the client is killed, this will be nil. + if *timer == identityCapture { + cb() + } + } + }) + + *timer = identityCapture +} + +func (c *client) setKillTimer() { + c.setTimer(&c.killTimer, pingInterval, func() { + c.kill("Timeout") + }) +} + +func (c *client) setTimeoutTimer() { + c.setTimer(&c.timeoutTimer, pingInterval, func() { + c.closeLink(fmt.Sprintf("Ping timeout: >%d seconds", + pingInterval/time.Second)) + }) +} + +func (c *client) setPingTimer() { + c.setTimer(&c.pingTimer, pingInterval, func() { + c.sendf("PING :%s", serverName) + c.setTimeoutTimer() + }) +} + +// --- IRC command handling ---------------------------------------------------- + +func (c *client) makeReply(id int, ap ...interface{}) string { + s := fmt.Sprintf(":%s %03d %s ", serverName, id, c.nicknameOrStar()) + if reply, ok := catalog[id]; ok { + return s + fmt.Sprintf(reply, ap...) + } + return s + fmt.Sprintf(defaultReplies[id], ap...) +} + +// XXX: This way simple static analysis cannot typecheck the arguments, so we +// need to be careful. +func (c *client) sendReply(id int, args ...interface{}) { + c.send(c.makeReply(id, args...)) +} + +// Send a space-separated list of words across as many replies as needed. +func (c *client) sendReplyVector(id int, items []string, args ...interface{}) { + common := c.makeReply(id, args...) + + // We always send at least one message (there might be a client that + // expects us to send this message at least once). + if len(items) == 0 { + items = append(items, "") + } + + for len(items) > 0 { + // If not even a single item fits in the limit (which may happen, + // in theory) it just gets cropped. We could also skip it. + reply := append([]byte(common), items[0]...) + items = items[1:] + + // Append as many items as fits in a single message. + for len(items) > 0 && + len(reply)+1+len(items[0]) <= ircMaxMessageLength { + reply = append(reply, ' ') + reply = append(reply, items[0]...) + items = items[1:] + } + + c.send(string(reply)) + } +} + +func (c *client) sendMOTD() { + if len(motd) == 0 { + c.sendReply(ERR_NOMOTD) + return + } + + c.sendReply(RPL_MOTDSTART, serverName) + for _, line := range motd { + c.sendReply(RPL_MOTD, line) + } + c.sendReply(RPL_ENDOFMOTD) +} + +func (c *client) sendLUSERS() { + nUsers, nServices, nOpers, nUnknown := 0, 0, 0, 0 + for c := range clients { + if c.registered { + nUsers++ + } else { + nUnknown++ + } + if 0 != c.mode&ircUserModeOperator { + nOpers++ + } + } + + nChannels := 0 + for _, ch := range channels { + if 0 != ch.modes&ircChanModeSecret { + nChannels++ + } + } + + c.sendReply(RPL_LUSERCLIENT, nUsers, nServices, 1 /* servers total */) + if nOpers != 0 { + c.sendReply(RPL_LUSEROP, nOpers) + } + if nUnknown != 0 { + c.sendReply(RPL_LUSERUNKNOWN, nUnknown) + } + if nChannels != 0 { + c.sendReply(RPL_LUSERCHANNELS, nChannels) + } + c.sendReply(RPL_LUSERME, nUsers+nServices+nUnknown, 0 /* peer servers */) +} + +func ircIsThisMe(target string) bool { + // Target servers can also be matched by their users + if ircFnmatch(target, serverName) { + return true + } + _, ok := users[ircToCanon(target)] + return ok +} + +func (c *client) sendISUPPORT() { + // Only # channels, +e supported, +I supported, unlimited arguments to MODE. + c.sendReply(RPL_ISUPPORT, fmt.Sprintf("CHANTYPES=# EXCEPTS INVEX MODES"+ + " TARGMAX=WHOIS:,LIST:,NAMES:,PRIVMSG:1,NOTICE:1,KICK:"+ + " NICKLEN=%d CHANNELLEN=%d", ircMaxNickname, ircMaxChannelName)) +} + +func (c *client) tryFinishRegistration() { + if c.registered || c.capNegotiating { + return + } + if c.nickname == "" || c.username == "" { + return + } + + c.registered = true + c.sendReply(RPL_WELCOME, c.nickname, c.username, c.hostname) + + c.sendReply(RPL_YOURHOST, serverName, projectVersion) + // The purpose of this message eludes me. + c.sendReply(RPL_CREATED, started.Format("Mon, 02 Jan 2006")) + c.sendReply(RPL_MYINFO, serverName, projectVersion, + ircSupportedUserModes, ircSupportedChanModes) + + c.sendISUPPORT() + c.sendLUSERS() + c.sendMOTD() + + if mode := c.getMode(); mode != "" { + c.sendf(":%s MODE %s :+%s", c.nickname, c.nickname, mode) + } + + c.tlsCertFingerprint = c.getTLSCertFingerprint() + if c.tlsCertFingerprint != "" { + c.sendf(":%s NOTICE %s :Your TLS client certificate fingerprint is %s", + serverName, c.nickname, c.tlsCertFingerprint) + } + + delete(whowas, ircToCanon(c.nickname)) +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// IRCv3 capability negotiation. See http://ircv3.org for details. + +type ircCapArgs struct { + subcommand string // the subcommand being processed + fullParams string // whole parameter string + params []string // split parameters + target string // target parameter for replies +} + +var ircCapTable = []struct { + flag uint // flag + name string // name of the capability +}{ + {ircCapMultiPrefix, "multi-prefix"}, + {ircCapInviteNotify, "invite-notify"}, + {ircCapEchoMessage, "echo-message"}, + {ircCapUserhostInNames, "userhost-in-names"}, + {ircCapServerTime, "server-time"}, +} + +func (c *client) handleCAPLS(a *ircCapArgs) { + if len(a.params) == 1 { + if ver, err := strconv.ParseUint(a.params[0], 10, 32); err != nil { + c.sendReply(ERR_INVALIDCAPCMD, a.subcommand, + "Ignoring invalid protocol version number") + } else { + c.capVersion = uint(ver) + } + } + + c.capNegotiating = true + c.sendf(":%s CAP %s LS :multi-prefix invite-notify echo-message"+ + " userhost-in-names server-time", serverName, a.target) +} + +func (c *client) handleCAPLIST(a *ircCapArgs) { + caps := []string{} + for _, cap := range ircCapTable { + if 0 != c.capsEnabled&cap.flag { + caps = append(caps, cap.name) + } + } + + c.sendf(":%s CAP %s LIST :%s", serverName, a.target, + strings.Join(caps, " ")) +} + +func ircDecodeCapability(name string) uint { + for _, cap := range ircCapTable { + if cap.name == name { + return cap.flag + } + } + return 0 +} + +func (c *client) handleCAPREQ(a *ircCapArgs) { + c.capNegotiating = true + + newCaps := c.capsEnabled + ok := true + for _, param := range a.params { + removing := false + name := param + if name[:1] == "-" { + removing = true + name = name[1:] + } + + if cap := ircDecodeCapability(name); cap == 0 { + ok = false + } else if removing { + newCaps &= ^cap + } else { + newCaps |= cap + } + } + + if ok { + c.capsEnabled = newCaps + c.sendf(":%s CAP %s ACK :%s", serverName, a.target, a.fullParams) + } else { + c.sendf(":%s CAP %s NAK :%s", serverName, a.target, a.fullParams) + } +} + +func (c *client) handleCAPACK(a *ircCapArgs) { + if len(a.params) > 0 { + c.sendReply(ERR_INVALIDCAPCMD, a.subcommand, + "No acknowledgable capabilities supported") + } +} + +func (c *client) handleCAPEND(a *ircCapArgs) { + c.capNegotiating = false + c.tryFinishRegistration() +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +var ircCapHandlers = map[string]func(*client, *ircCapArgs){ + "LS": (*client).handleCAPLS, + "LIST": (*client).handleCAPLIST, + "REQ": (*client).handleCAPREQ, + "ACK": (*client).handleCAPACK, + "END": (*client).handleCAPEND, +} + +// XXX: Maybe these also deserve to be methods for client? They operate on +// global state, though. + +func ircParseWEBIRCOptions(options string, out map[string]string) { + for _, option := range strings.Split(options, " ") { + if equal := strings.IndexByte(option, '='); equal < 0 { + out[option] = "" + } else { + out[option[:equal]] = ircUnescapeMessageTag(option[equal+1:]) + } + } +} + +func ircHandleWEBIRC(msg *message, c *client) { + if len(msg.params) < 4 { + c.sendReply(ERR_NEEDMOREPARAMS, msg.command) + return + } + + password, gateway, hostname := msg.params[0], msg.params[1], msg.params[2] + if config["webirc_password"] != password { + c.closeLink("Invalid WebIRC password") + return + } + + options := make(map[string]string) + if len(msg.params) >= 5 { + ircParseWEBIRCOptions(msg.params[4], options) + } + + c.hostname = hostname + c.port = "WebIRC-" + gateway + c.address = net.JoinHostPort(hostname, c.port) + + // Note that this overrides the gateway's certificate, conditionally. + fp, _ := options["certfp-sha-256"] + if _, secure := options["secure"]; secure && ircIsValidFingerprint(fp) { + c.tlsCertFingerprint = strings.ToLower(fp) + } +} + +func ircHandleCAP(msg *message, c *client) { + if len(msg.params) < 1 { + c.sendReply(ERR_NEEDMOREPARAMS, msg.command) + return + } + + args := &ircCapArgs{ + target: c.nicknameOrStar(), + subcommand: msg.params[0], + fullParams: "", + params: []string{}, + } + + if len(msg.params) > 1 { + args.fullParams = msg.params[1] + args.params = splitString(args.fullParams, " ", true) + } + + if fn, ok := ircCapHandlers[ircToCanon(args.subcommand)]; !ok { + c.sendReply(ERR_INVALIDCAPCMD, args.subcommand, + "Invalid CAP subcommand") + } else { + fn(c, args) + } +} + +func ircHandlePASS(msg *message, c *client) { + if c.registered { + c.sendReply(ERR_ALREADYREGISTERED) + } else if len(msg.params) < 1 { + c.sendReply(ERR_NEEDMOREPARAMS, msg.command) + } + + // We have TLS client certificates for this purpose; ignoring. +} + +func ircHandleNICK(msg *message, c *client) { + if len(msg.params) < 1 { + c.sendReply(ERR_NONICKNAMEGIVEN) + return + } + + nickname := msg.params[0] + if !ircIsValidNickname(nickname) { + c.sendReply(ERR_ERRONEOUSNICKNAME, nickname) + return + } + + nicknameCanon := ircToCanon(nickname) + if client, ok := users[nicknameCanon]; ok && client != c { + c.sendReply(ERR_NICKNAMEINUSE, nickname) + return + } + + if c.registered { + c.addToWhowas() + + message := fmt.Sprintf(":%s!%s@%s NICK :%s", + c.nickname, c.username, c.hostname, nickname) + ircNotifyRoommates(c, message) + c.send(message) + } + + // Release the old nickname and allocate a new one. + if c.nickname != "" { + delete(users, ircToCanon(c.nickname)) + } + + c.nickname = nickname + users[nicknameCanon] = c + + c.tryFinishRegistration() +} + +func ircHandleUSER(msg *message, c *client) { + if c.registered { + c.sendReply(ERR_ALREADYREGISTERED) + return + } + if len(msg.params) < 4 { + c.sendReply(ERR_NEEDMOREPARAMS, msg.command) + return + } + + username, mode, realname := msg.params[0], msg.params[1], msg.params[3] + + // Unfortunately, the protocol doesn't give us any means of rejecting it. + if !ircIsValidUsername(username) { + username = "*" + } + + c.username = username + c.realname = realname + + c.mode = 0 + if m, err := strconv.ParseUint(mode, 10, 32); err != nil { + if 0 != m&4 { + c.mode |= ircUserModeRxWallops + } + if 0 != m&8 { + c.mode |= ircUserModeInvisible + } + } + + c.tryFinishRegistration() +} + +func ircHandleUSERHOST(msg *message, c *client) { + if len(msg.params) < 1 { + c.sendReply(ERR_NEEDMOREPARAMS, msg.command) + return + } + + var reply []byte + for i := 0; i < 5 && i < len(msg.params); i++ { + nick := msg.params[i] + target := users[ircToCanon(nick)] + if target == nil { + continue + } + if i != 0 { + reply = append(reply, ' ') + } + + reply = append(reply, nick...) + if 0 != target.mode&ircUserModeOperator { + reply = append(reply, '*') + } + + if target.awayMessage != "" { + reply = append(reply, "=-"...) + } else { + reply = append(reply, "=+"...) + } + reply = append(reply, (target.username + "@" + target.hostname)...) + } + c.sendReply(RPL_USERHOST, string(reply)) +} + +func ircHandleLUSERS(msg *message, c *client) { + if len(msg.params) > 1 && !ircIsThisMe(msg.params[1]) { + c.sendReply(ERR_NOSUCHSERVER, msg.params[1]) + return + } + c.sendLUSERS() +} + +func ircHandleMOTD(msg *message, c *client) { + if len(msg.params) > 0 && !ircIsThisMe(msg.params[0]) { + c.sendReply(ERR_NOSUCHSERVER, msg.params[1]) + return + } + c.sendMOTD() +} + +func ircHandlePING(msg *message, c *client) { + // XXX: The RFC is pretty incomprehensible about the exact usage. + if len(msg.params) > 1 && !ircIsThisMe(msg.params[1]) { + c.sendReply(ERR_NOSUCHSERVER, msg.params[1]) + } else if len(msg.params) < 1 { + c.sendReply(ERR_NOORIGIN) + } else { + c.sendf(":%s PONG :%s", serverName, msg.params[0]) + } +} + +func ircHandlePONG(msg *message, c *client) { + // We are the only server, so we don't have to care too much. + if len(msg.params) < 1 { + c.sendReply(ERR_NOORIGIN) + return + } + + // Set a new timer to send another PING + c.setPingTimer() +} + +func ircHandleQUIT(msg *message, c *client) { + reason := c.nickname + if len(msg.params) > 0 { + reason = msg.params[0] + } + + c.closeLink("Quit: " + reason) +} + +func ircHandleTIME(msg *message, c *client) { + if len(msg.params) > 0 && !ircIsThisMe(msg.params[0]) { + c.sendReply(ERR_NOSUCHSERVER, msg.params[0]) + return + } + + c.sendReply(RPL_TIME, serverName, + time.Now().Format("Mon Jan _2 2006 15:04:05")) +} + +func ircHandleVERSION(msg *message, c *client) { + if len(msg.params) > 0 && !ircIsThisMe(msg.params[0]) { + c.sendReply(ERR_NOSUCHSERVER, msg.params[0]) + return + } + + postVersion := 0 + if debugMode { + postVersion = 1 + } + + c.sendReply(RPL_VERSION, projectVersion, postVersion, serverName, + projectName+" "+projectVersion) + c.sendISUPPORT() +} + +func ircChannelMulticast(ch *channel, msg string, except *client) { + for c := range ch.userModes { + if c != except { + c.send(msg) + } + } +} + +func ircModifyMode(mask *uint, mode uint, add bool) bool { + orig := *mask + if add { + *mask |= mode + } else { + *mask &= ^mode + } + return *mask != orig +} + +func ircUpdateUserMode(c *client, newMode uint) { + oldMode := c.mode + c.mode = newMode + + added, removed := newMode & ^oldMode, oldMode & ^newMode + + var diff []byte + if added != 0 { + diff = append(diff, '+') + diff = ircAppendClientModes(added, diff) + } + if removed != 0 { + diff = append(diff, '-') + diff = ircAppendClientModes(removed, diff) + } + + if len(diff) > 0 { + c.sendf(":%s MODE %s :%s", c.nickname, c.nickname, string(diff)) + } +} + +func ircHandleUserModeChange(c *client, modeString string) { + newMode := c.mode + adding := true + + for _, flag := range modeString { + switch flag { + case '+': + adding = true + case '-': + adding = false + + case 'a': + // Ignore, the client should use AWAY. + case 'i': + ircModifyMode(&newMode, ircUserModeInvisible, adding) + case 'w': + ircModifyMode(&newMode, ircUserModeRxWallops, adding) + case 'r': + // It's not possible ot un-restrict yourself. + if adding { + newMode |= ircUserModeRestricted + } + case 'o': + if !adding { + newMode &= ^ircUserModeOperator + } else if operators[c.tlsCertFingerprint] { + newMode |= ircUserModeOperator + } else { + c.sendf(":%s NOTICE %s :Either you're not using an TLS"+ + " client certificate, or the fingerprint doesn't match", + serverName, c.nickname) + } + case 's': + ircModifyMode(&newMode, ircUserModeRxServerNotices, adding) + default: + c.sendReply(ERR_UMODEUNKNOWNFLAG) + return + } + } + ircUpdateUserMode(c, newMode) +} + +func ircSendChannelList(c *client, channelName string, list []string, + reply, endReply int) { + for _, line := range list { + c.sendReply(reply, channelName, line) + } + c.sendReply(endReply, channelName) +} + +func ircCheckExpandUserMask(mask string) string { + var result []byte + result = append(result, mask...) + + // Make sure it is a complete mask. + if bytes.IndexByte(result, '!') < 0 { + result = append(result, "!*"...) + } + if bytes.IndexByte(result, '@') < 0 { + result = append(result, "@*"...) + } + + // And validate whatever the result is. + s := string(result) + if !ircIsValidUserMask(s) { + return "" + } + + return s +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// Channel MODE command handling. This is by far the worst command to implement +// from the whole RFC; don't blame me if it doesn't work exactly as expected. + +type modeProcessor struct { + params []string // mode string parameters + + c *client // who does the changes + ch *channel // the channel we're modifying + present bool // c present on ch + modes uint // channel user modes + + adding bool // currently adding modes + modeChar byte // currently processed mode char + + added []byte // added modes + removed []byte // removed modes + output *[]byte // "added" or "removed" + + addedParams []string // params for added modes + removedParams []string // params for removed modes + outputParams *[]string // "addedParams" or "removedParams" +} + +func (mp *modeProcessor) nextParam() string { + if len(mp.params) == 0 { + return "" + } + + param := mp.params[0] + mp.params = mp.params[1:] + return param +} + +func (mp *modeProcessor) checkOperator() bool { + if (mp.present && 0 != mp.modes&ircChanModeOperator) || + 0 != mp.c.mode&ircUserModeOperator { + return true + } + + mp.c.sendReply(ERR_CHANOPRIVSNEEDED, mp.ch.name) + return false +} + +func (mp *modeProcessor) doUser(mode uint) { + target := mp.nextParam() + if !mp.checkOperator() || target == "" { + return + } + + if client := users[ircToCanon(target)]; client == nil { + mp.c.sendReply(ERR_NOSUCHNICK, target) + } else if modes, present := mp.ch.userModes[client]; !present { + mp.c.sendReply(ERR_USERNOTINCHANNEL, target, mp.ch.name) + } else if ircModifyMode(&modes, mode, mp.adding) { + mp.ch.userModes[client] = modes + *mp.output = append(*mp.output, mp.modeChar) + *mp.outputParams = append(*mp.outputParams, client.nickname) + } +} + +func (mp *modeProcessor) doChan(mode uint) bool { + if !mp.checkOperator() || !ircModifyMode(&mp.ch.modes, mode, mp.adding) { + return false + } + *mp.output = append(*mp.output, mp.modeChar) + return true +} + +func (mp *modeProcessor) doChanRemove(modeChar byte, mode uint) { + if mp.adding && ircModifyMode(&mp.ch.modes, mode, false) { + mp.removed = append(mp.removed, modeChar) + } +} + +func (mp *modeProcessor) doList(list *[]string, listMsg, endMsg int) { + target := mp.nextParam() + if target == "" { + if mp.adding { + ircSendChannelList(mp.c, mp.ch.name, *list, listMsg, endMsg) + } + return + } + + if !mp.checkOperator() { + return + } + + mask := ircCheckExpandUserMask(target) + if mask == "" { + return + } + + var i int + for i = 0; i < len(*list); i++ { + if ircEqual((*list)[i], mask) { + break + } + } + + found := i < len(*list) + if found != mp.adding { + if mp.adding { + *list = append(*list, mask) + } else { + *list = append((*list)[:i], (*list)[i+1:]...) + } + + *mp.output = append(*mp.output, mp.modeChar) + *mp.outputParams = append(*mp.outputParams, mask) + } +} + +func (mp *modeProcessor) doKey() { + target := mp.nextParam() + if !mp.checkOperator() || target == "" { + return + } + + if !mp.adding { + if mp.ch.key == "" || !ircEqual(target, mp.ch.key) { + return + } + + mp.removed = append(mp.removed, mp.modeChar) + mp.removedParams = append(mp.removedParams, mp.ch.key) + mp.ch.key = "" + } else if !ircIsValidKey(target) { + // TODO: We should notify the user somehow. + return + } else if mp.ch.key != "" { + mp.c.sendReply(ERR_KEYSET, mp.ch.name) + } else { + mp.ch.key = target + mp.added = append(mp.added, mp.modeChar) + mp.addedParams = append(mp.addedParams, mp.ch.key) + } +} + +func (mp *modeProcessor) doLimit() { + if !mp.checkOperator() { + return + } + + if !mp.adding { + if mp.ch.userLimit == -1 { + return + } + + mp.ch.userLimit = -1 + mp.removed = append(mp.removed, mp.modeChar) + } else if target := mp.nextParam(); target != "" { + if x, err := strconv.ParseInt(target, 10, 32); err == nil && x > 0 { + mp.ch.userLimit = int(x) + mp.added = append(mp.added, mp.modeChar) + mp.addedParams = append(mp.addedParams, target) + } + } +} + +func (mp *modeProcessor) step(modeChar byte) bool { + mp.modeChar = modeChar + switch mp.modeChar { + case '+': + mp.adding = true + mp.output = &mp.added + mp.outputParams = &mp.addedParams + case '-': + mp.adding = false + mp.output = &mp.removed + mp.outputParams = &mp.removedParams + + case 'o': + mp.doUser(ircChanModeOperator) + case 'v': + mp.doUser(ircChanModeVoice) + + case 'i': + mp.doChan(ircChanModeInviteOnly) + case 'm': + mp.doChan(ircChanModeModerated) + case 'n': + mp.doChan(ircChanModeNoOutsideMsgs) + case 'q': + mp.doChan(ircChanModeQuiet) + case 't': + mp.doChan(ircChanModeProtectedTopic) + + case 'p': + if mp.doChan(ircChanModePrivate) { + mp.doChanRemove('s', ircChanModeSecret) + } + case 's': + if mp.doChan(ircChanModeSecret) { + mp.doChanRemove('p', ircChanModePrivate) + } + + case 'b': + mp.doList(&mp.ch.banList, RPL_BANLIST, RPL_ENDOFBANLIST) + case 'e': + mp.doList(&mp.ch.banList, RPL_EXCEPTLIST, RPL_ENDOFEXCEPTLIST) + case 'I': + mp.doList(&mp.ch.banList, RPL_INVITELIST, RPL_ENDOFINVITELIST) + + case 'k': + mp.doKey() + case 'l': + mp.doLimit() + + default: + // It's not safe to continue, results could be undesired. + mp.c.sendReply(ERR_UNKNOWNMODE, modeChar, mp.ch.name) + return false + } + return true +} + +func ircHandleChanModeChange(c *client, ch *channel, params []string) { + modes, present := ch.userModes[c] + mp := &modeProcessor{ + c: c, + ch: ch, + present: present, + modes: modes, + params: params, + } + +Outer: + for { + modeString := mp.nextParam() + if modeString == "" { + break + } + + mp.step('+') + for _, modeChar := range []byte(modeString) { + if !mp.step(modeChar) { + break Outer + } + } + } + + // TODO: Limit to three changes with parameter per command. + if len(mp.added) > 0 || len(mp.removed) > 0 { + buf := []byte(fmt.Sprintf(":%s!%s@%s MODE %s ", + mp.c.nickname, mp.c.username, mp.c.hostname, mp.ch.name)) + if len(mp.added) > 0 { + buf = append(buf, '+') + buf = append(buf, mp.added...) + } + if len(mp.removed) > 0 { + buf = append(buf, '-') + buf = append(buf, mp.removed...) + } + for _, param := range mp.addedParams { + buf = append(buf, ' ') + buf = append(buf, param...) + } + for _, param := range mp.removedParams { + buf = append(buf, ' ') + buf = append(buf, param...) + } + ircChannelMulticast(mp.ch, string(buf), nil) + } +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +func ircHandleMODE(msg *message, c *client) { + if len(msg.params) < 1 { + c.sendReply(ERR_NEEDMOREPARAMS, msg.command) + return + } + + target := msg.params[0] + client := users[ircToCanon(target)] + ch := channels[ircToCanon(target)] + + if client != nil { + if !ircEqual(target, c.nickname) { + c.sendReply(ERR_USERSDONTMATCH) + return + } + + if len(msg.params) < 2 { + c.sendReply(RPL_UMODEIS, c.getMode()) + } else { + ircHandleUserModeChange(c, msg.params[1]) + } + } else if ch != nil { + if len(msg.params) < 2 { + _, present := ch.userModes[c] + c.sendReply(RPL_CHANNELMODEIS, target, ch.getMode(present)) + c.sendReply(RPL_CREATIONTIME, target, ch.created.Unix()) + } else { + ircHandleChanModeChange(c, ch, msg.params[1:]) + } + } else { + c.sendReply(ERR_NOSUCHNICK, target) + } +} + +func ircHandleUserMessage(msg *message, c *client, + command string, allowAwayReply bool) { + if len(msg.params) < 1 { + c.sendReply(ERR_NORECIPIENT, msg.command) + return + } + if len(msg.params) < 2 || msg.params[1] == "" { + c.sendReply(ERR_NOTEXTTOSEND) + return + } + + target, text := msg.params[0], msg.params[1] + message := fmt.Sprintf(":%s!%s@%s %s %s :%s", + c.nickname, c.username, c.hostname, command, target, text) + + if client := users[ircToCanon(target)]; client != nil { + client.send(message) + if allowAwayReply && client.awayMessage != "" { + c.sendReply(RPL_AWAY, target, client.awayMessage) + } + + // Acknowledging a message from the client to itself would be silly. + if client != c && (0 != c.capsEnabled&ircCapEchoMessage) { + c.send(message) + } + } else if ch := channels[ircToCanon(target)]; ch != nil { + modes, present := ch.userModes[c] + + outsider := !present && 0 != ch.modes&ircChanModeNoOutsideMsgs + moderated := 0 != ch.modes&ircChanModeModerated && + 0 == modes&(ircChanModeVoice|ircChanModeOperator) + banned := c.inMaskList(ch.banList) && !c.inMaskList(ch.exceptionList) + + if outsider || moderated || banned { + c.sendReply(ERR_CANNOTSENDTOCHAN, target) + return + } + + except := c + if 0 != c.capsEnabled&ircCapEchoMessage { + except = nil + } + + ircChannelMulticast(ch, message, except) + } else { + c.sendReply(ERR_NOSUCHNICK, target) + } +} + +func ircHandlePRIVMSG(msg *message, c *client) { + ircHandleUserMessage(msg, c, "PRIVMSG", true /* allowAwayReply */) + c.lastActive = time.Now() +} + +func ircHandleNOTICE(msg *message, c *client) { + ircHandleUserMessage(msg, c, "NOTICE", false /* allowAwayReply */) +} + +func ircHandleLIST(msg *message, c *client) { + if len(msg.params) > 1 && !ircIsThisMe(msg.params[1]) { + c.sendReply(ERR_NOSUCHSERVER, msg.params[1]) + return + } + + // XXX: Maybe we should skip ircUserModeInvisible from user counts. + if len(msg.params) == 0 { + for _, ch := range channels { + if _, present := ch.userModes[c]; present || + 0 == ch.modes&(ircChanModePrivate|ircChanModeSecret) { + c.sendReply(RPL_LIST, ch.name, len(ch.userModes), ch.topic) + } + } + } else { + for _, target := range splitString(msg.params[0], ",", true) { + if ch := channels[ircToCanon(target)]; ch != nil && + 0 == ch.modes&ircChanModeSecret { + c.sendReply(RPL_LIST, ch.name, len(ch.userModes), ch.topic) + } + } + } + c.sendReply(RPL_LISTEND) +} + +func ircAppendPrefixes(c *client, modes uint, buf []byte) []byte { + var all []byte + if 0 != modes&ircChanModeOperator { + all = append(all, '@') + } + if 0 != modes&ircChanModeVoice { + all = append(all, '+') + } + + if len(all) > 0 { + if 0 != c.capsEnabled&ircCapMultiPrefix { + buf = append(buf, all...) + } else { + buf = append(buf, all[0]) + } + } + return buf +} + +func ircMakeRPLNAMREPLYItem(c, target *client, modes uint) string { + result := string(ircAppendPrefixes(c, modes, nil)) + target.nickname + if 0 != c.capsEnabled&ircCapUserhostInNames { + result += fmt.Sprintf("!%s@%s", target.username, target.hostname) + } + return result +} + +func ircSendRPLNAMREPLY(c *client, ch *channel, usedNicks map[*client]bool) { + kind := '=' + if 0 != ch.modes&ircChanModeSecret { + kind = '@' + } else if 0 != ch.modes&ircChanModePrivate { + kind = '*' + } + + _, present := ch.userModes[c] + + var nicks []string + for client, modes := range ch.userModes { + if !present && 0 != client.mode&ircUserModeInvisible { + continue + } + if usedNicks != nil { + usedNicks[client] = true + } + nicks = append(nicks, ircMakeRPLNAMREPLYItem(c, client, modes)) + } + c.sendReplyVector(RPL_NAMREPLY, nicks, kind, ch.name, "") +} + +func ircSendDisassociatedNames(c *client, usedNicks map[*client]bool) { + var nicks []string + for _, client := range users { + if 0 == client.mode&ircUserModeInvisible && !usedNicks[client] { + nicks = append(nicks, ircMakeRPLNAMREPLYItem(c, client, 0)) + } + } + if len(nicks) > 0 { + c.sendReplyVector(RPL_NAMREPLY, nicks, '*', "*", "") + } +} + +func ircHandleNAMES(msg *message, c *client) { + if len(msg.params) > 1 && !ircIsThisMe(msg.params[1]) { + c.sendReply(ERR_NOSUCHSERVER, msg.params[1]) + return + } + + if len(msg.params) == 0 { + usedNicks := make(map[*client]bool) + for _, ch := range channels { + if _, present := ch.userModes[c]; present || + 0 == ch.modes&(ircChanModePrivate|ircChanModeSecret) { + ircSendRPLNAMREPLY(c, ch, usedNicks) + } + } + + // Also send all visible users we haven't listed yet. + ircSendDisassociatedNames(c, usedNicks) + c.sendReply(RPL_ENDOFNAMES, "*") + } else { + for _, target := range splitString(msg.params[0], ",", true) { + if ch := channels[ircToCanon(target)]; ch == nil { + } else if _, present := ch.userModes[c]; present || + 0 == ch.modes&ircChanModeSecret { + ircSendRPLNAMREPLY(c, ch, nil) + c.sendReply(RPL_ENDOFNAMES, target) + } + } + } +} + +func ircSendRPLWHOREPLY(c *client, ch *channel, target *client) { + var chars []byte + if target.awayMessage != "" { + chars = append(chars, 'G') + } else { + chars = append(chars, 'H') + } + + if 0 != target.mode&ircUserModeOperator { + chars = append(chars, '*') + } + + channelName := "*" + if ch != nil { + channelName = ch.name + if modes, present := ch.userModes[target]; present { + chars = ircAppendPrefixes(c, modes, chars) + } + } + + c.sendReply(RPL_WHOREPLY, channelName, + target.username, target.hostname, serverName, + target.nickname, string(chars), 0 /* hop count */, target.realname) +} + +func ircMatchSendRPLWHOREPLY(c, target *client, mask string) { + isRoommate := false + for _, ch := range channels { + _, presentClient := ch.userModes[c] + _, presentTarget := ch.userModes[target] + if presentClient && presentTarget { + isRoommate = true + break + } + } + if !isRoommate && 0 != target.mode&ircUserModeInvisible { + return + } + + if !ircFnmatch(mask, target.hostname) && + !ircFnmatch(mask, target.nickname) && + !ircFnmatch(mask, target.realname) && + !ircFnmatch(mask, serverName) { + return + } + + // Try to find a channel they're on that's visible to us. + var userCh *channel + for _, ch := range channels { + _, presentClient := ch.userModes[c] + _, presentTarget := ch.userModes[target] + if presentTarget && (presentClient || + 0 == ch.modes&(ircChanModePrivate|ircChanModeSecret)) { + userCh = ch + break + } + } + ircSendRPLWHOREPLY(c, userCh, target) +} + +func ircHandleWHO(msg *message, c *client) { + onlyOps := len(msg.params) > 1 && msg.params[1] == "o" + + shownMask, usedMask := "*", "*" + if len(msg.params) > 0 { + shownMask = msg.params[0] + if shownMask != "0" { + usedMask = shownMask + } + } + + if ch := channels[ircToCanon(usedMask)]; ch != nil { + _, present := ch.userModes[c] + if present || 0 == ch.modes&ircChanModeSecret { + for client := range ch.userModes { + if (present || 0 == client.mode&ircUserModeInvisible) && + (!onlyOps || 0 != client.mode&ircUserModeOperator) { + ircSendRPLWHOREPLY(c, ch, client) + } + } + } + } else { + for _, client := range users { + if !onlyOps || 0 != client.mode&ircUserModeOperator { + ircMatchSendRPLWHOREPLY(c, client, usedMask) + } + } + } + c.sendReply(RPL_ENDOFWHO, shownMask) +} + +func ircSendWHOISReply(c, target *client) { + nick := target.nickname + c.sendReply(RPL_WHOISUSER, nick, + target.username, target.hostname, target.realname) + c.sendReply(RPL_WHOISSERVER, nick, serverName, config["server_info"]) + if 0 != target.mode&ircUserModeOperator { + c.sendReply(RPL_WHOISOPERATOR, nick) + } + c.sendReply(RPL_WHOISIDLE, nick, + time.Now().Sub(target.lastActive)/time.Second) + if target.awayMessage != "" { + c.sendReply(RPL_AWAY, nick, target.awayMessage) + } + + var chans []string + for _, ch := range channels { + _, presentClient := ch.userModes[c] + modes, presentTarget := ch.userModes[target] + if presentTarget && (presentClient || + 0 == ch.modes&(ircChanModePrivate|ircChanModeSecret)) { + // TODO: Deduplicate, ircAppendPrefixes just also cuts prefixes. + var all []byte + if 0 != modes&ircChanModeOperator { + all = append(all, '@') + } + if 0 != modes&ircChanModeVoice { + all = append(all, '+') + } + chans = append(chans, string(all)+ch.name) + } + } + c.sendReplyVector(RPL_WHOISCHANNELS, chans, nick, "") + c.sendReply(RPL_ENDOFWHOIS, nick) +} + +func ircHandleWHOIS(msg *message, c *client) { + if len(msg.params) < 1 { + c.sendReply(ERR_NEEDMOREPARAMS, msg.command) + return + } + if len(msg.params) > 1 && !ircIsThisMe(msg.params[0]) { + c.sendReply(ERR_NOSUCHSERVER, msg.params[0]) + return + } + + masksStr := msg.params[0] + if len(msg.params) > 1 { + masksStr = msg.params[1] + } + + for _, mask := range splitString(masksStr, ",", true /* ignoreEmpty */) { + if strings.IndexAny(mask, "*?") < 0 { + if target := users[ircToCanon(mask)]; target == nil { + c.sendReply(ERR_NOSUCHNICK, mask) + } else { + ircSendWHOISReply(c, target) + } + } else { + found := false + for _, target := range users { + if ircFnmatch(mask, target.nickname) { + ircSendWHOISReply(c, target) + found = true + } + } + if !found { + c.sendReply(ERR_NOSUCHNICK, mask) + } + } + } +} + +func ircHandleWHOWAS(msg *message, c *client) { + if len(msg.params) < 1 { + c.sendReply(ERR_NEEDMOREPARAMS, msg.command) + return + } + if len(msg.params) > 2 && !ircIsThisMe(msg.params[2]) { + c.sendReply(ERR_NOSUCHSERVER, msg.params[2]) + return + } + // The "count" parameter is ignored, we only store one entry for a nick. + + for _, nick := range splitString(msg.params[0], ",", true) { + if info := whowas[ircToCanon(nick)]; info == nil { + c.sendReply(ERR_WASNOSUCHNICK, nick) + } else { + c.sendReply(RPL_WHOWASUSER, nick, + info.username, info.hostname, info.realname) + c.sendReply(RPL_WHOISSERVER, nick, + serverName, config["server_info"]) + } + c.sendReply(RPL_ENDOFWHOWAS, nick) + } +} + +func ircSendRPLTOPIC(c *client, ch *channel) { + if ch.topic == "" { + c.sendReply(RPL_NOTOPIC, ch.name) + } else { + c.sendReply(RPL_TOPIC, ch.name, ch.topic) + c.sendReply(RPL_TOPICWHOTIME, + ch.name, ch.topicWho, ch.topicTime.Unix()) + } +} + +func ircHandleTOPIC(msg *message, c *client) { + if len(msg.params) < 1 { + c.sendReply(ERR_NEEDMOREPARAMS, msg.command) + return + } + + channelName := msg.params[0] + ch := channels[ircToCanon(channelName)] + if ch == nil { + c.sendReply(ERR_NOSUCHCHANNEL, channelName) + return + } + + if len(msg.params) < 2 { + ircSendRPLTOPIC(c, ch) + return + } + + modes, present := ch.userModes[c] + if !present { + c.sendReply(ERR_NOTONCHANNEL, channelName) + return + } + + if 0 != ch.modes&ircChanModeProtectedTopic && + 0 == modes&ircChanModeOperator { + c.sendReply(ERR_CHANOPRIVSNEEDED, channelName) + return + } + + ch.topic = msg.params[1] + ch.topicWho = fmt.Sprintf("%s@%s@%s", c.nickname, c.username, c.hostname) + ch.topicTime = time.Now() + + message := fmt.Sprintf(":%s!%s@%s TOPIC %s :%s", + c.nickname, c.username, c.hostname, channelName, ch.topic) + ircChannelMulticast(ch, message, nil) +} + +func ircTryPart(c *client, channelName string, reason string) { + if reason == "" { + reason = c.nickname + } + + ch := channels[ircToCanon(channelName)] + if ch == nil { + c.sendReply(ERR_NOSUCHCHANNEL, channelName) + return + } + + if _, present := ch.userModes[c]; !present { + c.sendReply(ERR_NOTONCHANNEL, channelName) + return + } + + message := fmt.Sprintf(":%s@%s@%s PART %s :%s", + c.nickname, c.username, c.hostname, channelName, reason) + if 0 == ch.modes&ircChanModeQuiet { + ircChannelMulticast(ch, message, nil) + } else { + c.send(message) + } + + delete(ch.userModes, c) + ircChannelDestroyIfEmpty(ch) +} + +func ircPartAllChannels(c *client) { + for _, ch := range channels { + if _, present := ch.userModes[c]; present { + ircTryPart(c, ch.name, "") + } + } +} + +func ircHandlePART(msg *message, c *client) { + if len(msg.params) < 1 { + c.sendReply(ERR_NEEDMOREPARAMS, msg.command) + return + } + + reason := "" + if len(msg.params) > 1 { + reason = msg.params[1] + } + + for _, channelName := range splitString(msg.params[0], ",", true) { + ircTryPart(c, channelName, reason) + } +} + +func ircTryKick(c *client, channelName, nick, reason string) { + ch := channels[ircToCanon(channelName)] + if ch == nil { + c.sendReply(ERR_NOSUCHCHANNEL, channelName) + return + } + + if modes, present := ch.userModes[c]; !present { + c.sendReply(ERR_NOTONCHANNEL, channelName) + return + } else if 0 == modes&ircChanModeOperator { + c.sendReply(ERR_CHANOPRIVSNEEDED, channelName) + return + } + + client := users[ircToCanon(nick)] + if _, present := ch.userModes[client]; client == nil || !present { + c.sendReply(ERR_USERNOTINCHANNEL, nick, channelName) + return + } + + message := fmt.Sprintf(":%s@%s@%s KICK %s %s :%s", + c.nickname, c.username, c.hostname, channelName, nick, reason) + if 0 == ch.modes&ircChanModeQuiet { + ircChannelMulticast(ch, message, nil) + } else { + c.send(message) + } + + delete(ch.userModes, client) + ircChannelDestroyIfEmpty(ch) +} + +func ircHandleKICK(msg *message, c *client) { + if len(msg.params) < 2 { + c.sendReply(ERR_NEEDMOREPARAMS, msg.command) + return + } + + reason := c.nickname + if len(msg.params) > 2 { + reason = msg.params[2] + } + + targetChannels := splitString(msg.params[0], ",", true) + targetUsers := splitString(msg.params[1], ",", true) + + if len(channels) == 1 { + for i := 0; i < len(targetUsers); i++ { + ircTryKick(c, targetChannels[0], targetUsers[i], reason) + } + } else { + for i := 0; i < len(channels) && i < len(targetUsers); i++ { + ircTryKick(c, targetChannels[i], targetUsers[i], reason) + } + } +} + +func ircSendInviteNotifications(ch *channel, c, target *client) { + for client := range ch.userModes { + if client != target && 0 != client.capsEnabled&ircCapInviteNotify { + client.sendf(":%s!%s@%s INVITE %s %s", + c.nickname, c.username, c.hostname, target.nickname, ch.name) + } + } +} + +func ircHandleINVITE(msg *message, c *client) { + if len(msg.params) < 2 { + c.sendReply(ERR_NEEDMOREPARAMS, msg.command) + return + } + + target, channelName := msg.params[0], msg.params[1] + client := users[ircToCanon(target)] + if client == nil { + c.sendReply(ERR_NOSUCHNICK, target) + return + } + + if ch := channels[ircToCanon(channelName)]; ch != nil { + invitingModes, invitingPresent := ch.userModes[c] + if !invitingPresent { + c.sendReply(ERR_NOTONCHANNEL, channelName) + return + } + if _, present := ch.userModes[client]; present { + c.sendReply(ERR_USERONCHANNEL, target, channelName) + return + } + + if 0 != invitingModes&ircChanModeOperator { + client.invites[ircToCanon(channelName)] = true + } else if 0 != ch.modes&ircChanModeInviteOnly { + c.sendReply(ERR_CHANOPRIVSNEEDED, channelName) + return + } + + // It's not specified when and how we should send out invite-notify. + if 0 != ch.modes&ircChanModeInviteOnly { + ircSendInviteNotifications(ch, c, client) + } + } + + client.sendf(":%s!%s@%s INVITE %s %s", + c.nickname, c.username, c.hostname, client.nickname, channelName) + if client.awayMessage != "" { + c.sendReply(RPL_AWAY, client.nickname, client.awayMessage) + } + c.sendReply(RPL_INVITING, client.nickname, channelName) +} + +func ircTryJoin(c *client, channelName, key string) { + ch := channels[ircToCanon(channelName)] + var userMode uint + if ch == nil { + if !ircIsValidChannelName(channelName) { + c.sendReply(ERR_BADCHANMASK, channelName) + return + } + ch = ircChannelCreate(channelName) + userMode = ircChanModeOperator + } else if _, present := ch.userModes[c]; present { + return + } + + _, invitedByChanop := c.invites[ircToCanon(channelName)] + if 0 != ch.modes&ircChanModeInviteOnly && c.inMaskList(ch.inviteList) && + !invitedByChanop { + c.sendReply(ERR_INVITEONLYCHAN, channelName) + return + } + if ch.key != "" && (key == "" || key != ch.key) { + c.sendReply(ERR_BADCHANNELKEY, channelName) + return + } + if ch.userLimit != -1 && len(ch.userModes) >= ch.userLimit { + c.sendReply(ERR_CHANNELISFULL, channelName) + return + } + if c.inMaskList(ch.banList) && !c.inMaskList(ch.exceptionList) && + !invitedByChanop { + c.sendReply(ERR_BANNEDFROMCHAN, channelName) + return + } + + // Destroy any invitation as there's no other way to get rid of it. + delete(c.invites, ircToCanon(channelName)) + + ch.userModes[c] = userMode + + message := fmt.Sprintf(":%s!%s@%s JOIN %s", + c.nickname, c.username, c.hostname, channelName) + if 0 == ch.modes&ircChanModeQuiet { + ircChannelMulticast(ch, message, nil) + } else { + c.send(message) + } + + ircSendRPLTOPIC(c, ch) + ircSendRPLNAMREPLY(c, ch, nil) + c.sendReply(RPL_ENDOFNAMES, ch.name) +} + +func ircHandleJOIN(msg *message, c *client) { + if len(msg.params) < 1 { + c.sendReply(ERR_NEEDMOREPARAMS, msg.command) + return + } + + if msg.params[0] == "0" { + ircPartAllChannels(c) + return + } + + targetChannels := splitString(msg.params[0], ",", true) + + var keys []string + if len(msg.params) > 1 { + keys = splitString(msg.params[1], ",", true) + } + + for i, name := range targetChannels { + key := "" + if i < len(keys) { + key = keys[i] + } + ircTryJoin(c, name, key) + } +} + +func ircHandleSUMMON(msg *message, c *client) { + c.sendReply(ERR_SUMMONDISABLED) +} + +func ircHandleUSERS(msg *message, c *client) { + c.sendReply(ERR_USERSDISABLED) +} + +func ircHandleAWAY(msg *message, c *client) { + if len(msg.params) < 1 { + c.awayMessage = "" + c.sendReply(RPL_UNAWAY) + } else { + c.awayMessage = msg.params[0] + c.sendReply(RPL_NOWAWAY) + } +} + +func ircHandleISON(msg *message, c *client) { + if len(msg.params) < 1 { + c.sendReply(ERR_NEEDMOREPARAMS, msg.command) + return + } + + var on []string + for _, nick := range msg.params { + if client := users[ircToCanon(nick)]; client != nil { + on = append(on, nick) + } + } + c.sendReply(RPL_ISON, strings.Join(on, " ")) +} + +func ircHandleADMIN(msg *message, c *client) { + if len(msg.params) > 0 && !ircIsThisMe(msg.params[0]) { + c.sendReply(ERR_NOSUCHSERVER, msg.params[0]) + return + } + c.sendReply(ERR_NOADMININFO, serverName) +} + +func ircHandleStatsLinks(c *client, msg *message) { + // There is only an "l" query in RFC 2812 but we cannot link, + // so instead we provide the "L" query giving information for all users. + filter := "" + if len(msg.params) > 1 { + filter = msg.params[1] + } + + for _, client := range users { + if filter != "" && !ircEqual(client.nickname, filter) { + continue + } + c.sendReply(RPL_STATSLINKINFO, + client.address, // linkname + len(client.sendQ), // sendq + client.nSentMessages, client.sentBytes/1024, + client.nReceivedMessages, client.receivedBytes/1024, + time.Now().Sub(client.opened)/time.Second) + } +} + +func ircHandleStatsCommands(c *client) { + for name, handler := range ircHandlers { + if handler.nReceived > 0 { + c.sendReply(RPL_STATSCOMMANDS, name, + handler.nReceived, handler.bytesReceived, 0) + } + } +} + +// We need to do it this way because of an initialization loop concerning +// ircHandlers. Workaround proposed by rsc in go #1817. +var ircHandleStatsCommandsIndirect func(c *client) + +func init() { + ircHandleStatsCommandsIndirect = ircHandleStatsCommands +} + +func ircHandleStatsUptime(c *client) { + uptime := time.Now().Sub(started) / time.Second + + days := uptime / 60 / 60 / 24 + hours := (uptime % (60 * 60 * 24)) / 60 / 60 + mins := (uptime % (60 * 60)) / 60 + secs := uptime % 60 + + c.sendReply(RPL_STATSUPTIME, days, hours, mins, secs) +} + +func ircHandleSTATS(msg *message, c *client) { + var query byte + if len(msg.params) > 0 && len(msg.params[0]) > 0 { + query = msg.params[0][0] + } + + if len(msg.params) > 1 && !ircIsThisMe(msg.params[1]) { + c.sendReply(ERR_NOSUCHSERVER, msg.params[0]) + return + } + if 0 == c.mode&ircUserModeOperator { + c.sendReply(ERR_NOPRIVILEGES) + return + } + + switch query { + case 'L': + ircHandleStatsLinks(c, msg) + case 'm': + ircHandleStatsCommandsIndirect(c) + case 'u': + ircHandleStatsUptime(c) + } + c.sendReply(RPL_ENDOFSTATS, query) +} + +func ircHandleLINKS(msg *message, c *client) { + if len(msg.params) > 1 && !ircIsThisMe(msg.params[0]) { + c.sendReply(ERR_NEEDMOREPARAMS, msg.command) + return + } + + mask := "*" + if len(msg.params) > 0 { + if len(msg.params) > 1 { + mask = msg.params[1] + } else { + mask = msg.params[0] + } + } + + if ircFnmatch(mask, serverName) { + c.sendReply(RPL_LINKS, mask, serverName, + 0 /* hop count */, config["server_info"]) + } + c.sendReply(RPL_ENDOFLINKS, mask) +} + +func ircHandleWALLOPS(msg *message, c *client) { + if len(msg.params) < 1 { + c.sendReply(ERR_NEEDMOREPARAMS, msg.command) + return + } + if 0 == c.mode&ircUserModeOperator { + c.sendReply(ERR_NOPRIVILEGES) + return + } + + // Our interpretation: anonymize the sender, + // and target all users who want to receive these messages. + for _, client := range users { + if client == c || 0 != client.mode&ircUserModeRxWallops { + client.sendf(":%s WALLOPS :%s", serverName, msg.params[0]) + } + } +} + +func ircHandleKILL(msg *message, c *client) { + if len(msg.params) < 2 { + c.sendReply(ERR_NEEDMOREPARAMS, msg.command) + return + } + if 0 == c.mode&ircUserModeOperator { + c.sendReply(ERR_NOPRIVILEGES) + return + } + + target := users[ircToCanon(msg.params[0])] + if target == nil { + c.sendReply(ERR_NOSUCHNICK, msg.params[0]) + return + } + + c.sendf(":%s!%s@%s KILL %s :%s", + c.nickname, c.username, c.hostname, target.nickname, msg.params[1]) + target.closeLink(fmt.Sprintf("Killed by %s: %s", c.nickname, msg.params[1])) +} + +func ircHandleDIE(msg *message, c *client) { + if 0 == c.mode&ircUserModeOperator { + c.sendReply(ERR_NOPRIVILEGES) + } else if !quitting { + initiateQuit() + } +} + +// ----------------------------------------------------------------------------- + +// TODO: Add an index for ERR_NOSUCHSERVER validation? +// TODO: Add a minimal parameter count? +// TODO: Add a field for oper-only commands? Use flags? +var ircHandlers = map[string]*ircCommand{ + "WEBIRC": {false, ircHandleWEBIRC, 0, 0}, + "CAP": {false, ircHandleCAP, 0, 0}, + "PASS": {false, ircHandlePASS, 0, 0}, + "NICK": {false, ircHandleNICK, 0, 0}, + "USER": {false, ircHandleUSER, 0, 0}, + + "USERHOST": {true, ircHandleUSERHOST, 0, 0}, + "LUSERS": {true, ircHandleLUSERS, 0, 0}, + "MOTD": {true, ircHandleMOTD, 0, 0}, + "PING": {true, ircHandlePING, 0, 0}, + "PONG": {false, ircHandlePONG, 0, 0}, + "QUIT": {false, ircHandleQUIT, 0, 0}, + "TIME": {true, ircHandleTIME, 0, 0}, + "VERSION": {true, ircHandleVERSION, 0, 0}, + "USERS": {true, ircHandleUSERS, 0, 0}, + "SUMMON": {true, ircHandleSUMMON, 0, 0}, + "AWAY": {true, ircHandleAWAY, 0, 0}, + "ADMIN": {true, ircHandleADMIN, 0, 0}, + "STATS": {true, ircHandleSTATS, 0, 0}, + "LINKS": {true, ircHandleLINKS, 0, 0}, + "WALLOPS": {true, ircHandleWALLOPS, 0, 0}, + + "MODE": {true, ircHandleMODE, 0, 0}, + "PRIVMSG": {true, ircHandlePRIVMSG, 0, 0}, + "NOTICE": {true, ircHandleNOTICE, 0, 0}, + "JOIN": {true, ircHandleJOIN, 0, 0}, + "PART": {true, ircHandlePART, 0, 0}, + "KICK": {true, ircHandleKICK, 0, 0}, + "INVITE": {true, ircHandleINVITE, 0, 0}, + "TOPIC": {true, ircHandleTOPIC, 0, 0}, + "LIST": {true, ircHandleLIST, 0, 0}, + "NAMES": {true, ircHandleNAMES, 0, 0}, + "WHO": {true, ircHandleWHO, 0, 0}, + "WHOIS": {true, ircHandleWHOIS, 0, 0}, + "WHOWAS": {true, ircHandleWHOWAS, 0, 0}, + "ISON": {true, ircHandleISON, 0, 0}, + + "KILL": {true, ircHandleKILL, 0, 0}, + "DIE": {true, ircHandleDIE, 0, 0}, +} + +func ircProcessMessage(c *client, msg *message, raw string) { + if c.closing { + return + } + + c.nReceivedMessages++ + c.receivedBytes += len(raw) + 2 + + if !c.antiflood.check() { + c.closeLink("Excess flood") + return + } + + if cmd, ok := ircHandlers[ircToCanon(msg.command)]; !ok { + c.sendReply(ERR_UNKNOWNCOMMAND, msg.command) + } else { + cmd.nReceived++ + cmd.bytesReceived += len(raw) + 2 + + if cmd.requiresRegistration && !c.registered { + c.sendReply(ERR_NOTREGISTERED) + } else { + cmd.handler(msg, c) + } + } +} + +// --- Network I/O ------------------------------------------------------------- + +// Handle the results from initializing the client's connection. +func (c *client) onPrepared(host string, isTLS bool) { + c.printDebug("client resolved to %s, TLS %t", host, isTLS) + if !isTLS { + c.conn = c.transport.(connCloseWriter) + } else if tlsConf != nil { + c.tls = tls.Server(c.transport, tlsConf) + c.conn = c.tls + } else { + c.printDebug("could not initialize TLS: disabled") + c.kill("TLS support disabled") + return + } + + c.hostname = host + c.address = net.JoinHostPort(host, c.port) + + // If we tried to send any data before now, we would need to flushSendQ. + go read(c) + c.reading = true + c.setPingTimer() +} + +// Handle the results from trying to read from the client connection. +func (c *client) onRead(data []byte, readErr error) { + if !c.reading { + // Abusing the flag to emulate CloseRead and skip over data, see below. + return + } + + c.recvQ = append(c.recvQ, data...) + for { + // XXX: This accepts even simple LF newlines, even though they're not + // really allowed by the protocol. + advance, token, _ := bufio.ScanLines(c.recvQ, false /* atEOF */) + if advance == 0 { + break + } + + // XXX: And since it accepts LF, we miscalculate receivedBytes within. + c.recvQ = c.recvQ[advance:] + line := string(token) + c.printDebug("-> %s", line) + + if msg := ircParseMessage(line); msg == nil { + c.printDebug("error: invalid line") + } else { + ircProcessMessage(c, msg, line) + } + } + + if readErr != nil { + c.reading = false + + if readErr != io.EOF { + c.printDebug("%s", readErr) + c.kill(readErr.Error()) + } else if c.closing { + // Disregarding whether a clean shutdown has happened or not. + c.printDebug("client finished shutdown") + c.kill("") + } else { + c.printDebug("client EOF") + c.closeLink("") + } + } else if len(c.recvQ) > 8192 { + c.closeLink("recvQ overrun") + + // tls.Conn doesn't have the CloseRead method (and it needs to be able + // to read from the TCP connection even for writes, so there isn't much + // sense in expecting the implementation to do anything useful), + // otherwise we'd use it to block incoming packet data. + c.reading = false + } +} + +// Spawn a goroutine to flush the sendQ if possible and necessary. +func (c *client) flushSendQ() { + if !c.writing && c.conn != nil { + go write(c, c.sendQ) + c.writing = true + } +} + +// Handle the results from trying to write to the client connection. +func (c *client) onWrite(written int, writeErr error) { + c.sendQ = c.sendQ[written:] + c.writing = false + + if writeErr != nil { + c.printDebug("%s", writeErr) + c.kill(writeErr.Error()) + } else if len(c.sendQ) > 0 { + c.flushSendQ() + } else if c.closing { + if c.reading { + c.conn.CloseWrite() + } else { + c.kill("") + } + } +} + +// --- Worker goroutines ------------------------------------------------------- + +func accept(ln net.Listener) { + for { + // Error handling here may be tricky, see go #6163, #24808. + if conn, err := ln.Accept(); err != nil { + // See go #4373, they're being dicks. Another solution would be to + // pass a done channel to this function and close it before closing + // all the listeners, returning from here if it's readable. + if strings.Contains(err.Error(), + "use of closed network connection") { + return + } + // XXX: net.Error.Temporary() has been deprecated in 1.18. + if op, ok := err.(net.Error); !ok || !op.Temporary() { + exitFatal("%s", err) + } else { + printError("%s", err) + } + } else { + // TCP_NODELAY is set by default on TCPConns. + conns <- conn + } + } +} + +func prepare(client *client) { + conn, host := client.transport, client.hostname + + // The Cgo resolver doesn't pthread_cancel getnameinfo threads, so not + // bothering with pointless contexts. + ch := make(chan string, 1) + go func() { + defer close(ch) + if names, err := net.LookupAddr(host); err != nil { + printError("%s", err) + } else { + ch <- names[0] + } + }() + + // While we can't cancel it, we still want to set a timeout on it. + select { + case <-time.After(5 * time.Second): + case resolved, ok := <-ch: + if ok { + host = resolved + } + } + + // Note that in this demo application the autodetection prevents non-TLS + // clients from receiving any messages until they send something. + isTLS := false + if sysconn, err := conn.(syscall.Conn).SyscallConn(); err != nil { + // This is just for the TLS detection and doesn't need to be fatal. + printError("%s", err) + } else { + isTLS = detectTLS(sysconn) + } + + prepared <- preparedEvent{client, host, isTLS} +} + +func read(client *client) { + // A new buffer is allocated each time we receive some bytes, because of + // thread-safety. Therefore the buffer shouldn't be too large, or we'd + // need to copy it each time into a precisely sized new buffer. + var err error + for err == nil { + var ( + buf [512]byte + n int + ) + n, err = client.conn.Read(buf[:]) + reads <- readEvent{client, buf[:n], err} + } +} + +// Flush sendQ, which is passed by parameter so that there are no data races. +func write(client *client, data []byte) { + // We just write as much as we can, the main goroutine does the looping. + n, err := client.conn.Write(data) + writes <- writeEvent{client, n, err} +} + +// --- Event loop -------------------------------------------------------------- + +func processOneEvent() { + select { + case <-sigs: + if quitting { + forceQuit("requested by user") + } else { + initiateQuit() + } + + case <-quitTimer: + forceQuit("timeout") + + case callback := <-timers: + callback() + + case conn := <-conns: + if maxConnections > 0 && len(clients) >= maxConnections { + printDebug("connection limit reached, refusing connection") + conn.Close() + break + } + + address := conn.RemoteAddr().String() + host, port, err := net.SplitHostPort(address) + if err != nil { + // In effect, we require TCP/UDP, as they have port numbers. + exitFatal("%s", err) + } + + c := &client{ + transport: conn, + address: address, + hostname: host, + port: port, + capVersion: 301, + opened: time.Now(), + lastActive: time.Now(), + // TODO: Make this configurable and more fine-grained. + antiflood: newFloodDetector(10*time.Second, 20), + } + + clients[c] = true + c.printDebug("new client") + go prepare(c) + + // The TLS autodetection in prepare needs to have a timeout. + c.setKillTimer() + + case ev := <-prepared: + if _, ok := clients[ev.client]; ok { + ev.client.onPrepared(ev.host, ev.isTLS) + } + + case ev := <-reads: + if _, ok := clients[ev.client]; ok { + ev.client.onRead(ev.data, ev.err) + } + + case ev := <-writes: + if _, ok := clients[ev.client]; ok { + ev.client.onWrite(ev.written, ev.err) + } + } +} + +// --- Application setup ------------------------------------------------------- + +func ircInitializeTLS() error { + configCert, configKey := config["tls_cert"], config["tls_key"] + + // Only try to enable SSL support if the user configures it; it is not + // a failure if no one has requested it. + if configCert == "" && configKey == "" { + return nil + } else if configCert == "" { + return errors.New("no TLS certificate set") + } else if configKey == "" { + return errors.New("no TLS private key set") + } + + pathCert := resolveFilename(configCert, resolveRelativeConfigFilename) + if pathCert == "" { + return fmt.Errorf("cannot find file: %s", configCert) + } + + pathKey := resolveFilename(configKey, resolveRelativeConfigFilename) + if pathKey == "" { + return fmt.Errorf("cannot find file: %s", configKey) + } + + cert, err := tls.LoadX509KeyPair(pathCert, pathKey) + if err != nil { + return err + } + + tlsConf = &tls.Config{ + Certificates: []tls.Certificate{cert}, + ClientAuth: tls.RequestClientCert, + SessionTicketsDisabled: true, + } + return nil +} + +func ircInitializeCatalog() error { + configCatalog := config["catalog"] + if configCatalog == "" { + return nil + } + + path := resolveFilename(configCatalog, resolveRelativeConfigFilename) + if path == "" { + return fmt.Errorf("cannot find file: %s", configCatalog) + } + + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("failed reading the MOTD file: %s", err) + } + defer f.Close() + + scanner := bufio.NewScanner(f) + catalog = make(map[int]string) + for lineNo := 1; scanner.Scan(); lineNo++ { + line := strings.TrimLeft(scanner.Text(), " \t") + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + delim := strings.IndexAny(line, " \t") + if delim < 0 { + return fmt.Errorf("%s:%d: malformed line", path, lineNo) + } + + id, err := strconv.ParseUint(line[:delim], 10, 16) + if err != nil { + return fmt.Errorf("%s:%d: %s", path, lineNo, err) + } + + catalog[int(id)] = line[delim+1:] + } + return scanner.Err() +} + +func ircInitializeMOTD() error { + configMOTD := config["motd"] + if configMOTD == "" { + return nil + } + + path := resolveFilename(configMOTD, resolveRelativeConfigFilename) + if path == "" { + return fmt.Errorf("cannot find file: %s", configMOTD) + } + + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("failed reading the MOTD file: %s", err) + } + defer f.Close() + + scanner := bufio.NewScanner(f) + motd = nil + for scanner.Scan() { + motd = append(motd, scanner.Text()) + } + return scanner.Err() +} + +type configProcessor struct { + err error // any error that has occurred so far +} + +func (cp *configProcessor) read(name string, process func(string) string) { + if cp.err != nil { + return + } + if err := process(config[name]); err != "" { + cp.err = fmt.Errorf("invalid configuration value for `%s': %s", + name, err) + } +} + +// This function handles values that require validation before their first use, +// or some kind of a transformation (such as conversion to an integer) needs +// to be done before they can be used directly. +func ircParseConfig() error { + cp := &configProcessor{} + cp.read("ping_interval", func(value string) string { + if u, err := strconv.ParseUint( + config["ping_interval"], 10, 32); err != nil { + return err.Error() + } else if u < 1 { + return "the value is out of range" + } else { + pingInterval = time.Second * time.Duration(u) + } + return "" + }) + cp.read("max_connections", func(value string) string { + if i, err := strconv.ParseInt( + value, 10, 32); err != nil { + return err.Error() + } else if i < 0 { + return "the value is out of range" + } else { + maxConnections = int(i) + } + return "" + }) + cp.read("operators", func(value string) string { + operators = make(map[string]bool) + for _, fp := range splitString(value, ",", true) { + if !ircIsValidFingerprint(fp) { + return "invalid fingerprint value" + } + operators[strings.ToLower(fp)] = true + } + return "" + }) + return cp.err +} + +func ircInitializeServerName() error { + if value := config["server_name"]; value != "" { + if err := ircValidateHostname(value); err != nil { + return err + } + serverName = value + return nil + } + + if hostname, err := os.Hostname(); err != nil { + return err + } else if err := ircValidateHostname(hostname); err != nil { + return err + } else { + serverName = hostname + } + return nil +} + +func ircSetupListenFDs() error { + for _, address := range splitString(config["bind"], ",", true) { + ln, err := net.Listen("tcp", address) + if err != nil { + return err + } + listeners = append(listeners, ln) + printStatus("listening on %s", address) + } + if len(listeners) == 0 { + return errors.New("network setup failed: no ports to listen on") + } + for _, ln := range listeners { + go accept(ln) + } + return nil +} + +// --- Main -------------------------------------------------------------------- + +func main() { + flag.BoolVar(&debugMode, "debug", false, "run in verbose debug mode") + version := flag.Bool("version", false, "show version and exit") + writeDefaultCfg := flag.Bool("writedefaultcfg", false, + "write a default configuration file and exit") + systemd := flag.Bool("systemd", false, "log in systemd format") + + flag.Parse() + + if *version { + fmt.Printf("%s %s\n", projectName, projectVersion) + return + } + if *writeDefaultCfg { + callSimpleConfigWriteDefault("", configTable) + return + } + if *systemd { + logMessage = logMessageSystemd + } + if flag.NArg() > 0 { + flag.Usage() + os.Exit(2) + } + + // Note that this has become unnecessary since Go 1.19. + var limit syscall.Rlimit + if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &limit); err == nil && + limit.Cur != limit.Max { + limit.Cur = limit.Max + syscall.Setrlimit(syscall.RLIMIT_NOFILE, &limit) + } + + config = make(simpleConfig) + config.loadDefaults(configTable) + if err := config.updateFromFile(); err != nil && !os.IsNotExist(err) { + printError("error loading configuration: %s", err) + os.Exit(1) + } + + started = time.Now() + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + + for _, fn := range []func() error{ + ircInitializeTLS, + ircInitializeServerName, + ircInitializeMOTD, + ircInitializeCatalog, + ircParseConfig, + ircSetupListenFDs, + } { + if err := fn(); err != nil { + exitFatal("%s", err) + } + } + + for !quitting || len(clients) > 0 { + processOneEvent() + } +} diff --git a/xS/xS_test.go b/xS/xS_test.go new file mode 100644 index 0000000..8241b4e --- /dev/null +++ b/xS/xS_test.go @@ -0,0 +1,168 @@ +// +// Copyright (c) 2015 - 2018, Přemysl Eric Janouch +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +// + +package main + +import ( + "crypto/tls" + "net" + "os" + "reflect" + "syscall" + "testing" +) + +func TestSplitString(t *testing.T) { + var splitStringTests = []struct { + s, delims string + ignoreEmpty bool + result []string + }{ + {",a,,bc", ",", false, []string{"", "a", "", "bc"}}, + {",a,,bc", ",", true, []string{"a", "bc"}}, + {"a,;bc,", ",;", false, []string{"a", "", "bc", ""}}, + {"a,;bc,", ",;", true, []string{"a", "bc"}}, + {"", ",", false, []string{""}}, + {"", ",", true, nil}, + } + + for i, d := range splitStringTests { + got := splitString(d.s, d.delims, d.ignoreEmpty) + if !reflect.DeepEqual(got, d.result) { + t.Errorf("case %d: %v should be %v\n", i, got, d.result) + } + } +} + +func socketpair() (*os.File, *os.File, error) { + pair, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0) + if err != nil { + return nil, nil, err + } + + // See go #24331, this makes 1.11 use the internal poller + // while there wasn't a way to achieve that before. + if err := syscall.SetNonblock(int(pair[0]), true); err != nil { + return nil, nil, err + } + if err := syscall.SetNonblock(int(pair[1]), true); err != nil { + return nil, nil, err + } + + fa := os.NewFile(uintptr(pair[0]), "a") + if fa == nil { + return nil, nil, os.ErrInvalid + } + + fb := os.NewFile(uintptr(pair[1]), "b") + if fb == nil { + fa.Close() + return nil, nil, os.ErrInvalid + } + + return fa, fb, nil +} + +func TestDetectTLS(t *testing.T) { + detectTLSFromFunc := func(t *testing.T, writer func(net.Conn)) bool { + // net.Pipe doesn't use file descriptors, we need a socketpair. + sockA, sockB, err := socketpair() + if err != nil { + t.Fatal(err) + } + defer sockA.Close() + defer sockB.Close() + + fcB, err := net.FileConn(sockB) + if err != nil { + t.Fatal(err) + } + go writer(fcB) + + fcA, err := net.FileConn(sockA) + if err != nil { + t.Fatal(err) + } + sc, err := fcA.(syscall.Conn).SyscallConn() + if err != nil { + t.Fatal(err) + } + return detectTLS(sc) + } + + t.Run("SSL_2.0", func(t *testing.T) { + if !detectTLSFromFunc(t, func(fc net.Conn) { + // The obsolete, useless, unsupported SSL 2.0 record format. + _, _ = fc.Write([]byte{0x80, 0x01, 0x01}) + }) { + t.Error("could not detect SSL") + } + }) + t.Run("crypto_tls", func(t *testing.T) { + if !detectTLSFromFunc(t, func(fc net.Conn) { + conn := tls.Client(fc, &tls.Config{InsecureSkipVerify: true}) + _ = conn.Handshake() + }) { + t.Error("could not detect TLS") + } + }) + t.Run("text", func(t *testing.T) { + if detectTLSFromFunc(t, func(fc net.Conn) { + _, _ = fc.Write([]byte("ПРЕВЕД")) + }) { + t.Error("detected UTF-8 as TLS") + } + }) + t.Run("EOF", func(t *testing.T) { + type connCloseWriter interface { + net.Conn + CloseWrite() error + } + if detectTLSFromFunc(t, func(fc net.Conn) { + _ = fc.(connCloseWriter).CloseWrite() + }) { + t.Error("detected EOF as TLS") + } + }) +} + +func TestIRC(t *testing.T) { + msg := ircParseMessage( + `@first=a\:\s\r\n\\;2nd :srv hi there :good m8 :how are you?`) + + if !reflect.DeepEqual(msg.tags, map[string]string{ + "first": "a; \r\n\\", + "2nd": "", + }) { + t.Error("tags parsed incorrectly") + } + + if msg.nick != "srv" || msg.user != "" || msg.host != "" { + t.Error("server name parsed incorrectly") + } + if msg.command != "hi" { + t.Error("command name parsed incorrectly") + } + if !reflect.DeepEqual(msg.params, + []string{"there", "good m8 :how are you?"}) { + t.Error("params parsed incorrectly") + } + + if !ircEqual("[fag]^", "{FAG}~") { + t.Error("string case comparison not according to RFC 2812") + } + + // TODO: More tests. +} -- cgit v1.2.3