diff options
author | Přemysl Eric Janouch <p@janouch.name> | 2022-09-26 12:26:49 +0200 |
---|---|---|
committer | Přemysl Eric Janouch <p@janouch.name> | 2022-09-26 12:41:47 +0200 |
commit | f891e5ca638ead13485cc490320e74d698641623 (patch) | |
tree | 50c063589ed2b5aba130e87149c97f9ed487911b | |
parent | 4d50ed111ad4f3172c4c186a2ca19b638c709a70 (diff) | |
parent | 8344b09c4f9989370691c71b1145b09348e0a6d3 (diff) | |
download | xK-f891e5ca638ead13485cc490320e74d698641623.tar.gz xK-f891e5ca638ead13485cc490320e74d698641623.tar.xz xK-f891e5ca638ead13485cc490320e74d698641623.zip |
Merge hid IRCd from haven as xS
Given that this project already contains a Go binary,
it only makes sense to put the IRCds back together.
-rw-r--r-- | xS/main.go | 3529 | ||||
-rw-r--r-- | xS/main_test.go | 168 | ||||
-rwxr-xr-x | xS/xS-gen-replies.awk | 16 | ||||
-rw-r--r-- | xS/xS-replies | 87 |
4 files changed, 3800 insertions, 0 deletions
diff --git a/xS/main.go b/xS/main.go new file mode 100644 index 0000000..21851f1 --- /dev/null +++ b/xS/main.go @@ -0,0 +1,3529 @@ +// +// Copyright (c) 2014 - 2022, Přemysl Eric Janouch <p@janouch.name> +// +// 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 new file mode 100644 index 0000000..8241b4e --- /dev/null +++ b/xS/main_test.go @@ -0,0 +1,168 @@ +// +// Copyright (c) 2015 - 2018, Přemysl Eric Janouch <p@janouch.name> +// +// 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 new file mode 100755 index 0000000..fce7b50 --- /dev/null +++ b/xS/xS-gen-replies.awk @@ -0,0 +1,16 @@ +#!/usr/bin/awk -f +/^[0-9]+ *(ERR|RPL)_[A-Z]+ *".*"$/ { + match($0, /".*"/); + ids[$1] = $2; + texts[$2] = substr($0, RSTART, RLENGTH); +} + +END { + print "package " ENVIRON["GOPACKAGE"] "\n\nconst (" + for (i in ids) + printf("\t%s = %s\n", ids[i], i) + print ")\n\nvar defaultReplies = map[int]string{" + for (i in ids) + print "\t" ids[i] ": " texts[ids[i]] "," + print "}" +} diff --git a/xS/xS-replies b/xS/xS-replies new file mode 100644 index 0000000..c539520 --- /dev/null +++ b/xS/xS-replies @@ -0,0 +1,87 @@ +1 RPL_WELCOME ":Welcome to the Internet Relay Network %s!%s@%s" +2 RPL_YOURHOST ":Your host is %s, running version %s" +3 RPL_CREATED ":This server was created %s" +4 RPL_MYINFO "%s %s %s %s" +5 RPL_ISUPPORT "%s :are supported by this server" +211 RPL_STATSLINKINFO "%s %d %d %d %d %d %d" +212 RPL_STATSCOMMANDS "%s %d %d %d" +219 RPL_ENDOFSTATS "%c :End of STATS report" +221 RPL_UMODEIS "+%s" +242 RPL_STATSUPTIME ":Server Up %d days %d:%02d:%02d" +251 RPL_LUSERCLIENT ":There are %d users and %d services on %d servers" +252 RPL_LUSEROP "%d :operator(s) online" +253 RPL_LUSERUNKNOWN "%d :unknown connection(s)" +254 RPL_LUSERCHANNELS "%d :channels formed" +255 RPL_LUSERME ":I have %d clients and %d servers" +301 RPL_AWAY "%s :%s" +302 RPL_USERHOST ":%s" +303 RPL_ISON ":%s" +305 RPL_UNAWAY ":You are no longer marked as being away" +306 RPL_NOWAWAY ":You have been marked as being away" +311 RPL_WHOISUSER "%s %s %s * :%s" +312 RPL_WHOISSERVER "%s %s :%s" +313 RPL_WHOISOPERATOR "%s :is an IRC operator" +314 RPL_WHOWASUSER "%s %s %s * :%s" +315 RPL_ENDOFWHO "%s :End of WHO list" +317 RPL_WHOISIDLE "%s %d :seconds idle" +318 RPL_ENDOFWHOIS "%s :End of WHOIS list" +319 RPL_WHOISCHANNELS "%s :%s" +322 RPL_LIST "%s %d :%s" +323 RPL_LISTEND ":End of LIST" +324 RPL_CHANNELMODEIS "%s +%s" +329 RPL_CREATIONTIME "%s %d" +331 RPL_NOTOPIC "%s :No topic is set" +332 RPL_TOPIC "%s :%s" +333 RPL_TOPICWHOTIME "%s %s %d" +341 RPL_INVITING "%s %s" +346 RPL_INVITELIST "%s %s" +347 RPL_ENDOFINVITELIST "%s :End of channel invite list" +348 RPL_EXCEPTLIST "%s %s" +349 RPL_ENDOFEXCEPTLIST "%s :End of channel exception list" +351 RPL_VERSION "%s.%d %s :%s" +352 RPL_WHOREPLY "%s %s %s %s %s %s :%d %s" +353 RPL_NAMREPLY "%c %s :%s" +364 RPL_LINKS "%s %s :%d %s" +365 RPL_ENDOFLINKS "%s :End of LINKS list" +366 RPL_ENDOFNAMES "%s :End of NAMES list" +367 RPL_BANLIST "%s %s" +368 RPL_ENDOFBANLIST "%s :End of channel ban list" +369 RPL_ENDOFWHOWAS "%s :End of WHOWAS" +372 RPL_MOTD ":- %s" +375 RPL_MOTDSTART ":- %s Message of the day - " +376 RPL_ENDOFMOTD ":End of MOTD command" +391 RPL_TIME "%s :%s" +401 ERR_NOSUCHNICK "%s :No such nick/channel" +402 ERR_NOSUCHSERVER "%s :No such server" +403 ERR_NOSUCHCHANNEL "%s :No such channel" +404 ERR_CANNOTSENDTOCHAN "%s :Cannot send to channel" +406 ERR_WASNOSUCHNICK "%s :There was no such nickname" +409 ERR_NOORIGIN ":No origin specified" +410 ERR_INVALIDCAPCMD "%s :%s" +411 ERR_NORECIPIENT ":No recipient given (%s)" +412 ERR_NOTEXTTOSEND ":No text to send" +421 ERR_UNKNOWNCOMMAND "%s: Unknown command" +422 ERR_NOMOTD ":MOTD File is missing" +423 ERR_NOADMININFO "%s :No administrative info available" +431 ERR_NONICKNAMEGIVEN ":No nickname given" +432 ERR_ERRONEOUSNICKNAME "%s :Erroneous nickname" +433 ERR_NICKNAMEINUSE "%s :Nickname is already in use" +441 ERR_USERNOTINCHANNEL "%s %s :They aren't on that channel" +442 ERR_NOTONCHANNEL "%s :You're not on that channel" +443 ERR_USERONCHANNEL "%s %s :is already on channel" +445 ERR_SUMMONDISABLED ":SUMMON has been disabled" +446 ERR_USERSDISABLED ":USERS has been disabled" +451 ERR_NOTREGISTERED ":You have not registered" +461 ERR_NEEDMOREPARAMS "%s :Not enough parameters" +462 ERR_ALREADYREGISTERED ":Unauthorized command (already registered)" +467 ERR_KEYSET "%s :Channel key already set" +471 ERR_CHANNELISFULL "%s :Cannot join channel (+l)" +472 ERR_UNKNOWNMODE "%c :is unknown mode char to me for %s" +473 ERR_INVITEONLYCHAN "%s :Cannot join channel (+i)" +474 ERR_BANNEDFROMCHAN "%s :Cannot join channel (+b)" +475 ERR_BADCHANNELKEY "%s :Cannot join channel (+k)" +476 ERR_BADCHANMASK "%s :Bad Channel Mask" +481 ERR_NOPRIVILEGES ":Permission Denied- You're not an IRC operator" +482 ERR_CHANOPRIVSNEEDED "%s :You're not channel operator" +501 ERR_UMODEUNKNOWNFLAG ":Unknown MODE flag" +502 ERR_USERSDONTMATCH ":Cannot change mode for other users" |