xK/xS/xS.go

3400 lines
83 KiB
Go
Raw Permalink Normal View History

//
// 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.
//
2022-09-26 12:23:58 +02:00
// 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"
)
const projectName = "xS"
var projectVersion = "?"
var debugMode = false
// --- 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.
//
2022-09-26 12:23:58 +02:00
// 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:
2018-08-04 21:13:28 +02:00
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.
2018-08-06 12:31:31 +02:00
func writeFile(path string, data []byte) error {
if dir := filepath.Dir(path); dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
}
2018-08-06 12:31:31 +02:00
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.
2018-08-06 12:31:31 +02:00
func writeFileSafe(path string, data []byte) error {
temp := path + ".new"
if err := writeFile(temp, data); err != nil {
return err
}
2018-08-06 12:31:31 +02:00
return os.Rename(temp, path)
}
// --- Simple configuration ----------------------------------------------------
2018-08-06 12:31:31 +02:00
// 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"
2018-08-06 12:31:31 +02:00
path := resolveFilename(basename, resolveRelativeConfigFilename)
if path == "" {
return &os.PathError{
Op: "cannot find",
Path: basename,
Err: os.ErrNotExist,
}
}
2018-08-06 12:31:31 +02:00
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 {
2018-08-06 12:31:31 +02:00
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)
}
2022-09-26 12:23:58 +02:00
// 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,
``,
``,
}
2018-08-06 12:31:31 +02:00
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 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 @]+$`)
2018-07-31 21:13:30 +02:00
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
}
2018-08-03 21:12:45 +02:00
// TODO: Maybe we want to keep it in a struct?
// A better question might be: can we run multiple instances of it?
var (
2018-08-03 21:12:45 +02:00
// network
2018-08-03 21:12:45 +02:00
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
2018-07-31 21:13:30 +02:00
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
2018-08-03 21:12:45 +02:00
// 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)
2018-08-01 17:49:27 +02:00
timers = make(chan func())
2018-08-03 21:12:45 +02:00
// 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,
2018-07-31 21:13:30 +02:00
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) -----------------------------------------------------
2018-08-06 20:31:22 +02:00
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) {