Last active
June 23, 2020 17:21
-
-
Save mndrix/7947009178e4a18c247b4bd25821661f to your computer and use it in GitHub Desktop.
Family IRC server
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// My small, family IRC server | |
// | |
// This file won't compile by itself because it's only one file from | |
// my larger family server (movie hosting, Asterisk dialplan, Git | |
// hosting, personal assistant, etc). | |
// | |
// Users authenticate via NICK and PASS. The USER is interpreted as a | |
// "device" name. That allows each user to connect from multiple | |
// devices simultaneously while still appearing as only one nick in | |
// channels. Each nick-device combo has a queue of messages which | |
// have not yet been delivered. This allows users to disconnect and | |
// receive their messages when they reconnect. | |
// | |
// IRC doesn't have a built in ACK mechanism, so we fake it with PING | |
// messages. After each PRIVMSG, we send a PING which identifies the | |
// previous PRIVMSG. Since TCP guarantees in-order arrival, if we | |
// receive the PONG, we know the client recieved the message too. | |
// It's a hack, but it allows messages to be queued when users are | |
// offline. | |
// | |
// This file doesn't include the code for our SMS gateway or our | |
// family IRC bot. | |
// | |
// Copyright 2018 Michael Hendricks | |
// | |
// Permission is granted to do anything with this work for any purpose | |
// with or without fee, and with or without attribution. | |
package main | |
import ( | |
"bytes" | |
"errors" | |
"fmt" | |
"io" | |
"log" | |
"net" | |
"runtime/debug" | |
"strconv" | |
"strings" | |
"sync" | |
"time" | |
"github.com/mndrix/rand" | |
) | |
// configuration goes here | |
const serverHostname = `example.org` | |
const ircTimeout = 5 * time.Minute | |
var ircAuth = map[string]string{ | |
"alice": "secret password", | |
"bob": "another password", | |
"charles": "more secrets", | |
"david": "running out of secrets", | |
} | |
var routeMap = map[string]func(*client, Message) error{ | |
"JOIN": commandJoin, | |
"PASS": commandPass, | |
"PING": commandPing, | |
"PONG": commandPong, | |
"PRIVMSG": commandPrivmsg, | |
"WHOIS": commandWhois, | |
} | |
type IrcServer struct { | |
sync.Mutex | |
clients Clients // all connected clients | |
} | |
type NetConn struct { | |
conn net.Conn | |
buf []byte | |
} | |
type IrcConn interface { | |
Addr() string // connection's address (IP:port, etc) | |
Close() error // close connection (noop if already closed) | |
Send(Message) error // transmit IRC message to connection | |
Recv() (Message, bool) // read next IRC message from connection | |
} | |
type client struct { | |
conn IrcConn | |
active time.Time // last time client was active | |
device string // name of the device ("phone", "laptop", etc) | |
latency time.Duration // latency of most recent ping; -1 if unreachable | |
name string // user's real name | |
nick string // user's authenticated nickname | |
queue []Message // messages sent but not yet acknowledged | |
sentPing time.Time // time of the most recent, outgoing ping | |
tags map[string]bool // tags set on this client | |
} | |
type Clients map[*client]bool | |
type Message struct { | |
command string | |
from *client | |
id string | |
line string | |
params []string | |
time time.Time | |
omitPrefix bool | |
} | |
var ircServer = &IrcServer{clients: map[*client]bool{}} | |
func ircListen() { | |
listener, err := net.Listen("tcp", ":6667") | |
if err != nil { | |
panic(err) | |
} | |
defer listener.Close() | |
log.Printf("IRC listening on %s", listener.Addr()) | |
go ircBot() | |
for { | |
conn, err := listener.Accept() | |
if err != nil { | |
panic(err) | |
} | |
go newClient(&NetConn{conn: conn}).handle() | |
} | |
} | |
func (srv *IrcServer) Clients() Clients { | |
srv.Lock() | |
defer srv.Unlock() | |
return srv.clients.Keep(func(*client) bool { return true }) | |
} | |
func (srv *IrcServer) addClient(cl *client) { | |
srv.Lock() | |
defer srv.Unlock() | |
// does this user-device combo already have connections? | |
cl.queue = nil | |
after := cl.conn.Addr() | |
remove := make([]*client, 0, len(srv.clients)) | |
for c := range srv.clients { | |
if c.nick == cl.nick && c.device == cl.device { | |
before := "(closed)" | |
if c.conn != nil { | |
before = c.conn.Addr() | |
} | |
log.Printf("Replacing client %s with %s", before, after) | |
cl.queue = append(cl.queue, c.queue...) | |
for tag := range c.tags { | |
cl.setTag(tag) | |
} | |
srv.closeClient(c) | |
remove = append(remove, c) | |
} | |
} | |
for _, c := range remove { | |
delete(srv.clients, c) | |
} | |
srv.clients[cl] = true | |
} | |
func (srv *IrcServer) closeClient(cl *client) { | |
if err := recover(); err != nil { | |
log.Printf("PANIC: %s, %s", err, debug.Stack()) | |
} | |
if cl.conn != nil { | |
err := cl.conn.Close() | |
if err == nil { | |
log.Printf("Server closed connection") | |
} else { | |
log.Printf("Error closing connection: %s", err) | |
} | |
cl.conn = nil | |
} | |
} | |
func (conn *NetConn) Addr() string { return conn.conn.RemoteAddr().String() } | |
func (conn *NetConn) Close() error { return conn.conn.Close() } | |
func (conn *NetConn) Send(msg Message) error { | |
if conn.conn == nil { | |
return nil | |
} | |
_, err := conn.conn.Write([]byte(msg.String())) | |
return err | |
} | |
func newClient(conn IrcConn) *client { | |
return &client{ | |
conn: conn, | |
tags: make(map[string]bool), | |
} | |
} | |
func (cl *client) next() (msg Message, ok bool) { | |
msg, ok = cl.conn.Recv() | |
if ok { | |
if msg.command != "x-timeout" { | |
cl.active = time.Now() | |
log.Printf("%s -> %q", cl.logNick(), msg.line) | |
} | |
if time.Since(cl.sentPing) >= ircTimeout { | |
cl.ping(serverHostname) | |
} | |
} | |
return | |
} | |
func (cl *NetConn) Recv() (msg Message, ok bool) { | |
// do we already have a full message in the buffer? | |
n := 0 | |
if msg, n = ParseMessage(cl.buf); n > 0 { | |
cl.buf = cl.buf[n:] | |
ok = true | |
return | |
} | |
// nope. read more data into the buffer | |
cl.conn.SetReadDeadline(time.Now().Add(ircTimeout)) | |
data := make([]byte, 1024) | |
n, err := cl.conn.Read(data) | |
if n == 0 && err == nil { | |
err = errors.New("no data available to read") | |
} | |
if neterr, ok := err.(net.Error); ok && neterr.Timeout() { | |
return Message{command: "x-timeout"}, true | |
} | |
switch err { | |
case nil: | |
cl.buf = append(cl.buf, data[0:n]...) | |
return cl.Recv() | |
case io.EOF: | |
log.Printf("%s -> Client disconnected", cl.Addr()) | |
default: | |
log.Printf("%s -> next(): %s (%#v)", cl.Addr(), err, err) | |
} | |
return | |
} | |
func (cl *client) resend(tag string) error { | |
// resend messages the client missed while away | |
seen := make(map[string]bool, len(cl.queue)) | |
for _, msg := range cl.queue { | |
if !seen[msg.id] && msg.command == "PRIVMSG" && tag == msg.params[0] { | |
seen[msg.id] = true | |
err := cl.Send(msg) | |
if err == nil { | |
err = cl.ping(msg.id) | |
} | |
if err != nil { | |
return err | |
} | |
} | |
} | |
return nil | |
} | |
func (cl *client) send(cmd string, args ...string) error { | |
return cl.Send(NewMessage(cmd, args...)) | |
} | |
// Send transmits a single message to the client. | |
func (cl *client) Send(msg Message) error { | |
switch msg.command { | |
case "PING", "PRIVMSG": | |
cl.queue = append(cl.queue, msg) | |
} | |
log.Printf("%s <- %q", cl.logNick(), msg.String()) | |
if cl.conn == nil { | |
return nil | |
} | |
return cl.conn.Send(msg) | |
} | |
func (cl *client) ping(id string) error { | |
cl.sentPing = time.Now() | |
return cl.send("PING", id) | |
} | |
func (cl *client) logNick() string { | |
if cl.nick == "" { | |
return "?" | |
} | |
return cl.nick | |
} | |
func (cl *client) hasTag(tag string) bool { | |
_, ok := cl.tags[tag] | |
return ok | |
} | |
func (cl *client) setTag(tag string) { | |
cl.tags[tag] = true | |
} | |
func (cl *client) handle() { | |
defer ircServer.closeClient(cl) | |
log.Printf("Client connected from %s", cl.conn.Addr()) | |
cl.sentPing = time.Now() // pretend that we sent a ping | |
cl.send("NOTICE", "*", "Hi") | |
for { | |
msg, ok := cl.next() | |
if !ok { | |
return | |
} | |
if fn, ok := routeMap[msg.command]; ok { | |
if err := fn(cl, msg); err != nil { | |
log.Printf("callback error: %s", err) | |
return | |
} | |
} | |
} | |
} | |
func registrationDone(cl *client) error { | |
// announce that registration is done | |
sends := []func() error{ | |
func() error { return cl.send("001", cl.nick, "Welcome") }, | |
func() error { | |
return cl.send( | |
"004", cl.nick, | |
serverHostname, | |
"0.0.1", // version | |
"ov", // user modes | |
"bklm", // channel modes | |
) | |
}, | |
func() error { | |
return cl.send( | |
"005", cl.nick, | |
"CHANTYPES=#", | |
"CHANMODES=b,k,l,m", | |
"PREFIX=(ov)@+", | |
"are supported", | |
) | |
}, | |
func() error { return cl.send("422", cl.nick, "No message today") }, | |
func() error { return cl.resend(cl.nick) }, | |
func() error { return cl.ping(serverHostname) }, | |
} | |
for _, send := range sends { | |
err := send() | |
if err != nil { | |
return err | |
} | |
} | |
return nil | |
} | |
func commandPass(cl *client, msg Message) error { | |
password := msg.params[0] | |
username := "" | |
device := "" | |
name := "" | |
for username == "" || device == "" { | |
msg, ok := cl.next() | |
if !ok { | |
return errors.New("commandPass: trouble reading") | |
} | |
if msg.command == "NICK" { | |
username = msg.params[0] | |
} else if msg.command == "USER" { | |
device = msg.params[0] | |
name = msg.params[3] | |
} | |
} | |
// verify credentials | |
correctPassword, ok := ircAuth[username] | |
if ok && password == correctPassword { | |
cl.nick = username | |
cl.setTag(username) | |
cl.device = device | |
cl.name = name | |
ircServer.addClient(cl) | |
return registrationDone(cl) | |
} | |
return cl.send("464", "*", "Wrong username or password") | |
} | |
func commandPing(cl *client, msg Message) error { | |
return cl.send("PONG", serverHostname, msg.params[0]) | |
} | |
func commandPong(cl *client, msg Message) error { | |
// keep messages that have not yet been acknowledged | |
keep := make([]Message, 0, len(cl.queue)) | |
id := msg.params[0] | |
for _, msg := range cl.queue { | |
if msg.id == id { | |
// message acknowledged. don't keep it | |
} else if msg.command == "PING" && msg.params[0] == id { | |
cl.latency = time.Since(msg.time) | |
// ping acknowledged. don't keep it | |
} else { | |
keep = append(keep, msg) | |
} | |
} | |
log.Printf(" acknowledged %d messages", len(cl.queue)-len(keep)) | |
cl.queue = keep | |
log.Printf(" latency %s", cl.latency) | |
return nil | |
} | |
func commandJoin(cl *client, req Message) error { | |
channelName := req.params[0] | |
if strings.Contains(channelName, ",") { | |
for _, name := range strings.Split(channelName, ",") { | |
redo := req | |
redo.params[0] = name | |
err := commandJoin(cl, redo) | |
if err != nil { | |
return err | |
} | |
} | |
return nil | |
} | |
if !strings.HasPrefix(channelName, "#") { | |
return errors.New("TODO send invalid group error to client") | |
} | |
// notify channel about the new member | |
msg := NewMessage("JOIN", channelName).From(cl) | |
members := ircServer.Clients().Tagged(channelName) | |
if len(members.Tagged(cl.nick)) == 0 { | |
// nick not yet in channel | |
members.Send(msg) | |
} | |
// this client now belongs to this channel | |
cl.setTag(channelName) | |
cl.Send(msg) | |
// provide channel details to the new participant | |
nicks := members.UniqueNicks(cl.nick) | |
cl.send("332", cl.nick, channelName, "Discuss "+channelName) | |
cl.send("353", cl.nick, "=", channelName, strings.Join(nicks, " ")) | |
cl.send("366", cl.nick, channelName, "End of /NAMES list") | |
cl.resend(channelName) | |
return nil | |
} | |
func commandPrivmsg(cl *client, req Message) error { | |
target := req.params[0] | |
text := req.params[1] | |
if text == "" { | |
return cl.send("412", cl.nick, "No text to send") | |
} | |
// who gets a copy of the message? | |
recipients := ircServer.Clients().Tagged(target) | |
if len(recipients) == 0 { | |
if phone, ok := ParsePhone(target); ok && cl.nick == "michael" { | |
go SmsSend(req.From(cl), phone, text) | |
return nil | |
} else { | |
return cl.send("401", cl.nick, target, "No such nick/channel") | |
} | |
} | |
recipients = recipients.Except(cl) | |
// send the actual message | |
res := NewMessage("PRIVMSG", target, text).From(cl) | |
err := recipients.Send(res) | |
if err != nil { | |
return err | |
} | |
// conclude with a ping to help guess whether the PRIVMSG got through | |
for c := range recipients { | |
go c.ping(res.id) | |
} | |
return nil | |
} | |
func commandWhois(cl *client, msg Message) error { | |
nick := msg.params[0] | |
found := ircServer.Clients().Tagged(nick).First() | |
if found == nil { | |
cl.send("401", cl.nick, nick, "No such nick/channel") | |
return nil | |
} | |
// calculate useful details about the user | |
idle := fmt.Sprintf("%.0f", time.Since(found.active).Seconds()+1) | |
latency := found.latency.Round(10 * time.Millisecond).String() | |
if found.latency.Seconds() > 1 { | |
latency = found.latency.Round(100 * time.Millisecond).String() | |
} | |
if found.latency < 0 { | |
latency = "unreachable" | |
} | |
channels := make([]string, 0, len(found.tags)) | |
for tag := range found.tags { | |
if strings.HasPrefix(tag, "#") { | |
channels = append(channels, tag) | |
} | |
} | |
// send response | |
cl.send("311", cl.nick, nick, found.device, serverHostname, "*", found.name) | |
cl.send("312", cl.nick, nick, serverHostname, "ping "+latency) | |
cl.send("317", cl.nick, nick, idle, "seconds idle") | |
cl.send("319", cl.nick, nick, strings.Join(channels, " ")) | |
cl.send("318", cl.nick, nick, "End of /WHOIS list") | |
// update latency info for target of WHOIS | |
if target := ircServer.Clients().Tagged(nick).First(); target != nil { | |
target.ping(serverHostname) | |
} | |
return nil | |
} | |
func (cls Clients) Keep(f func(*client) bool) Clients { | |
out := make(Clients, len(cls)) | |
for cl := range cls { | |
if f(cl) { | |
out[cl] = true | |
} | |
} | |
return out | |
} | |
func (cls Clients) Except(drop *client) Clients { | |
return cls.Keep(func(cl *client) bool { return cl != drop }) | |
} | |
func (cls Clients) First() *client { | |
for cl := range cls { | |
return cl | |
} | |
return nil | |
} | |
func (cls Clients) Tagged(tag string) Clients { | |
return cls.Keep(func(cl *client) bool { return cl.hasTag(tag) }) | |
} | |
func (recipients Clients) Send(msg Message) (err error) { | |
// start all deliveries | |
errCh := make(chan error) | |
for recipient := range recipients { | |
go func(r *client) { errCh <- r.Send(msg) }(recipient) | |
} | |
// wait for them all to conclude | |
outstanding := len(recipients) | |
for outstanding > 0 { | |
if e := <-errCh; e != nil && err == nil { | |
err = e | |
} | |
outstanding-- | |
} | |
return | |
} | |
func (cls Clients) UniqueNicks(nicks ...string) []string { | |
seen := make(map[string]bool, len(cls)+len(nicks)) | |
for _, nick := range nicks { | |
seen[nick] = true | |
} | |
for cl := range cls { | |
if !seen[cl.nick] { | |
nicks = append(nicks, cl.nick) | |
seen[cl.nick] = true | |
} | |
} | |
return nicks | |
} | |
func NewMessage(command string, params ...string) Message { | |
return Message{ | |
id: strconv.FormatInt(rand.Int63(), 36), | |
command: command, | |
params: params, | |
time: time.Now(), | |
} | |
} | |
// returns a message and the number of bytes consumed, or 0 if a full | |
// line is not present | |
func ParseMessage(data []byte) (msg Message, i int) { | |
// extract the first complete line | |
i = bytes.Index(data, []byte("\n")) | |
if i < 0 { | |
i = 0 | |
return | |
} | |
data = bytes.TrimRight(data[0:i], "\r") | |
msg.line = string(data) | |
i++ | |
// extract the command and other parameters | |
words := bytes.Split(data, []byte{' '}) | |
for n, word := range words { | |
if msg.command == "" { | |
msg.command = string(word) | |
continue | |
} | |
if len(word) == 0 { // bad client | |
continue | |
} | |
if word[0] == ':' { // reassemble final param | |
word = bytes.Join(words[n:], []byte{' '}) | |
msg.params = append(msg.params, string(word[1:])) | |
break | |
} | |
msg.params = append(msg.params, string(word)) | |
} | |
return | |
} | |
func (msg Message) String() string { | |
parts := make([]string, 0, 2+len(msg.params)) | |
if msg.command == "PING" || msg.omitPrefix { | |
// prefix must be absent | |
} else if cl := msg.from; cl == nil { | |
parts = append(parts, ":"+serverHostname) | |
} else { | |
prefix := fmt.Sprintf(":%s!%s@%s", cl.nick, cl.device, serverHostname) | |
parts = append(parts, prefix) | |
} | |
parts = append(parts, msg.command) | |
if len(msg.params) > 0 { | |
parts = append(parts, msg.params...) | |
parts[len(parts)-1] = ":" + parts[len(parts)-1] | |
} | |
return strings.Join(parts, " ") + "\r\n" | |
} | |
func (msg Message) From(cl *client) Message { | |
msg.from = cl | |
return msg | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment