-/*
-goircd -- minimalistic simple Internet Relay Chat (IRC) server
-Copyright (C) 2014-2015 Sergey Matveev <stargrave@stargrave.org>
-
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU General Public License as published by
-the Free Software Foundation, either version 3 of the License, or
-(at your option) any later version.
-
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU General Public License for more details.
-
-You should have received a copy of the GNU General Public License
-along with this program. If not, see <http://www.gnu.org/licenses/>.
-*/
+// goircd -- minimalistic simple Internet Relay Chat (IRC) server
+// Copyright (C) 2014-2024 Sergey Matveev <stargrave@stargrave.org>
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, version 3 of the License.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
package main
import (
"bytes"
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/hex"
+ "fmt"
"log"
"net"
+ "os"
+ "regexp"
+ "sort"
"strings"
+ "sync"
"time"
)
const (
- BufSize = 1500
+ BufSize = 1500
+ MaxOutBuf = 128
)
var (
- CRLF []byte = []byte{'\x0d', '\x0a'}
+ CRLF []byte = []byte{'\x0d', '\x0a'}
+ RENickname = regexp.MustCompile("^[a-zA-Z0-9-]{1,24}$")
+
+ clients map[*Client]struct{} = make(map[*Client]struct{})
+ clientsLock sync.RWMutex
+ clientsWG sync.WaitGroup
)
type Client struct {
- hostname *string
- conn net.Conn
- registered bool
- nickname string
- username string
- realname string
- password string
- away *string
+ conn net.Conn
+ registered bool
+ nickname string
+ username string
+ realname string
+ password string
+ away string
+ recvTimestamp time.Time
+ sendTimestamp time.Time
+ outBuf chan string
+ alive bool
+ sync.Mutex
+}
+
+func (c *Client) Host() string {
+ if *cloak != "" {
+ return *cloak
+ }
+ addr := c.conn.RemoteAddr().String()
+ if host, _, err := net.SplitHostPort(addr); err == nil {
+ addr = host
+ }
+ if domains, err := net.LookupAddr(addr); err == nil {
+ addr = strings.TrimSuffix(domains[0], ".")
+ }
+ return addr
}
-type ClientAlivenessState struct {
- pingSent bool
- timestamp time.Time
+func (c *Client) String() string {
+ return strings.Join([]string{c.nickname, "!", c.username, "@", c.Host()}, "")
}
-func (client Client) String() string {
- return client.nickname + "!" + client.username + "@" + client.conn.RemoteAddr().String()
+func NewClient(conn net.Conn, events chan ClientEvent) *Client {
+ c := Client{
+ conn: conn,
+ nickname: "*",
+ username: "",
+ recvTimestamp: time.Now(),
+ sendTimestamp: time.Now(),
+ alive: true,
+ outBuf: make(chan string, MaxOutBuf),
+ }
+ clientsWG.Add(2)
+ go c.MsgSender()
+ go c.Processor(events)
+ return &c
}
-func NewClient(hostname *string, conn net.Conn) *Client {
- return &Client{hostname: hostname, conn: conn, nickname: "*", password: ""}
+func (c *Client) Close() {
+ c.Lock()
+ if c.alive {
+ close(c.outBuf)
+ c.alive = false
+ }
+ c.Unlock()
}
-// Client processor blockingly reads everything remote client sends,
-// splits messages by CRLF and send them to Daemon gorouting for processing
-// it futher. Also it can signalize that client is unavailable (disconnected).
-func (client *Client) Processor(sink chan<- ClientEvent) {
- sink <- ClientEvent{client, EventNew, ""}
- log.Println(client, "New client")
+func (c *Client) Processor(events chan ClientEvent) {
+ events <- ClientEvent{c, EventNew, ""}
+ if *verbose {
+ log.Println(c, "connected")
+ }
buf := make([]byte, BufSize*2)
- var n int
- var prev int
- var i int
+ var n, prev, i int
+ var msg string
var err error
for {
if prev == BufSize {
- log.Println(client, "buffer size exceeded, kicking him")
- sink <- ClientEvent{client, EventDel, ""}
- client.conn.Close()
+ log.Println(c, "input buffer size exceeded, kicking him")
break
}
- n, err = client.conn.Read(buf[prev:])
+ n, err = c.conn.Read(buf[prev:])
if err != nil {
- sink <- ClientEvent{client, EventDel, ""}
break
}
prev += n
if i == -1 {
continue
}
- sink <- ClientEvent{client, EventMsg, string(buf[:i])}
+ if *debug {
+ log.Println(c, "<-", msg)
+ }
+ msg = string(buf[:i])
+ if *debug {
+ log.Println(c, "->", msg)
+ }
+ events <- ClientEvent{c, EventMsg, msg}
copy(buf, buf[i+2:prev])
prev -= (i + 2)
goto CheckMore
}
+ c.Close()
+ if *verbose {
+ log.Println(c, "disconnected")
+ }
+ events <- ClientEvent{c, EventDel, ""}
+ clientsWG.Done()
}
-// Send message as is with CRLF appended.
-func (client *Client) Msg(text string) {
- client.conn.Write(append([]byte(text), CRLF...))
+func (c *Client) MsgSender() {
+ var err error
+ for msg := range c.outBuf {
+ if *debug {
+ log.Println(c, "<-", msg)
+ }
+ if _, err = c.conn.Write(append([]byte(msg), CRLF...)); err != nil {
+ if *verbose {
+ log.Println(c, "error writing", err)
+ }
+ break
+ }
+ }
+ c.conn.Close()
+ clientsWG.Done()
}
-// Send message from server. It has ": servername" prefix.
-func (client *Client) Reply(text string) {
- client.Msg(":" + *client.hostname + " " + text)
+func (c *Client) Msg(text string) {
+ c.Lock()
+ defer c.Unlock()
+ if !c.alive {
+ return
+ }
+ if len(c.outBuf) == MaxOutBuf {
+ log.Println(c, "output buffer size exceeded, kicking him")
+ if c.alive {
+ close(c.outBuf)
+ c.alive = false
+ }
+ return
+ }
+ c.outBuf <- text
}
-// Send server message, concatenating all provided text parts and
-// prefix the last one with ":".
-func (client *Client) ReplyParts(code string, text ...string) {
- parts := []string{code}
- for _, t := range text {
- parts = append(parts, t)
- }
+func (c *Client) Reply(text string) {
+ c.Msg(":" + *hostname + " " + text)
+}
+
+func (c *Client) ReplyParts(code string, text ...string) {
+ parts := append([]string{code}, text...)
parts[len(parts)-1] = ":" + parts[len(parts)-1]
- client.Reply(strings.Join(parts, " "))
+ c.Reply(strings.Join(parts, " "))
+}
+
+func (c *Client) ReplyNicknamed(code string, text ...string) {
+ c.ReplyParts(code, append([]string{c.nickname}, text...)...)
+}
+
+func (c *Client) ReplyNotEnoughParameters(command string) {
+ c.ReplyNicknamed("461", command, "Not enough parameters")
+}
+
+func (c *Client) ReplyNoChannel(channel string) {
+ c.ReplyNicknamed("403", channel, "No such channel")
+}
+
+func (c *Client) ReplyNoNickChan(channel string) {
+ c.ReplyNicknamed("401", channel, "No such nick/channel")
+}
+
+func (c *Client) SendLusers() {
+ lusers := 0
+ clientsLock.RLock()
+ for client := range clients {
+ if client.registered {
+ lusers++
+ }
+ }
+ clientsLock.RUnlock()
+ c.ReplyNicknamed(
+ "251",
+ fmt.Sprintf("There are %d users and 0 invisible on 1 servers",
+ lusers,
+ ))
}
-// Send nicknamed server message. After servername it always has target
-// client's nickname. The last part is prefixed with ":".
-func (client *Client) ReplyNicknamed(code string, text ...string) {
- client.ReplyParts(code, append([]string{client.nickname}, text...)...)
+func (c *Client) SendMotd() {
+ if motd == nil {
+ c.ReplyNicknamed("422", "MOTD File is missing")
+ return
+ }
+ motdText, err := os.ReadFile(*motd)
+ if err != nil {
+ log.Printf("can not read motd file %s: %v", *motd, err)
+ c.ReplyNicknamed("422", "Error reading MOTD File")
+ return
+ }
+ c.ReplyNicknamed("375", "- "+*hostname+" Message of the day -")
+ for _, s := range strings.Split(strings.TrimSuffix(string(motdText), "\n"), "\n") {
+ c.ReplyNicknamed("372", "- "+s)
+ }
+ c.ReplyNicknamed("376", "End of /MOTD command")
}
-// Reply "461 not enough parameters" error for given command.
-func (client *Client) ReplyNotEnoughParameters(command string) {
- client.ReplyNicknamed("461", command, "Not enough parameters")
+func (c *Client) Join(cmd string) {
+ args := strings.Split(cmd, " ")
+ rs := strings.Split(args[0], ",")
+ keys := []string{}
+ if len(args) > 1 {
+ keys = strings.Split(args[1], ",")
+ }
+RoomCycle:
+ for n, roomName := range rs {
+ if !RERoom.MatchString(roomName) {
+ c.ReplyNoChannel(roomName)
+ continue
+ }
+ var key string
+ if (n < len(keys)) && (keys[n] != "") {
+ key = keys[n]
+ }
+ roomsLock.RLock()
+ for roomNameExisting, room := range rooms {
+ if roomName != roomNameExisting {
+ continue
+ }
+ if (room.key != "") && (room.key != key) {
+ c.ReplyNicknamed("475", roomName, "Cannot join channel (+k)")
+ roomsLock.RUnlock()
+ return
+ }
+ room.events <- ClientEvent{c, EventNew, ""}
+ roomsLock.RUnlock()
+ continue RoomCycle
+ }
+ roomsLock.RUnlock()
+ roomNew := RoomRegister(roomName)
+ if *verbose {
+ log.Println("room", roomName, "created")
+ }
+ if key != "" {
+ roomNew.key = key
+ roomNew.StateSave()
+ }
+ roomNew.events <- ClientEvent{c, EventNew, ""}
+ }
}
-// Reply "403 no such channel" error for specified channel.
-func (client *Client) ReplyNoChannel(channel string) {
- client.ReplyNicknamed("403", channel, "No such channel")
+func (client *Client) SendWhois(nicknames []string) {
+ var c *Client
+ for _, nickname := range nicknames {
+ nickname = strings.ToLower(nickname)
+ clientsLock.RLock()
+ for c = range clients {
+ if strings.ToLower(c.nickname) == nickname {
+ clientsLock.RUnlock()
+ goto Found
+ }
+ }
+ clientsLock.RUnlock()
+ client.ReplyNoNickChan(nickname)
+ continue
+ Found:
+ var host string
+ if *cloak != "" {
+ host = *cloak
+ } else {
+ host, _, err := net.SplitHostPort(c.conn.RemoteAddr().String())
+ if err != nil {
+ log.Printf("can't parse RemoteAddr %q: %v", host, err)
+ host = "Unknown"
+ }
+ }
+ client.ReplyNicknamed("311", c.nickname, c.username, host, "*", c.realname)
+ client.ReplyNicknamed("312", c.nickname, *hostname, *hostname)
+ if c.away != "" {
+ client.ReplyNicknamed("301", c.nickname, c.away)
+ }
+ subscriptions := make([]string, 0)
+ roomsLock.RLock()
+ for _, room := range rooms {
+ for subscriber := range room.members {
+ if subscriber.nickname == nickname {
+ subscriptions = append(subscriptions, room.name)
+ }
+ }
+ }
+ roomsLock.RUnlock()
+ sort.Strings(subscriptions)
+ client.ReplyNicknamed("319", c.nickname, strings.Join(subscriptions, " "))
+ client.ReplyNicknamed("318", c.nickname, "End of /WHOIS list")
+ }
}
-func (client *Client) ReplyNoNickChan(channel string) {
- client.ReplyNicknamed("401", channel, "No such nick/channel")
+func (c *Client) SendList(cols []string) {
+ var rs []string
+ if (len(cols) > 1) && (cols[1] != "") {
+ rs = strings.Split(strings.Split(cols[1], " ")[0], ",")
+ } else {
+ rs = make([]string, 0)
+ roomsLock.RLock()
+ for r := range rooms {
+ rs = append(rs, r)
+ }
+ roomsLock.RUnlock()
+ }
+ sort.Strings(rs)
+ roomsLock.RLock()
+ for _, r := range rs {
+ if room, found := rooms[r]; found {
+ c.ReplyNicknamed(
+ "322",
+ r,
+ fmt.Sprintf("%d", len(room.members)),
+ room.topic,
+ )
+ }
+ }
+ roomsLock.RUnlock()
+ c.ReplyNicknamed("323", "End of /LIST")
+}
+
+func (c *Client) Register(cmd string, cols []string) {
+ switch cmd {
+ case "PASS":
+ if len(cols) == 1 || len(cols[1]) < 1 {
+ c.ReplyNotEnoughParameters("PASS")
+ return
+ }
+ password := strings.TrimPrefix(cols[1], ":")
+ c.password = password
+ case "NICK":
+ if len(cols) == 1 || len(cols[1]) < 1 {
+ c.ReplyParts("431", "No nickname given")
+ return
+ }
+ nickname := cols[1]
+ // Compatibility with some clients prepending colons to nickname
+ nickname = strings.TrimPrefix(nickname, ":")
+ nickname = strings.ToLower(nickname)
+ if !RENickname.MatchString(nickname) {
+ c.ReplyParts("432", "*", cols[1], "Erroneous nickname")
+ return
+ }
+ clientsLock.RLock()
+ for existingClient := range clients {
+ if existingClient.nickname == nickname {
+ clientsLock.RUnlock()
+ c.ReplyParts("433", "*", nickname, "Nickname is already in use")
+ return
+ }
+ }
+ clientsLock.RUnlock()
+ c.nickname = nickname
+ case "USER":
+ if len(cols) == 1 {
+ c.ReplyNotEnoughParameters("USER")
+ return
+ }
+ args := strings.SplitN(cols[1], " ", 4)
+ if len(args) < 4 {
+ c.ReplyNotEnoughParameters("USER")
+ return
+ }
+ c.username = args[0]
+ realname := strings.TrimLeft(args[3], ":")
+ c.realname = realname
+ }
+ if c.nickname != "*" && c.username != "" {
+ if *passwords != "" {
+ authenticated := false
+ if c.password == "" {
+ c.ReplyParts("462", "You may not register")
+ c.Close()
+ return
+ }
+ contents, err := os.ReadFile(*passwords)
+ if err != nil {
+ log.Fatalf("can not read passwords file %s: %s", *passwords, err)
+ return
+ }
+ for n, entry := range strings.Split(string(contents), "\n") {
+ if entry == "" || strings.HasPrefix(entry, "#") {
+ continue
+ }
+ cols := strings.Split(entry, ":")
+ if len(cols) != 2 {
+ log.Fatalf("bad passwords format: %s:%d", *passwords, n)
+ continue
+ }
+ if cols[0] != c.nickname {
+ continue
+ }
+ h := sha256.Sum256([]byte(c.password))
+ authenticated = subtle.ConstantTimeCompare(
+ []byte(hex.EncodeToString(h[:])),
+ []byte(cols[1]),
+ ) == 1
+ break
+ }
+ if !authenticated {
+ c.ReplyParts("462", "You may not register")
+ c.Close()
+ return
+ }
+ }
+ c.registered = true
+ c.ReplyNicknamed("001", "Hi, welcome to IRC")
+ c.ReplyNicknamed("002", "Your host is "+*hostname+", running goircd "+Version)
+ c.ReplyNicknamed("003", "This server was created sometime")
+ c.ReplyNicknamed("004", *hostname+" goircd o o")
+ c.SendLusers()
+ c.SendMotd()
+ if *verbose {
+ log.Println(c, "logged in")
+ }
+ }
}