Implementation 1 of a minimalistic IRC server

This commit is contained in:
2025-09-26 20:36:54 -07:00
commit d340bc49da
18 changed files with 1082 additions and 0 deletions

11
src/irc/commands/all.go Normal file
View File

@@ -0,0 +1,11 @@
package commands
import (
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/irc/world"
)
func HandleCommands(msg world.WrappedMessage) {
handleAuthCommands(msg)
handleJoinPartCommands(msg)
handlePrivmsgNotifyCommands(msg)
}

84
src/irc/commands/auth.go Normal file
View File

@@ -0,0 +1,84 @@
package commands
import (
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/irc/users"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/irc/world"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/transport"
)
func handleAuthCommands(msg world.WrappedMessage) {
handleNickAndUser(msg)
completeHandshakeIfPossible(msg)
}
func handleNickAndUser(msg world.WrappedMessage) {
if msg.Sender.GetHasReceivedAuthHandshakeReply() {
// TODO: Send an error reply
return
}
if msg.Content.Command == "NICK" {
args := msg.Content.Arguments
if len(args) != 1 {
// TODO: Send an error reply
return
}
nick := args[0]
validNick, err := users.ValidateNick(nick)
if err != nil {
// TODO: Send an error reply
return
}
err = msg.Sender.SetNick(&validNick)
if err != nil {
msg.World.Server.TerminateClient(msg.Sender.GetClientId(), err)
}
}
if msg.Content.Command == "USER" {
args := msg.Content.Arguments
if len(args) != 4 {
// TODO: Send an error reply
return
}
username := args[0]
zero := args[1]
star := args[2]
realName := args[3]
if zero != "0" || star != "*" {
// TODO: Send an error reply
return
}
msg.Sender.SetUsername(&username)
msg.Sender.SetRealName(&realName)
// TODO: Validation? I wonder if it matters.
}
}
func completeHandshakeIfPossible(msg world.WrappedMessage) {
sender := msg.Sender
if msg.Sender.GetHasReceivedAuthHandshakeReply() {
return
}
isReady := sender.GetNick() != nil && sender.GetUsername() != nil && sender.GetRealName() != nil
if !isReady {
return
}
sender.SetHasReceivedAuthHandshakeReply(true)
msg.World.Server.SendMessage(sender.GetClientId(), transport.Content{
Command: "NICK",
Arguments: []string{sender.GetNick().Value},
})
msg.World.Server.SendMessage(msg.Sender.GetClientId(), transport.Content{
Command: "USER",
Arguments: []string{*sender.GetUsername(), "0", "*", *sender.GetRealName()},
})
}

View File

@@ -0,0 +1,59 @@
package commands
import (
"strings"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/irc/users"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/irc/world"
)
func handleJoinPartCommands(msg world.WrappedMessage) {
if msg.Content.Command == "JOIN" {
if len(msg.Content.Arguments) != 1 {
// TODO: Wrong number of arguments
return
}
channelsToJoin := parseChannelList(msg.Content.Arguments[0])
for _, channel := range channelsToJoin {
err := msg.Sender.Join(channel)
if err != nil {
msg.World.Server.TerminateClient(msg.Sender.GetClientId(), err)
return
}
msg.World.RelayToChannel(msg, channel, nil)
}
}
if msg.Content.Command == "PART" {
n := len(msg.Content.Arguments)
if !(n == 1 || n == 2) {
return
}
channelsToPart := parseChannelList(msg.Content.Arguments[0])
for _, channel := range channelsToPart {
err := msg.Sender.Part(channel)
if err != nil {
msg.World.Server.TerminateClient(msg.Sender.GetClientId(), err)
return
}
msg.World.RelayToChannel(msg, channel, nil)
// the user won't see their own #part because they left, so send it
msg.World.RelayToClient(msg, msg.Sender.GetClientId(), nil)
}
}
}
func parseChannelList(arg string) []users.ChannelName {
var channels []users.ChannelName
for _, channelName := range strings.Split(arg, ",") {
validChannel, err := users.ValidateChannelName(channelName)
if err != nil { // can't join, not a channel
continue
}
channels = append(channels, validChannel)
}
return channels
}

View File

@@ -0,0 +1,21 @@
package commands
import (
"log"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/irc/world"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/transport"
)
func handlePrivmsgNotifyCommands(msg world.WrappedMessage) {
if msg.Content.Command == "PRIVMSG" || msg.Content.Command == "NOTIFY" || msg.Content.Command == "CTCP" {
log.Printf("message-like command")
if len(msg.Content.Arguments) == 0 {
// TODO: Error reply
return
}
// Was this message to a user?
msg.World.RelayToVagueDestination(msg, msg.Content.Arguments[0], []transport.ClientId{msg.Sender.GetClientId()})
}
}

7
src/irc/errors/errors.go Normal file
View File

@@ -0,0 +1,7 @@
package errors
import "fmt"
var ErrAlreadyInChannel = fmt.Errorf("already in channel")
var ErrNickAlreadyInUse = fmt.Errorf("nick already in use")
var ErrNotInChannel = fmt.Errorf("not in channel")

61
src/irc/identifiers.go Normal file
View File

@@ -0,0 +1,61 @@
package irc
import (
"fmt"
"regexp"
"strings"
)
var ErrNotANick = fmt.Errorf("does not look like a nickname")
var regexpNick = regexp.MustCompile("^[a-zA-Z0-9]+$") // NOTE: more constrained than real character set
type Nick string
type CanonicalNick string
func ValidateNick(s string) (Nick, error) {
// TODO: Fail if the string doesn't look like a nick
if !regexpNick.MatchString(s) {
return "", fmt.Errorf("%w: %s", ErrNotANick, s)
}
return Nick(s), nil
}
func (n Nick) Canonize() CanonicalNick {
return CanonicalNick(strings.ToLower(string(n)))
}
func (n *Nick) CanonizeNullable() *CanonicalNick {
if n == nil {
return nil
}
result := n.Canonize()
return &result
}
var ErrNotAChannel = fmt.Errorf("does not look like a channel name")
var regexpChannel = regexp.MustCompile("^#[a-zA-Z0-9]+$") // NOTE: more constrained than real character set
type Channel string
type CanonicalChannel string
func ValidateChannel(s string) (Channel, error) {
// TODO: Fail if the string doesn't look like a channel name
if !regexpChannel.MatchString(s) {
return "", fmt.Errorf("%w: %s", ErrNotAChannel, s)
}
return Channel(s), nil
}
func (c Channel) Canonize() CanonicalChannel {
return CanonicalChannel(strings.ToLower(string(c)))
}
func (c *Channel) CanonizeNullable() *CanonicalChannel {
if c == nil {
return nil
}
result := c.Canonize()
return &result
}

25
src/irc/main.go Normal file
View File

@@ -0,0 +1,25 @@
package irc
import (
"log"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/irc/commands"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/irc/world"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/transport"
)
func ServeIrc(server *transport.Server) {
world := world.NewWorld(server)
for {
rawMessage, err := server.ReceiveMessage()
if err != nil {
log.Println("failed to receive message: %w")
return
}
wrappedMessage := world.Wrap(rawMessage)
commands.HandleCommands(wrappedMessage)
}
}

183
src/irc/users/system.go Normal file
View File

@@ -0,0 +1,183 @@
package users
import (
"fmt"
"slices"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/irc/errors"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/transport"
)
type UserId uint64
type UsersSystem struct {
clientIdIndex map[transport.ClientId]*User
nickIndex map[canonicalNick]*User
channelNameIndex map[canonicalChannelName](map[*User]struct{})
}
type User struct {
users *UsersSystem
clientId transport.ClientId
nick *Nick
username *string
realName *string
hasReceivedAuthHandshakeReply bool
channels []ChannelName
}
func NewUsersSystem() *UsersSystem {
return &UsersSystem{
clientIdIndex: make(map[transport.ClientId]*User),
nickIndex: make(map[canonicalNick]*User),
channelNameIndex: make(map[canonicalChannelName]map[*User]struct{}),
}
}
func (users *UsersSystem) ByClientIdOrCreate(clientId transport.ClientId) *User {
existing, ok := users.clientIdIndex[clientId]
if ok {
return existing
}
user := &User{
users: users,
clientId: clientId,
nick: nil,
username: nil,
realName: nil,
hasReceivedAuthHandshakeReply: false,
channels: nil,
}
users.clientIdIndex[clientId] = user
return user
}
func (users *UsersSystem) ByNick(nick Nick) *User {
return users.nickIndex[nick.canonical]
}
func (users *UsersSystem) ByChannel(channelName ChannelName) map[*User]struct{} {
return users.channelNameIndex[channelName.canonical]
}
func (user *User) GetClientId() transport.ClientId {
return user.clientId
}
func (user *User) GetNick() *Nick {
return user.nick
}
func (user *User) SetNick(newNick *Nick) error {
users := user.users
oldNick := user.nick
// check if already in use -- if so, refuse
_, alreadyInUse := users.nickIndex[newNick.canonical]
if alreadyInUse {
if oldNick != nil && newNick.canonical == oldNick.canonical {
// it's fine, this is the user who held that nick
// so continue as before
} else {
return fmt.Errorf("%w: %s", errors.ErrNickAlreadyInUse, newNick.Value)
}
}
// update indexes
if oldNick != nil {
delete(users.nickIndex, oldNick.canonical)
}
if newNick != nil {
users.nickIndex[newNick.canonical] = user
}
// update me
user.nick = newNick
return nil
}
func (user *User) GetUsername() *string {
return user.username
}
func (user *User) SetUsername(username *string) {
user.username = username
}
func (user *User) GetRealName() *string {
return user.realName
}
func (user *User) SetRealName(realName *string) {
user.realName = realName
}
func (user *User) GetHasReceivedAuthHandshakeReply() bool {
return user.hasReceivedAuthHandshakeReply
}
func (user *User) SetHasReceivedAuthHandshakeReply(value bool) {
user.hasReceivedAuthHandshakeReply = value
}
func (user *User) IsInChannel(channelName ChannelName) bool {
return slices.ContainsFunc(user.channels, func(existingChannel ChannelName) bool {
return channelName.canonical == existingChannel.canonical
})
}
func (user *User) Join(channelName ChannelName) error {
users := user.users
// if I'm already in this channel, don't join
if user.IsInChannel(channelName) {
return fmt.Errorf("%w: %s", errors.ErrAlreadyInChannel, channelName.Value)
}
// update indexes
existing, ok := users.channelNameIndex[channelName.canonical]
if !ok {
existing = make(map[*User]struct{})
users.channelNameIndex[channelName.canonical] = existing
}
_, wasInChannel := existing[user]
if wasInChannel {
panic("tried to join a channel, but I was mysteriously already in it")
}
existing[user] = struct{}{}
// update me
user.channels = append(user.channels, channelName)
return nil
}
func (user *User) Part(channelName ChannelName) error {
users := user.users
// if i'm not in this channel, don't part
if !user.IsInChannel(channelName) {
return fmt.Errorf("%w: %s", errors.ErrNotInChannel, channelName.Value)
}
// update indexes
existing, ok := users.channelNameIndex[channelName.canonical]
if ok {
delete(existing, user)
if len(existing) == 0 {
delete(users.channelNameIndex, channelName.canonical)
}
} else {
panic("tried to part from a channel, but was mysteriously absent from it")
}
return nil
}

46
src/irc/users/types.go Normal file
View File

@@ -0,0 +1,46 @@
package users
import (
"fmt"
"regexp"
"strings"
)
type Nick struct {
Value string
canonical canonicalNick
}
type canonicalNick string
var ErrNotANick = fmt.Errorf("does not look like a nickname")
var regexpNick = regexp.MustCompile(`^[a-zA-Z0-9\-_]+$`) // NOTE: more constrained than real character set
func ValidateNick(input string) (Nick, error) {
if !regexpNick.MatchString(input) {
return Nick{}, fmt.Errorf("%w: %s", ErrNotANick, input)
}
return Nick{
Value: input,
canonical: canonicalNick(strings.ToLower(input)),
}, nil
}
type ChannelName struct {
Value string
canonical canonicalChannelName
}
type canonicalChannelName string
var ErrNotAChannelName = fmt.Errorf("does not look like a channel name")
var regexpChannelName = regexp.MustCompile(`^#[a-zA-Z0-9\-_]+$`)
func ValidateChannelName(input string) (ChannelName, error) {
if !regexpChannelName.MatchString(input) {
return ChannelName{}, fmt.Errorf("%w: %s", ErrNotAChannelName, input)
}
return ChannelName{
Value: input,
canonical: canonicalChannelName(strings.ToLower(input)),
}, nil
}

122
src/irc/world/world.go Normal file
View File

@@ -0,0 +1,122 @@
package world
import (
"fmt"
"log"
"slices"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/irc/users"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/transport"
)
type World struct {
Server *transport.Server
UsersSystem *users.UsersSystem
}
type WrappedMessage struct {
World *World
Sender *users.User
Content transport.Content
}
func NewWorld(server *transport.Server) *World {
usersSystem := users.NewUsersSystem()
return &World{
Server: server,
UsersSystem: usersSystem,
}
}
func (world *World) Wrap(msg transport.IncomingMessage) WrappedMessage {
sender := world.UsersSystem.ByClientIdOrCreate(msg.Sender)
return WrappedMessage{
World: world,
Sender: sender,
Content: msg.Content,
}
}
// transmission of messages
func (world *World) RelayToVagueDestination(
msg WrappedMessage,
name string,
exclude []transport.ClientId,
) {
nick, err := users.ValidateNick(name)
if err == nil {
// so it's a nick!
world.RelayToNick(msg, nick, exclude)
return
}
channel, err := users.ValidateChannelName(name)
if err == nil {
// so it's a channel!
world.RelayToChannel(msg, channel, exclude)
return
}
log.Fatalf("not sure how to send to %s", name)
// TODO: Error response: "what is this?"
}
func (world *World) RelayToClient(
msg WrappedMessage,
client transport.ClientId,
exclude []transport.ClientId,
) {
content := createAnnotatedContent(msg)
if slices.Contains(exclude, client) {
return // don't relay
}
world.Server.SendMessage(client, content)
}
func (world *World) RelayToNick(
msg WrappedMessage,
nick users.Nick,
exclude []transport.ClientId,
) {
content := createAnnotatedContent(msg)
user := world.UsersSystem.ByNick(nick)
if user == nil {
// TODO: Send an error reply. The user didn't exist
return
}
if slices.Contains(exclude, user.GetClientId()) {
return // don't relay
}
world.Server.SendMessage(user.GetClientId(), content)
}
func (world *World) RelayToChannel(
msg WrappedMessage,
channelName users.ChannelName,
exclude []transport.ClientId,
) {
content := createAnnotatedContent(msg)
members := world.UsersSystem.ByChannel(channelName)
log.Printf("Members of %s: %v\n", channelName, members)
for member := range members {
if slices.Contains(exclude, member.GetClientId()) {
return // don't relay
}
world.Server.SendMessage(member.GetClientId(), content)
}
}
func createAnnotatedContent(
msg WrappedMessage,
) transport.Content {
content := msg.Content
fullSource := fmt.Sprintf("%s!clients/%d", msg.Sender.GetNick().Value, msg.Sender.GetClientId())
content.Source = &fullSource
return content
}