diff options
author | Přemysl Janouch <p@janouch.name> | 2018-07-28 16:21:34 +0200 |
---|---|---|
committer | Přemysl Janouch <p@janouch.name> | 2018-07-28 16:21:34 +0200 |
commit | 2d287752d4b17ebac1db25bc6a013215e8b32162 (patch) | |
tree | 512bc20e79f689478f9ad7345b58a7abd42992ee /xS/main.go | |
download | xK-2d287752d4b17ebac1db25bc6a013215e8b32162.tar.gz xK-2d287752d4b17ebac1db25bc6a013215e8b32162.tar.xz xK-2d287752d4b17ebac1db25bc6a013215e8b32162.zip |
hid: add a work in progress IRC daemon
The port is more than viable but it's also sort of all-or-nothing
and versioning needs have come before I've had a chance to finish it.
Diffstat (limited to 'xS/main.go')
-rw-r--r-- | xS/main.go | 1526 |
1 files changed, 1526 insertions, 0 deletions
diff --git a/xS/main.go b/xS/main.go new file mode 100644 index 0000000..72a6df0 --- /dev/null +++ b/xS/main.go @@ -0,0 +1,1526 @@ +// +// Copyright (c) 2014 - 2018, Přemysl 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. +// + +// hid is a straight-forward port of kike IRCd from C. +package main + +/* + +// ANSI terminal formatting, would be better if we had isatty() available +func tf(text string, ansi string) string { + return "\x1b[0;" + ansi + "m" + text + "\x1b[0m" +} + +func logErrorf(format string, args ...interface{}) { + fmt.Fprintf(os.Stderr, tf("error: "+format+"\n", "1;31"), args...) +} + +func logFatalf(format string, args ...interface{}) { + fmt.Fprintf(os.Stderr, tf("fatal: "+format+"\n", "1;31"), args...) + os.Exit(1) +} + +func logFatal(object interface{}) { + logFatalf("%s", object) +} + +func getHome() (home string) { + if u, _ := user.Current(); u != nil { + home = u.HomeDir + } else { + home = os.Getenv("HOME") + } + return +} + +// Only handling the simple case as that's what one mostly wants. +// TODO(p): Handle the generic case as well. +func expandTilde(path string) string { + if strings.HasPrefix(path, "~/") { + return getHome() + path[1:] + } + return path +} + +func getXdgHomeDir(name, def string) string { + env := os.Getenv(name) + if env != "" && env[0] == '/' { + return env + } + return filepath.Join(getHome(), def) +} + +// 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 +} + +// Read a configuration file with the given basename w/o extension +func readConfigFile(name string, output interface{}) error { + var suffix = filepath.Join(projectName, name+".json") + for _, path := range getXdgConfigDirs() { + full := filepath.Join(path, suffix) + file, err := os.Open(full) + if err != nil { + if !os.IsNotExist(err) { + return err + } + continue + } + defer file.Close() + + decoder := json.NewDecoder(file) + err = decoder.Decode(output) + if err != nil { + return fmt.Errorf("%s: %s", full, err) + } + return nil + } + return errors.New("configuration file not found") +} + +*/ + +import ( + "bufio" + "crypto/sha256" + "crypto/tls" + "encoding/hex" + "flag" + "fmt" + "io" + "log" + "net" + "os" + "os/signal" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" + "time" +) + +var debugMode = false + +// --- Utilities --------------------------------------------------------------- + +// +// 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) +// +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 = buf[0] == 22 && buf[1] == 3 + case n == 1: + isTLS = buf[0] == 22 + case err == syscall.EAGAIN: + return false + } + return true + }) + return isTLS +} + +// --- Configuration ----------------------------------------------------------- + +// XXX: Do we really want to support default nil values? +var config = []struct { + key string // INI key + def []rune // default value, may be nil + description string // documentation +}{ + // XXX: I'm not sure if Go will cooperate here. + {"pid_file", nil, "Path or name of the PID file"}, + {"bind", []rune(":6667"), "Address of the IRC server"}, +} + +// --- Rate limiter ------------------------------------------------------------ + +type floodDetector struct { + interval uint // interval for the limit + limit uint // maximum number of events allowed + timestamps []int64 // timestamps of last events + pos uint // index of the oldest event +} + +func newFloodDetector(interval, limit uint) *floodDetector { + return &floodDetector{ + interval: interval, + limit: limit, + timestamps: make([]int64, limit+1), + pos: 0, + } +} + +func (fd *floodDetector) check() bool { + now := time.Now().Unix() + fd.timestamps[fd.pos] = now + + fd.pos++ + if fd.pos > fd.limit { + fd.pos = 0 + } + + var count uint + begin := now - int64(fd.interval) + for _, ts := range fd.timestamps { + if ts >= begin { + count++ + } + } + return count <= fd.limit +} + +// --- IRC protocol ------------------------------------------------------------ + +//go:generate sh -c "./hid-gen-replies.sh > hid-replies.go < hid-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 +} + +// TODO: To support ALL CAPS initialization of maps, perhaps we should use +// ircToUpper instead. +// FIXME: This doesn't follow the meaning of strxfrm and perhaps should be +// renamed to ircNormalize. +func ircStrxfrm(ident string) string { + var canon []byte + for _, c := range []byte(ident) { + canon = append(canon, ircToLower(c)) + } + return string(canon) +} + +func ircFnmatch(pattern string, s string) bool { + pattern, s = ircStrxfrm(pattern), ircStrxfrm(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 +} + +// TODO: We will need to add support for IRCv3 tags. +var reMsg = regexp.MustCompile( + `^(?::([^! ]*)(?:!([^@]*)@([^ ]*))? +)?([^ ]+)(.*)?$`) +var reArgs = regexp.MustCompile(`:.*| [^: ][^ ]*`) + +type message struct { + nick string // optional nickname + user string // optional username + host string // optional hostname or IP address + command string // command name + params []string // arguments +} + +// Everything as per RFC 2812 +const ( + ircMaxNickname = 9 + ircMaxHostname = 63 + ircMaxChannelName = 50 + ircMaxMessageLength = 510 +) + +// TODO: Port the IRC token validation part as needed. + +const reClassSpecial = "\\[\\]\\\\`_^{|}" + +var ( + // 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\7\r\n ,:]+$`) +) + +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) +} + +// --- Clients (equals users) -------------------------------------------------- + +type connCloseWrite interface { + net.Conn + CloseWrite() error +} + +const ircSupportedUserModes = "aiwros" + +const ( + ircUserModeInvisible = 1 << iota + ircUserModeRxWallops + ircUserModeRestricted + ircUserModeOperator + ircUserModeRxServerNotices +) + +const ( + ircCapMultiPrefix = 1 << iota + ircCapInviteNotify + ircCapEchoMessage + ircCapUserhostInNames + ircCapServerTime +) + +type client struct { + transport net.Conn // underlying connection + tls *tls.Conn // TLS, if detected + conn connCloseWrite // high-level connection + inQ []byte // unprocessed input + outQ []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 int64 // 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 int64 // 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 = 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 int64 // creation time + + topic string // channel topic + topicWho string // who set the topic + topicTime int64 // 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 newChannel() *channel { + return &channel{userLimit: -1} +} + +// TODO: Port struct channel methods. + +// --- 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 { + name string + requiresRegistration bool + handler func(*message, *client) + + nReceived uint // number of commands received + bytesReceived uint // 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: Port server_context. Maybe we want to keep it in a struct? +// XXX: Beware that maps with identifier keys need to be indexed correctly. +// We might want to enforce accessor functions for users and channels. +var ( + started int64 // when has the server been started + + users map[string]*client // maps nicknames to clients + channels map[string]*channel // maps channel names to data + handlers map[string]bool // TODO message handlers + capHandlers map[string]bool // TODO CAP message handlers + + whowas map[string]*whowasInfo // WHOWAS registry + + serverName string // our server name + pingInterval uint // ping interval in seconds + maxConnections int // max connections allowed or 0 + motd []string // MOTD (none if empty) + operators map[string]bool // TLS certificate fingerprints for IRCops +) + +var ( + sigs = make(chan os.Signal, 1) + conns = make(chan net.Conn) + prepared = make(chan preparedEvent) + reads = make(chan readEvent) + writes = make(chan writeEvent) + timeouts = make(chan *client) + + tlsConf *tls.Config + clients = make(map[*client]bool) + listener net.Listener + // TODO: quitting, quitTimer as they are named in kike? + inShutdown bool + shutdownTimer <-chan time.Time +) + +// Forcefully tear down all connections. +func forceShutdown(reason string) { + if !inShutdown { + log.Fatalln("forceShutdown called without initiateShutdown") + } + + log.Printf("forced shutdown (%s)\n", reason) + for c := range clients { + c.destroy("TODO") + } +} + +// Initiate a clean shutdown of the whole daemon. +func initiateShutdown() { + log.Println("shutting down") + if err := listener.Close(); err != nil { + log.Println(err) + } + for c := range clients { + c.closeLink("TODO") + } + + shutdownTimer = time.After(5 * time.Second) + inShutdown = true +} + +// TODO: ircChannelCreate + +func ircChannelDestroyIfEmpty(ch *channel) { + // TODO +} + +// TODO: ircSendToRoommates +// Broadcast to all /other/ clients (telnet-friendly, also in accordance to +// the plan of extending this to an IRCd). +func broadcast(line string, except *client) { + for c := range clients { + if c != except { + c.send(line) + } + } +} + +func ircSendToRoommates(c *client, message string) { + // TODO +} + +// --- Clients (continued) ----------------------------------------------------- + +// TODO: Perhaps we should append to *[]byte for performance. +func clientModeToString(m uint, mode *string) { + if 0 != m&ircUserModeInvisible { + *mode += "i" + } + if 0 != m&ircUserModeRxWallops { + *mode += "w" + } + if 0 != m&ircUserModeRestricted { + *mode += "r" + } + if 0 != m&ircUserModeOperator { + *mode += "o" + } + if 0 != m&ircUserModeRxServerNotices { + *mode += "s" + } +} + +func (c *client) getMode() string { + mode := "" + if c.awayMessage != "" { + mode += "a" + } + clientModeToString(c.mode, &mode) + return mode +} + +func (c *client) send(line string) { + if c.conn == nil || c.closing { + return + } + + // TODO: Rename inQ and outQ to recvQ and sendQ as they are usually named. + oldOutQ := len(c.outQ) + + // 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.outQ = time.Now().UTC(). + AppendFormat(c.outQ, "@time=2006-01-02T15:04:05.000Z ") + } + + bytes := []byte(line) + if len(bytes) > ircMaxMessageLength { + bytes = bytes[:ircMaxMessageLength] + } + + // TODO: Kill the connection above some "SendQ" threshold (careful!) + c.outQ = append(c.outQ, bytes...) + c.outQ = append(c.outQ, "\r\n"...) + c.flushOutQ() + + // Technically we haven't sent it yet but that's a minor detail + c.nSentMessages++ + c.sentBytes += len(c.outQ) - oldOutQ +} + +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[ircStrxfrm(c.nickname)] = newWhowasInfo(c) +} + +func (c *client) unregister(reason string) { + if !c.registered { + return + } + + ircSendToRoommates(c, fmt.Sprintf(":%s!%s@%s QUIT :%s", + c.nickname, c.username, c.hostname, reason)) + + // The eventual QUIT message will take care of state at clients. + for _, ch := range channels { + delete(ch.userModes, c) + ircChannelDestroyIfEmpty(ch) + } + + c.addToWhowas() + delete(users, ircStrxfrm(c.nickname)) + c.nickname = "" + c.registered = false +} + +// TODO: Rename to kill. +// Close the connection and forget about the client. +func (c *client) destroy(reason string) { + if reason == "" { + reason = "Client exited" + } + c.unregister(reason) + + // TODO: Log the address; seems like we always have c.address. + log.Println("client destroyed") + + // 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() + } + + // Clean up the goroutine, although a spurious event may still be sent. + // TODO: Other timers if needed. + if c.killTimer != nil { + c.killTimer.Stop() + } + + 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.destroy(reason) + return + } + if c.closing { + return + } + + nickname := c.nickname + if nickname == "" { + nickname = "*" + } + + // 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)", + nickname, c.hostname /* TODO host IP? */, reason) + c.closing = true + + c.unregister(reason) + c.killTimer = time.AfterFunc(3*time.Second, func() { + timeouts <- c + }) +} + +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 ------------------------------------------------------------------ + +// TODO + +// --- IRC command handling ---------------------------------------------------- + +// XXX: ap doesn't really need to be a slice. +func (c *client) makeReply(id int, ap []interface{}) string { + nickname := c.nickname + if nickname == "" { + nickname = "*" + } + + s := fmt.Sprintf(":%s %03d %s ", serverName, id, nickname) + a := fmt.Sprintf(defaultReplies[id], ap...) + return s + a +} + +// XXX: This way we cannot typecheck the arguments, so we must 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). + 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]...) + + // 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 isThisMe(target string) bool { + // Target servers can also be matched by their users + if ircFnmatch(target, serverName) { + return true + } + _, ok := users[ircStrxfrm(target)] + return ok +} + +func (c *client) sendISUPPORT() { + // Only # channels, +e supported, +I supported, unlimited arguments to MODE + c.sendReply(RPL_ISUPPORT, "CHANTYPES=# EXCEPTS INVEX MODES"+ + " TARGMAX=WHOIS:,LIST:,NAMES:,PRIVMSG:1,NOTICE:1,KICK:"+ + " NICKLEN=%d CHANNELLEN=%d", ircMaxNickname, ircMaxChannelName) +} + +func (c *client) tryFinishRegistration() { + // TODO: Check if the realname is really required. + if c.nickname == "" || c.username == "" || c.realname == "" { + return + } + if c.registered || c.capNegotiating { + return + } + + c.registered = true + c.sendReply(RPL_WELCOME, c.nickname, c.username, c.hostname) + + c.sendReply(RPL_YOURHOST, serverName, "TODO version") + // The purpose of this message eludes me. + c.sendReply(RPL_CREATED, time.Unix(started, 0).Format("Mon, 02 Jan 2006")) + c.sendReply(RPL_MYINFO, serverName, "TODO version", + 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, ircStrxfrm(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() +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// TODO: Beware of case sensitivity, probably need to index it by ircStrxfrm, +// which should arguably be named ircToLower and ircToUpper or something. +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 operato on +// global state, though. + +func ircHandleCAP(msg *message, c *client) { + if len(msg.params) < 1 { + c.sendReply(ERR_NEEDMOREPARAMS, msg.command) + return + } + + // TODO: This really does seem to warrant a method. + nickname := c.nickname + if nickname == "" { + nickname = "*" + } + + args := &ircCapArgs{ + target: nickname, + subcommand: msg.params[0], + fullParams: "", + params: []string{}, + } + + if len(msg.params) > 1 { + args.fullParams = msg.params[1] + // TODO: ignore_empty, likely create SplitSkipEmpty + args.params = strings.Split(args.fullParams, " ") + } + + // FIXME: We should ASCII ToUpper the subcommand. + if fn, ok := ircCapHandlers[ircStrxfrm(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 + } + + nicknameNormalized := ircStrxfrm(nickname) + if client, ok := users[nicknameNormalized]; 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) + ircSendToRoommates(c, message) + c.send(message) + } + + // Release the old nickname and allocate a new one. + if c.nickname != "" { + delete(users, ircStrxfrm(c.nickname)) + } + + c.nickname = nickname + users[nicknameNormalized] = 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 + } + + // TODO +} + +func ircHandleLUSERS(msg *message, c *client) { + if len(msg.params) > 1 && !isThisMe(msg.params[1]) { + c.sendReply(ERR_NOSUCHSERVER, msg.params[1]) + return + } + c.sendLUSERS() +} + +func ircHandleMOTD(msg *message, c *client) { + if len(msg.params) > 0 && !isThisMe(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 && !isThisMe(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 + // TODO +} + +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 && !isThisMe(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 && !isThisMe(msg.params[0]) { + c.sendReply(ERR_NOSUCHSERVER, msg.params[0]) + return + } + + postVersion := 0 + if debugMode { + postVersion = 1 + } + + c.sendReply(RPL_VERSION, "TODO version", postVersion, serverName, + "TODO program name"+" "+"TODO version") + c.sendISUPPORT() +} + +/* +func ircChannelMulticast(ch *channel, msg string, except *client) { + for c, m := 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) { + // TODO: Port, as well as all the other kike functions. +} +*/ + +func ircHandleMODE(msg *message, c *client) { + if len(msg.params) < 1 { + c.sendReply(ERR_NEEDMOREPARAMS, msg.command) + return + } + + // TODO + target := msg.params[0] + client := users[ircStrxfrm(target)] + ch := users[ircStrxfrm(target)] + + if client != nil { + // TODO: Think about strcmp. + //if ircStrcmp(target, c.nickname) != 0 { + //} + } else if ch != nil { + // TODO + } +} + +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] + if client, ok := users[ircStrxfrm(target)]; ok { + // TODO + _ = client + _ = text + } else if ch, ok := channels[ircStrxfrm(target)]; ok { + // TODO + _ = ch + } else { + c.sendReply(ERR_NOSUCHNICK, target) + } +} + +// TODO: All the various real command handlers. + +func ircHandleX(msg *message, c *client) { +} + +// --- ? ----------------------------------------------------------------------- + +// Handle the results from initializing the client's connection. +func (c *client) onPrepared(host string, isTLS bool) { + if isTLS { + c.tls = tls.Server(c.transport, tlsConf) + c.conn = c.tls + } else { + c.conn = c.transport.(connCloseWrite) + } + + c.hostname = host + c.address = net.JoinHostPort(host, c.port) + + // TODO: If we've tried to send any data before now, we need to flushOutQ. + go read(c) + c.reading = true +} + +// 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.inQ = append(c.inQ, data...) + for { + // XXX: This accepts even simple LF newlines, even though they're not + // really allowed by the protocol. + advance, token, _ := bufio.ScanLines(c.inQ, false /* atEOF */) + if advance == 0 { + break + } + + c.inQ = c.inQ[advance:] + line := string(token) + log.Printf("-> %s\n", line) + + m := reMsg.FindStringSubmatch(line) + if m == nil { + log.Println("error: invalid line") + continue + } + + msg := message{m[1], m[2], m[3], m[4], nil} + for _, x := range reArgs.FindAllString(m[5], -1) { + msg.params = append(msg.params, x[1:]) + } + + broadcast(line, c) + } + + if readErr != nil { + c.reading = false + + if readErr != io.EOF { + log.Println(readErr) + c.destroy("TODO") + } else if c.closing { + // Disregarding whether a clean shutdown has happened or not. + log.Println("client finished shutdown") + c.destroy("TODO") + } else { + log.Println("client EOF") + c.closeLink("") + } + } else if len(c.inQ) > 8192 { + log.Println("client inQ overrun") + c.closeLink("inQ 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 outQ if possible and necessary. +func (c *client) flushOutQ() { + if !c.writing && c.conn != nil { + go write(c, c.outQ) + c.writing = true + } +} + +// Handle the results from trying to write to the client connection. +func (c *client) onWrite(written int, writeErr error) { + c.outQ = c.outQ[written:] + c.writing = false + + if writeErr != nil { + log.Println(writeErr) + c.destroy("TODO") + } else if len(c.outQ) > 0 { + c.flushOutQ() + } else if c.closing { + if c.reading { + c.conn.CloseWrite() + } else { + c.destroy("TODO") + } + } +} + +// --- Worker goroutines ------------------------------------------------------- + +func accept(ln net.Listener) { + for { + if conn, err := ln.Accept(); err != nil { + // TODO: Consider specific cases in error handling, some errors + // are transitional while others are fatal. + log.Println(err) + break + } else { + conns <- conn + } + } +} + +func prepare(client *client) { + conn := client.transport + host, _, err := net.SplitHostPort(conn.RemoteAddr().String()) + if err != nil { + // In effect, we require TCP/UDP, as they have port numbers. + log.Fatalln(err) + } + + // 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 { + log.Println(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. + log.Println(err) + } else { + isTLS = detectTLS(sysconn) + } + + // FIXME: When the client sends no data, we still initialize its conn. + 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 outQ, 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} +} + +// --- Main -------------------------------------------------------------------- + +func processOneEvent() { + select { + case <-sigs: + if inShutdown { + forceShutdown("requested by user") + } else { + initiateShutdown() + } + + case <-shutdownTimer: + forceShutdown("timeout") + + case conn := <-conns: + log.Println("accepted client connection") + + // In effect, we require TCP/UDP, as they have port numbers. + address := conn.RemoteAddr().String() + host, port, err := net.SplitHostPort(address) + if err != nil { + log.Fatalln(err) + } + + c := &client{ + transport: conn, + address: address, + hostname: host, + port: port, + } + clients[c] = true + go prepare(c) + + case ev := <-prepared: + log.Println("client is ready:", ev.host) + if _, ok := clients[ev.client]; ok { + ev.client.onPrepared(ev.host, ev.isTLS) + } + + case ev := <-reads: + log.Println("received data from client") + if _, ok := clients[ev.client]; ok { + ev.client.onRead(ev.data, ev.err) + } + + case ev := <-writes: + log.Println("sent data to client") + if _, ok := clients[ev.client]; ok { + ev.client.onWrite(ev.written, ev.err) + } + + case c := <-timeouts: + if _, ok := clients[c]; ok { + log.Println("client timeouted") + c.destroy("TODO") + } + } +} + +func main() { + flag.BoolVar(&debugMode, "debug", false, "debug mode") + version := flag.Bool("version", false, "show version and exit") + flag.Parse() + + // TODO: Consider using the same version number for all subprojects. + if *version { + fmt.Printf("%s %s\n", "hid", "0") + return + } + + // TODO: Configuration--create an INI parser, probably; + // lift XDG_CONFIG_HOME from gitlab-notifier. + if len(flag.Args()) != 3 { + log.Fatalf("usage: %s KEY CERT ADDRESS\n", os.Args[0]) + } + + cert, err := tls.LoadX509KeyPair(flag.Arg(1), flag.Arg(0)) + if err != nil { + log.Fatalln(err) + } + + tlsConf = &tls.Config{ + Certificates: []tls.Certificate{cert}, + ClientAuth: tls.RequestClientCert, + SessionTicketsDisabled: true, + } + listener, err = net.Listen("tcp", flag.Arg(2)) + if err != nil { + log.Fatalln(err) + } + + go accept(listener) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + + for !inShutdown || len(clients) > 0 { + processOneEvent() + } +} |