Skip to content

Instantly share code, notes, and snippets.

@mndrix
Last active June 23, 2020 17:21
Show Gist options
  • Save mndrix/7947009178e4a18c247b4bd25821661f to your computer and use it in GitHub Desktop.
Save mndrix/7947009178e4a18c247b4bd25821661f to your computer and use it in GitHub Desktop.
Family IRC server
// 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