From b7fb219307483d2c31b5dad1f559f325f2fd1a5e Mon Sep 17 00:00:00 2001 From: Sergey Matveev Date: Fri, 6 Nov 2020 20:10:06 +0300 Subject: [PATCH] Many fixes and additions * NAMES and WALLOPS command * -cloak option * -v renamed to -verbose * -passwords renamed to -passwd * -debug option prints traffic messages * without -verbose only startup/shutdown and error messages are printed * -timestamped option provides timestamps in printed messages, as earlier. No timestamps is useful for running under daemontools * passwords are stored in SHA256-hashed format * state files replaced with state directory with files * removed many unnecessary pointers and locks * graceful shutdown with all clients notification * fixed time structure printing in log files, instead of short human readable timestamp * PART is sent to the user itself, to notify his client about leaving * log messages are printed to stdout, instead of stderr, for friendliness with daemontools logger * ability to configure newly created directories and files with -perm-state-dir, -perm-state-file, -perm-log-file --- INSTALL | 7 +- README | 62 +++--- clean.do | 1 + client.go | 361 ++++++++++++++++++++++++++++----- client_test.go | 27 +-- common_test.go | 33 +-- daemon.go | 541 ++++++++++++++++--------------------------------- daemon_test.go | 22 +- events.go | 119 ----------- goircd.go | 199 +++++++++++++----- log.go | 72 +++++++ room.go | 304 +++++++++++++-------------- room_test.go | 40 ++-- 13 files changed, 957 insertions(+), 831 deletions(-) create mode 100644 clean.do delete mode 100644 events.go create mode 100644 log.go diff --git a/INSTALL b/INSTALL index 3c5c634..f3d979c 100644 --- a/INSTALL +++ b/INSTALL @@ -1,8 +1,9 @@ goircd requires only standard Go's libraries and consists of single main -package. You can install it like either: +package. You can install it like that: -* with: go get go.cypherpunks.ru/goircd -* or manually: + $ go get go.cypherpunks.ru/goircd + +or manually: $ git clone git://git.cypherpunks.ru/goircd.git $ cd goircd diff --git a/README b/README index 663aaf3..e9c5eb1 100644 --- a/README +++ b/README @@ -10,7 +10,7 @@ It does not aim to replace full featured mass scalable IRC networks: * It can not connect to other servers. Just standalone installation * It has few basic IRC commands -* There is no support for channel operators, modes, votes, invites +* There is no support for channel operators, many modes, votes, invites * No ident lookups But it has some convincing features: @@ -19,8 +19,8 @@ But it has some convincing features: * Single executable binary * No configuration file, just few command line arguments * IPv6 out-of-box support -* Ability to listen on TLS-capable ports -* Optional channel logging to plain text files +* Ability to additionally listen on TLS-capable ports +* Optional channels logging to plain text files * Optional permanent channel's state saving in plain text files (so you can reload daemon and all channels topics and keys won't disappear) @@ -28,35 +28,42 @@ But it has some convincing features: Some remarks and recommendations related to it's simplicity: -* Use either nohup or similar tools to daemonize it -* Just plain logging on stderr, without syslog support +* Use daemontools to daemonize, setuid/gid it +* Just plaintext logging to stdout, without syslog support -- use + daemontool's multilog SUPPORTED IRC COMMANDS * PASS/NICK/USER during registration workflow * PING/PONGs * NOTICE/PRIVMSG, ISON -* AWAY, MOTD, LUSERS, WHO, WHOIS, VERSION, QUIT +* AWAY, MOTD, LUSERS, NAMES, WHO, WHOIS, VERSION, WALLOPS, QUIT * LIST, JOIN, TOPIC, +k/-k channel MODE USAGE Just execute goircd daemon. It has following optional arguments: - -hostname: hostname to show for client's connections - -bind: address to bind to (:6667 by default) - -motd: absolute path to MOTD file. It is reread every time - MOTD is requested - -logdir: directory where all channels messages will be saved. If - omitted, then no logs will be kept - -statedir: directory where all channels states will be saved and - loaded during startup. If omitted, then states will be - lost after daemon termination - -tlsbind: enable TLS, specify address to listen on and path - -tlspem to PEM file with certificate and private key - -passwords: enable client authentication and specify path to - passwords file - -v: increase verbosity + -hostname: hostname to show for client's connections + -bind: address to bind to (:6667 by default) + -cloak: cloak user's host with the given hostname + -motd: absolute path to MOTD file. It is reread every time + MOTD is requested + -logdir: directory where all channels messages will be saved. If + omitted, then no logs will be kept + -statedir: directory where all channels states will be saved and + loaded during startup. If omitted, then states will be + lost after daemon termination + -tlsbind: enable TLS, specify address to listen on and path + -tlspem: to PEM file with certificate and private key + -passwd: enable client authentication and specify path to + passwords file + -timestamped: enabled timestamps for stderr messages + -verbose: increase verbosity + -debug: also show traffic messages + -perm-state-dir: permission (before umask) for newly created state directory +-perm-state-file: permission (before umask) for newly created state file + -perm-log-file: permission (before umask) for newly created log file TLS @@ -69,10 +76,12 @@ AUTHENTICATION You can turn on optional client authentication by preparing passwords file and using the -passwords argument. Format of passwords file is: - login1:password1\n - login2:password2\n + login1:hex(sha256(password1))\n + login2:hex(sha256(password2))\n ... +You can get hashed password value using: echo -n password | sha256 + LOG FILES Log files are not opened all the time, but only during each message @@ -80,13 +89,8 @@ saving. That is why you can safely rename them for rotation purposes. STATE FILES -Each state file has the name equals to room's one. It contains two plain -text lines: room's topic and room's authentication key (empty if none -specified). For example: - - $ cat states/meinroom - This is meinroom's topic - secretkey +Room's state is created/saved when either topic or key is set. State is +a directory (room's name) with "topic" and "key" plaintext files. LICENCE diff --git a/clean.do b/clean.do new file mode 100644 index 0000000..34df69f --- /dev/null +++ b/clean.do @@ -0,0 +1 @@ +rm -f goircd diff --git a/client.go b/client.go index 2c8c0ad..6a21ab0 100644 --- a/client.go +++ b/client.go @@ -19,8 +19,15 @@ package main import ( "bytes" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "fmt" + "io/ioutil" "log" "net" + "regexp" + "sort" "strings" "sync" "time" @@ -28,29 +35,37 @@ import ( const ( BufSize = 1500 - MaxOutBuf = 1 << 12 + 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 { conn net.Conn registered bool - nickname *string - username *string - realname *string - password *string - away *string + nickname string + username string + realname string + password string + away string recvTimestamp time.Time sendTimestamp time.Time - outBuf chan *string + 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 @@ -62,48 +77,42 @@ func (c *Client) Host() string { } func (c *Client) String() string { - return *c.nickname + "!" + *c.username + "@" + c.Host() + return strings.Join([]string{c.nickname, "!", c.username, "@", c.Host()}, "") } -func NewClient(conn net.Conn) *Client { - nickname := "*" - username := "" +func NewClient(conn net.Conn, events chan ClientEvent) *Client { c := Client{ conn: conn, - nickname: &nickname, - username: &username, + nickname: "*", + username: "", recvTimestamp: time.Now(), sendTimestamp: time.Now(), alive: true, - outBuf: make(chan *string, MaxOutBuf), + outBuf: make(chan string, MaxOutBuf), } + clientsWG.Add(2) go c.MsgSender() + go c.Processor(events) return &c } -func (c *Client) SetDead() { - c.outBuf <- nil - c.alive = false -} - func (c *Client) Close() { c.Lock() if c.alive { - c.SetDead() + 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 (c *Client) Processor(sink chan ClientEvent) { - sink <- ClientEvent{c, EventNew, ""} - log.Println(c, "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 { @@ -120,26 +129,43 @@ func (c *Client) Processor(sink chan ClientEvent) { if i == -1 { continue } - sink <- ClientEvent{c, 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() - sink <- ClientEvent{c, EventDel, ""} + if *verbose { + log.Println(c, "disconnected") + } + events <- ClientEvent{c, EventDel, ""} + clientsWG.Done() } func (c *Client) MsgSender() { + var err error for msg := range c.outBuf { - if msg == nil { - c.conn.Close() + 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.Write(append([]byte(*msg), CRLF...)) } + c.conn.Close() + clientsWG.Done() } -// Send message as is with CRLF appended. func (c *Client) Msg(text string) { c.Lock() defer c.Unlock() @@ -149,20 +175,18 @@ func (c *Client) Msg(text string) { if len(c.outBuf) == MaxOutBuf { log.Println(c, "output buffer size exceeded, kicking him") if c.alive { - c.SetDead() + close(c.outBuf) + c.alive = false } return } - c.outBuf <- &text + c.outBuf <- text } -// Send message from server. It has ": servername" prefix. func (c *Client) Reply(text string) { c.Msg(":" + *hostname + " " + text) } -// Send server message, concatenating all provided text parts and -// prefix the last one with ":". func (c *Client) ReplyParts(code string, text ...string) { parts := []string{code} for _, t := range text { @@ -172,18 +196,14 @@ func (c *Client) ReplyParts(code string, text ...string) { c.Reply(strings.Join(parts, " ")) } -// Send nicknamed server message. After servername it always has target -// client's nickname. The last part is prefixed with ":". func (c *Client) ReplyNicknamed(code string, text ...string) { - c.ReplyParts(code, append([]string{*c.nickname}, text...)...) + c.ReplyParts(code, append([]string{c.nickname}, text...)...) } -// Reply "461 not enough parameters" error for given command. func (c *Client) ReplyNotEnoughParameters(command string) { c.ReplyNicknamed("461", command, "Not enough parameters") } -// Reply "403 no such channel" error for specified channel. func (c *Client) ReplyNoChannel(channel string) { c.ReplyNicknamed("403", channel, "No such channel") } @@ -191,3 +211,252 @@ func (c *Client) ReplyNoChannel(channel string) { 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, + )) +} + +func (c *Client) SendMotd() { + if motd == nil { + c.ReplyNicknamed("422", "MOTD File is missing") + return + } + motdText, err := ioutil.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") +} + +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, ""} + } +} + +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 (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 := ioutil.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") + } + } +} diff --git a/client_test.go b/client_test.go index 4af1dd3..ed71da8 100644 --- a/client_test.go +++ b/client_test.go @@ -21,45 +21,46 @@ import ( "testing" ) -// New client creation test. It must send an event about new client, -// two predefined messages from it and deletion one func TestNewClient(t *testing.T) { conn := NewTestingConn() - sink := make(chan ClientEvent) + events := make(chan ClientEvent) host := "foohost" hostname = &host - client := NewClient(conn) - go client.Processor(sink) + client := NewClient(conn, events) + defer func() { + client.Close() + }() - event := <-sink + event := <-events if event.eventType != EventNew { t.Fatal("no NEW event", event) } conn.inbound <- "foo" - event = <-sink + event = <-events if (event.eventType != EventMsg) || (event.text != "foo") { t.Fatal("no first MSG", event) } conn.inbound <- "bar" - event = <-sink + event = <-events if (event.eventType != EventMsg) || (event.text != "bar") { t.Fatal("no second MSG", event) } conn.inbound <- "" - event = <-sink + event = <-events if event.eventType != EventDel { t.Fatal("no client termination", event) } } -// Test replies formatting func TestClientReplies(t *testing.T) { conn := NewTestingConn() host := "foohost" hostname = &host - client := NewClient(conn) - nickname := "мойник" - client.nickname = &nickname + client := NewClient(conn, make(chan ClientEvent, 2)) + defer func() { + client.Close() + }() + client.nickname = "мойник" client.Reply("hello") if r := <-conn.outbound; r != ":foohost hello\r\n" { diff --git a/common_test.go b/common_test.go index 870852f..9750b86 100644 --- a/common_test.go +++ b/common_test.go @@ -18,22 +18,23 @@ along with this program. If not, see . package main import ( + "io" "net" "time" ) -// Testing network connection that satisfies net.Conn interface -// Can send predefined messages and store all written ones type TestingConn struct { inbound chan string outbound chan string - closed bool + closed chan struct{} } func NewTestingConn() *TestingConn { - inbound := make(chan string, 8) - outbound := make(chan string, 8) - return &TestingConn{inbound: inbound, outbound: outbound} + return &TestingConn{ + inbound: make(chan string, 8), + outbound: make(chan string, 8), + closed: make(chan struct{}), + } } func (conn TestingConn) Error() string { @@ -41,14 +42,18 @@ func (conn TestingConn) Error() string { } func (conn *TestingConn) Read(b []byte) (n int, err error) { - msg := <-conn.inbound - if msg == "" { - return 0, conn - } - for n, bt := range append([]byte(msg), CRLF...) { - b[n] = bt + select { + case msg := <-conn.inbound: + if msg == "" { + return 0, conn + } + for n, bt := range append([]byte(msg), CRLF...) { + b[n] = bt + } + return len(msg) + 2, nil + case <-conn.closed: + return 0, io.EOF } - return len(msg) + 2, nil } type MyAddr struct{} @@ -66,7 +71,7 @@ func (conn *TestingConn) Write(b []byte) (n int, err error) { } func (conn *TestingConn) Close() error { - conn.closed = true + close(conn.closed) close(conn.outbound) return nil } diff --git a/daemon.go b/daemon.go index 4b07e59..ce2dd00 100644 --- a/daemon.go +++ b/daemon.go @@ -19,13 +19,8 @@ package main import ( "fmt" - "io/ioutil" "log" - "net" - "regexp" - "sort" "strings" - "sync" "time" ) @@ -36,289 +31,26 @@ const ( PingThreshold = time.Second * 90 ) -var ( - RENickname = regexp.MustCompile("^[a-zA-Z0-9-]{1,24}$") - - clients map[*Client]struct{} = make(map[*Client]struct{}) - clientsM sync.RWMutex - rooms map[string]*Room = make(map[string]*Room) - roomsM sync.RWMutex - roomsGroup sync.WaitGroup - roomSinks map[*Room]chan ClientEvent = make(map[*Room]chan ClientEvent) -) - -func SendLusers(client *Client) { - lusers := 0 - clientsM.RLock() - for client := range clients { - if client.registered { - lusers++ - } - } - clientsM.RUnlock() - client.ReplyNicknamed("251", fmt.Sprintf("There are %d users and 0 invisible on 1 servers", lusers)) -} - -func SendMotd(client *Client) { - if motd == nil { - client.ReplyNicknamed("422", "MOTD File is missing") - return - } - motdText, err := ioutil.ReadFile(*motd) - if err != nil { - log.Printf("Can not read motd file %s: %v", *motd, err) - client.ReplyNicknamed("422", "Error reading MOTD File") - return - } - client.ReplyNicknamed("375", "- "+*hostname+" Message of the day -") - for _, s := range strings.Split(strings.TrimSuffix(string(motdText), "\n"), "\n") { - client.ReplyNicknamed("372", "- "+s) - } - client.ReplyNicknamed("376", "End of /MOTD command") -} - -func SendWhois(client *Client, nicknames []string) { - var c *Client - var hostPort string - var err error - var subscriptions []string - var room *Room - var subscriber *Client - for _, nickname := range nicknames { - nickname = strings.ToLower(nickname) - clientsM.RLock() - for c = range clients { - if strings.ToLower(*c.nickname) == nickname { - clientsM.RUnlock() - goto Found - } - } - clientsM.RUnlock() - client.ReplyNoNickChan(nickname) - continue - Found: - hostPort, _, err = net.SplitHostPort(c.conn.RemoteAddr().String()) - if err != nil { - log.Printf("Can't parse RemoteAddr %q: %v", hostPort, err) - hostPort = "Unknown" - } - client.ReplyNicknamed("311", *c.nickname, *c.username, hostPort, "*", *c.realname) - client.ReplyNicknamed("312", *c.nickname, *hostname, *hostname) - if c.away != nil { - client.ReplyNicknamed("301", *c.nickname, *c.away) - } - subscriptions = make([]string, 0) - roomsM.RLock() - for _, room = range rooms { - for subscriber = range room.members { - if *subscriber.nickname == nickname { - subscriptions = append(subscriptions, *room.name) - } - } - } - roomsM.RUnlock() - sort.Strings(subscriptions) - client.ReplyNicknamed("319", *c.nickname, strings.Join(subscriptions, " ")) - client.ReplyNicknamed("318", *c.nickname, "End of /WHOIS list") - } -} - -func SendList(client *Client, cols []string) { - var rs []string - var r string - if (len(cols) > 1) && (cols[1] != "") { - rs = strings.Split(strings.Split(cols[1], " ")[0], ",") - } else { - rs = make([]string, 0) - roomsM.RLock() - for r = range rooms { - rs = append(rs, r) - } - roomsM.RUnlock() - } - sort.Strings(rs) - var room *Room - var found bool - for _, r = range rs { - roomsM.RLock() - if room, found = rooms[r]; found { - client.ReplyNicknamed( - "322", - r, - fmt.Sprintf("%d", len(room.members)), - *room.topic, - ) - } - roomsM.RUnlock() - } - client.ReplyNicknamed("323", "End of /LIST") -} - -// Unregistered client workflow processor. Unregistered client: -// * is not PINGed -// * only QUIT, NICK and USER commands are processed -// * other commands are quietly ignored -// When client finishes NICK/USER workflow, then MOTD and LUSERS are send to him. -func ClientRegister(client *Client, cmd string, cols []string) { - switch cmd { - case "PASS": - if len(cols) == 1 || len(cols[1]) < 1 { - client.ReplyNotEnoughParameters("PASS") - return - } - password := strings.TrimPrefix(cols[1], ":") - client.password = &password - case "NICK": - if len(cols) == 1 || len(cols[1]) < 1 { - client.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) - clientsM.RLock() - for existingClient := range clients { - if *existingClient.nickname == nickname { - clientsM.RUnlock() - client.ReplyParts("433", "*", nickname, "Nickname is already in use") - return - } - } - clientsM.RUnlock() - if !RENickname.MatchString(nickname) { - client.ReplyParts("432", "*", cols[1], "Erroneous nickname") - return - } - client.nickname = &nickname - case "USER": - if len(cols) == 1 { - client.ReplyNotEnoughParameters("USER") - return - } - args := strings.SplitN(cols[1], " ", 4) - if len(args) < 4 { - client.ReplyNotEnoughParameters("USER") - return - } - client.username = &args[0] - realname := strings.TrimLeft(args[3], ":") - client.realname = &realname - } - if *client.nickname != "*" && *client.username != "" { - if passwords != nil && *passwords != "" { - if client.password == nil { - client.ReplyParts("462", "You may not register") - client.Close() - return - } - contents, err := ioutil.ReadFile(*passwords) - if err != nil { - log.Fatalf("Can no read passwords file %s: %s", *passwords, err) - return - } - for _, entry := range strings.Split(string(contents), "\n") { - if entry == "" { - continue - } - if lp := strings.Split(entry, ":"); lp[0] == *client.nickname && lp[1] != *client.password { - client.ReplyParts("462", "You may not register") - client.Close() - return - } - } - } - client.registered = true - client.ReplyNicknamed("001", "Hi, welcome to IRC") - client.ReplyNicknamed("002", "Your host is "+*hostname+", running goircd "+Version) - client.ReplyNicknamed("003", "This server was created sometime") - client.ReplyNicknamed("004", *hostname+" goircd o o") - SendLusers(client) - SendMotd(client) - log.Println(client, "logged in") - } -} - -// Register new room in Daemon. Create an object, events sink, save pointers -// to corresponding daemon's places and start room's processor goroutine. -func RoomRegister(name string) (*Room, chan ClientEvent) { - roomNew := NewRoom(name) - roomSink := make(chan ClientEvent) - roomsM.Lock() - rooms[name] = roomNew - roomSinks[roomNew] = roomSink - roomsM.Unlock() - go roomNew.Processor(roomSink) - roomsGroup.Add(1) - return roomNew, roomSink -} - -func HandlerJoin(client *Client, cmd string) { - args := strings.Split(cmd, " ") - rs := strings.Split(args[0], ",") - var keys []string - if len(args) > 1 { - keys = strings.Split(args[1], ",") - } else { - keys = make([]string, 0) - } - var roomExisting *Room - var roomSink chan ClientEvent - var roomNew *Room - for n, room := range rs { - if !RoomNameValid(room) { - client.ReplyNoChannel(room) - continue - } - var key string - if (n < len(keys)) && (keys[n] != "") { - key = keys[n] - } else { - key = "" - } - roomsM.RLock() - for roomExisting, roomSink = range roomSinks { - if room == *roomExisting.name { - roomsM.RUnlock() - if (*roomExisting.key != "") && (*roomExisting.key != key) { - goto Denied - } - roomSink <- ClientEvent{client, EventNew, ""} - goto Joined - } - } - roomsM.RUnlock() - roomNew, roomSink = RoomRegister(room) - log.Println("Room", roomNew, "created") - if key != "" { - roomNew.key = &key - roomNew.StateSave() - } - roomSink <- ClientEvent{client, EventNew, ""} - continue - Denied: - client.ReplyNicknamed("475", room, "Cannot join channel (+k) - bad key") - Joined: - } -} - func Processor(events chan ClientEvent, finished chan struct{}) { var now time.Time + ticker := time.NewTicker(10 * time.Second) go func() { - for { - time.Sleep(10 * time.Second) + for range ticker.C { events <- ClientEvent{eventType: EventTick} } }() - for event := range events { +EventsCycle: + for e := range events { now = time.Now() - client := event.client - switch event.eventType { + client := e.client + switch e.eventType { case EventTick: - clientsM.RLock() + clientsLock.RLock() for c := range clients { if c.recvTimestamp.Add(PingTimeout).Before(now) { - log.Println(c, "ping timeout") + if *verbose { + log.Println(c, "ping timeout") + } c.Close() continue } @@ -327,57 +59,55 @@ func Processor(events chan ClientEvent, finished chan struct{}) { c.Msg("PING :" + *hostname) c.sendTimestamp = time.Now() } else { - log.Println(c, "ping timeout") + if *verbose { + log.Println(c, "ping timeout") + } c.Close() } } } - clientsM.RUnlock() - roomsM.Lock() + clientsLock.RUnlock() + roomsLock.Lock() for rn, r := range rooms { if *statedir == "" && len(r.members) == 0 { - log.Println(rn, "emptied room") + if *verbose { + log.Println(rn, "emptied room") + } delete(rooms, rn) - close(roomSinks[r]) - delete(roomSinks, r) + close(r.events) } } - roomsM.Unlock() + roomsLock.Unlock() case EventTerm: - roomsM.RLock() - for _, sink := range roomSinks { - sink <- ClientEvent{eventType: EventTerm} - } - roomsM.RUnlock() - roomsGroup.Wait() - close(finished) - return + break EventsCycle case EventNew: - clientsM.Lock() + clientsLock.Lock() clients[client] = struct{}{} - clientsM.Unlock() + clientsLock.Unlock() case EventDel: - clientsM.Lock() + clientsLock.Lock() delete(clients, client) - clientsM.Unlock() - roomsM.RLock() - for _, roomSink := range roomSinks { - roomSink <- event + clientsLock.Unlock() + roomsLock.RLock() + for _, r := range rooms { + r.events <- e } - roomsM.RUnlock() + roomsLock.RUnlock() case EventMsg: - cols := strings.SplitN(event.text, " ", 2) + cols := strings.SplitN(e.text, " ", 2) cmd := strings.ToUpper(cols[0]) if *verbose { log.Println(client, "command", cmd) } if cmd == "QUIT" { - log.Println(client, "quit") client.Close() + if *verbose { + log.Println(client, "quit") + } continue } if !client.registered { - ClientRegister(client, cmd, cols) + client.Register(cmd, cols) continue } if client != nil { @@ -386,68 +116,83 @@ func Processor(events chan ClientEvent, finished chan struct{}) { switch cmd { case "AWAY": if len(cols) == 1 { - client.away = nil + client.away = "" client.ReplyNicknamed("305", "You are no longer marked as being away") continue } - msg := strings.TrimLeft(cols[1], ":") - client.away = &msg + client.away = strings.TrimLeft(cols[1], ":") client.ReplyNicknamed("306", "You have been marked as being away") case "JOIN": if len(cols) == 1 || len(cols[1]) < 1 { client.ReplyNotEnoughParameters("JOIN") continue } - HandlerJoin(client, cols[1]) + client.Join(cols[1]) case "LIST": - SendList(client, cols) + client.SendList(cols) case "LUSERS": - SendLusers(client) + client.SendLusers() case "MODE": if len(cols) == 1 || len(cols[1]) < 1 { client.ReplyNotEnoughParameters("MODE") continue } cols = strings.SplitN(cols[1], " ", 2) - if cols[0] == *client.username { - if len(cols) == 1 { - client.Msg("221 " + *client.nickname + " +") - } else { - client.ReplyNicknamed("501", "Unknown MODE flag") - } + if cols[0] == client.username { + client.Msg("221 " + client.nickname + " +w") + // client.ReplyNicknamed("501", "Unknown MODE flag") continue } room := cols[0] - roomsM.RLock() r, found := rooms[room] if !found { client.ReplyNoChannel(room) - roomsM.RUnlock() continue } if len(cols) == 1 { - roomSinks[r] <- ClientEvent{client, EventMode, ""} + r.events <- ClientEvent{client, EventMode, ""} } else { - roomSinks[r] <- ClientEvent{client, EventMode, cols[1]} + r.events <- ClientEvent{client, EventMode, cols[1]} } - roomsM.RUnlock() case "MOTD": - SendMotd(client) + client.SendMotd() + case "NAMES": + rs := make([]*Room, len(cols)) + roomsLock.RLock() + if len(cols) == 0 { + for _, r := range rooms { + rs = append(rs, r) + } + } else { + needed := make(map[string]struct{}, len(rs)) + for _, r := range cols { + needed[r] = struct{}{} + } + for rn, r := range rooms { + if _, found := needed[rn]; found { + rs = append(rs, r) + } + } + } + roomsLock.RUnlock() + for _, r := range rs { + r.SendNames(client) + } case "PART": if len(cols) == 1 || len(cols[1]) < 1 { client.ReplyNotEnoughParameters("PART") continue } rs := strings.Split(cols[1], " ")[0] - roomsM.RLock() + roomsLock.RLock() for _, room := range strings.Split(rs, ",") { if r, found := rooms[room]; found { - roomSinks[r] <- ClientEvent{client, EventDel, ""} + r.events <- ClientEvent{client, EventDel, ""} } else { client.ReplyNoChannel(room) } } - roomsM.RUnlock() + roomsLock.RUnlock() case "PING": if len(cols) == 1 { client.ReplyNicknamed("409", "No origin specified") @@ -466,43 +211,43 @@ func Processor(events chan ClientEvent, finished chan struct{}) { client.ReplyNicknamed("412", "No text to send") continue } - msg := "" target := strings.ToLower(cols[0]) - clientsM.RLock() - for c := range clients { - if *c.nickname == target { - msg = fmt.Sprintf(":%s %s %s %s", client, cmd, *c.nickname, cols[1]) - c.Msg(msg) - if c.away != nil { - client.ReplyNicknamed("301", *c.nickname, *c.away) - } - break - } - } - clientsM.RUnlock() - if msg != "" { - continue - } - roomsM.RLock() + roomsLock.RLock() if r, found := rooms[target]; found { - roomSinks[r] <- ClientEvent{ + r.events <- ClientEvent{ client, EventMsg, cmd + " " + strings.TrimLeft(cols[1], ":"), } - } else { - client.ReplyNoNickChan(target) + roomsLock.RUnlock() + continue + } + roomsLock.RUnlock() + var msg string + clientsLock.RLock() + for c := range clients { + if c.nickname != target { + continue + } + msg = fmt.Sprintf(":%s %s %s %s", client, cmd, c.nickname, cols[1]) + c.Msg(msg) + if c.away != "" { + client.ReplyNicknamed("301", c.nickname, c.away) + } + break + } + clientsLock.RUnlock() + if msg != "" { + continue } - roomsM.RUnlock() + client.ReplyNoNickChan(target) case "TOPIC": if len(cols) == 1 { client.ReplyNotEnoughParameters("TOPIC") continue } cols = strings.SplitN(cols[1], " ", 2) - roomsM.RLock() r, found := rooms[cols[0]] - roomsM.RUnlock() if !found { client.ReplyNoChannel(cols[0]) continue @@ -510,25 +255,20 @@ func Processor(events chan ClientEvent, finished chan struct{}) { var change string if len(cols) > 1 { change = cols[1] - } else { - change = "" } - roomsM.RLock() - roomSinks[r] <- ClientEvent{client, EventTopic, change} - roomsM.RUnlock() + r.events <- ClientEvent{client, EventTopic, change} case "WHO": if len(cols) == 1 || len(cols[1]) < 1 { client.ReplyNotEnoughParameters("WHO") continue } room := strings.Split(cols[1], " ")[0] - roomsM.RLock() - if r, found := rooms[room]; found { - roomSinks[r] <- ClientEvent{client, EventWho, ""} + r, found := rooms[room] + if found { + r.events <- ClientEvent{client, EventWho, ""} } else { client.ReplyNoChannel(room) } - roomsM.RUnlock() case "WHOIS": if len(cols) == 1 || len(cols[1]) < 1 { client.ReplyNotEnoughParameters("WHOIS") @@ -536,31 +276,51 @@ func Processor(events chan ClientEvent, finished chan struct{}) { } cols := strings.Split(cols[1], " ") nicknames := strings.Split(cols[len(cols)-1], ",") - SendWhois(client, nicknames) + client.SendWhois(nicknames) case "ISON": if len(cols) == 1 || len(cols[1]) < 1 { client.ReplyNotEnoughParameters("ISON") continue } - nicksKnown := make(map[string]struct{}) - clientsM.RLock() + nicknamesList := strings.Split(cols[1], " ") + nicknames := make(map[string]bool, len(nicknamesList)) + for _, nickname := range nicknamesList { + nicknames[nickname] = false + } + clientsLock.RLock() for c := range clients { - nicksKnown[*c.nickname] = struct{}{} + if _, exists := nicknames[c.nickname]; exists { + nicknames[c.nickname] = true + } } - clientsM.RUnlock() - var nicksExists []string - for _, nickname := range strings.Split(cols[1], " ") { - if _, exists := nicksKnown[nickname]; exists { - nicksExists = append(nicksExists, nickname) + clientsLock.RUnlock() + nicknamesList = nicknamesList[:0] + for n, exists := range nicknames { + if exists { + nicknamesList = append(nicknamesList, n) } } - client.ReplyNicknamed("303", strings.Join(nicksExists, " ")) + client.ReplyNicknamed("303", strings.Join(nicknamesList, " ")) + case "WALLOPS": + if len(cols) == 1 { + client.ReplyNotEnoughParameters("WALLOPS") + continue + } + cs := make([]*Client, 0, len(clients)) + clientsLock.RLock() + for c := range clients { + if c != client { + cs = append(cs, c) + } + } + clientsLock.RUnlock() + for _, c := range cs { + c.Msg(fmt.Sprintf(":%s NOTICE %s %s", client, c.nickname, cols[1])) + } case "VERSION": var debug string if *verbose { debug = "debug" - } else { - debug = "" } client.ReplyNicknamed("351", fmt.Sprintf("%s.%s %s :", Version, debug, *hostname)) default: @@ -568,4 +328,47 @@ func Processor(events chan ClientEvent, finished chan struct{}) { } } } + ticker.Stop() + + // Notify all clients about shutdown + clientsLock.RLock() + for c := range clients { + c.Msg(fmt.Sprintf( + ":%s NOTICE %s %s", *hostname, c.nickname, + ":Server is shutting down", + )) + c.Close() + } + clientsLock.RUnlock() + + // Read their EventDel + go func() { + for range events { + } + }() + + // Stop room processors + roomsLock.RLock() + for _, r := range rooms { + r.events <- ClientEvent{eventType: EventTerm} + } + roomsLock.RUnlock() + roomsWG.Wait() + + // Wait for either 5sec or all clients quitting + t := time.NewTimer(5 * time.Second) + clientsDone := make(chan struct{}) + go func() { + clientsWG.Wait() + close(clientsDone) + }() + select { + case <-t.C: + case <-clientsDone: + } + if !t.Stop() { + <-t.C + } + close(events) + close(finished) } diff --git a/daemon_test.go b/daemon_test.go index 599cbf6..76d6203 100644 --- a/daemon_test.go +++ b/daemon_test.go @@ -28,13 +28,14 @@ func TestRegistrationWorkflow(t *testing.T) { host := "foohost" hostname = &host events := make(chan ClientEvent) + finished := make(chan struct{}) defer func() { events <- ClientEvent{eventType: EventTerm} + <-finished }() - go Processor(events, make(chan struct{})) + go Processor(events, finished) conn := NewTestingConn() - client := NewClient(conn) - go client.Processor(events) + client := NewClient(conn, events) conn.inbound <- "UNEXISTENT CMD" // should receive nothing on this conn.inbound <- "NICK" @@ -54,7 +55,7 @@ func TestRegistrationWorkflow(t *testing.T) { if r := <-conn.outbound; r != ":foohost 461 meinick USER :Not enough parameters\r\n" { t.Fatal("461 for USER", r) } - if (*client.nickname != "meinick") || client.registered { + if (client.nickname != "meinick") || client.registered { t.Fatal("NICK saved") } @@ -63,7 +64,7 @@ func TestRegistrationWorkflow(t *testing.T) { t.Fatal("461 again for USER", r) } - SendLusers(client) + client.SendLusers() if r := <-conn.outbound; !strings.Contains(r, "There are 0 users") { t.Fatal("LUSERS", r) } @@ -87,7 +88,7 @@ func TestRegistrationWorkflow(t *testing.T) { if r := <-conn.outbound; !strings.Contains(r, ":foohost 422") { t.Fatal("422 after registration", r) } - if (*client.username != "1") || (*client.realname != "4 5") || !client.registered { + if (client.username != "1") || (client.realname != "4 5") || !client.registered { t.Fatal("client register") } @@ -98,7 +99,7 @@ func TestRegistrationWorkflow(t *testing.T) { t.Fatal("reply for unexistent command", r) } - SendLusers(client) + client.SendLusers() if r := <-conn.outbound; !strings.Contains(r, "There are 1 users") { t.Fatal("1 users logged in", r) } @@ -122,11 +123,14 @@ func TestMotd(t *testing.T) { conn := NewTestingConn() host := "foohost" hostname = &host - client := NewClient(conn) + client := NewClient(conn, make(chan ClientEvent, 2)) + defer func() { + client.Close() + }() motdName := fd.Name() motd = &motdName - SendMotd(client) + client.SendMotd() if r := <-conn.outbound; !strings.HasPrefix(r, ":foohost 375") { t.Fatal("MOTD start", r) } diff --git a/events.go b/events.go deleted file mode 100644 index 283b5f7..0000000 --- a/events.go +++ /dev/null @@ -1,119 +0,0 @@ -/* -goircd -- minimalistic simple Internet Relay Chat (IRC) server -Copyright (C) 2014-2020 Sergey Matveev - -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 . -*/ - -package main - -import ( - "fmt" - "io/ioutil" - "log" - "os" - "path" - "time" -) - -const ( - EventNew = iota - EventDel = iota - EventMsg = iota - EventTopic = iota - EventWho = iota - EventMode = iota - EventTerm = iota - EventTick = iota - FormatMsg = "[%s] <%s> %s\n" - FormatMeta = "[%s] * %s %s\n" -) - -var ( - logSink chan LogEvent = make(chan LogEvent) - stateSink chan StateEvent = make(chan StateEvent) -) - -// Client events going from each of client -// They can be either NEW, DEL or unparsed MSG -type ClientEvent struct { - client *Client - eventType int - text string -} - -func (m ClientEvent) String() string { - return string(m.eventType) + ": " + m.client.String() + ": " + m.text -} - -// Logging in-room events -// Intended to tell when, where and who send a message or meta command -type LogEvent struct { - where string - who string - what string - meta bool -} - -// Logging events logger itself -// Each room's events are written to separate file in logdir -// Events include messages, topic and keys changes, joining and leaving -func Logger(logdir string, events <-chan LogEvent) { - mode := os.O_CREATE | os.O_WRONLY | os.O_APPEND - perm := os.FileMode(0660) - var format string - var logfile string - var fd *os.File - var err error - for event := range events { - logfile = path.Join(logdir, event.where+".log") - fd, err = os.OpenFile(logfile, mode, perm) - if err != nil { - log.Println("Can not open logfile", logfile, err) - continue - } - if event.meta { - format = FormatMeta - } else { - format = FormatMsg - } - _, err = fd.WriteString(fmt.Sprintf(format, time.Now(), event.who, event.what)) - fd.Close() - if err != nil { - log.Println("Error writing to logfile", logfile, err) - } - } -} - -type StateEvent struct { - where string - topic string - key string -} - -// Room state events saver -// Room states shows that either topic or key has been changed -// Each room's state is written to separate file in statedir -func StateKeeper(statedir string, events <-chan StateEvent) { - var fn string - var data string - var err error - for event := range events { - fn = path.Join(statedir, event.where) - data = event.topic + "\n" + event.key + "\n" - err = ioutil.WriteFile(fn, []byte(data), os.FileMode(0660)) - if err != nil { - log.Printf("Can not write statefile %s: %v", fn, err) - } - } -} diff --git a/goircd.go b/goircd.go index deb6e35..f8aad49 100644 --- a/goircd.go +++ b/goircd.go @@ -23,114 +23,213 @@ import ( "io/ioutil" "log" "net" + "os" + "os/signal" "path" "path/filepath" + "strconv" "strings" + "syscall" ) -const Version = "1.8.2" +const ( + Version = "1.9.0" + + StateTopicFilename = "topic" + StateKeyFilename = "key" + + EventNew = iota + EventDel = iota + EventMsg = iota + EventTopic = iota + EventWho = iota + EventMode = iota + EventTerm = iota + EventTick = iota +) + +type ClientEvent struct { + client *Client + eventType int + text string +} + +type StateEvent struct { + where string + topic string + key string +} var ( - hostname = flag.String("hostname", "localhost", "Hostname") - bind = flag.String("bind", ":6667", "Address to bind to") - motd = flag.String("motd", "", "Path to MOTD file") - logdir = flag.String("logdir", "", "Absolute path to directory for logs") - statedir = flag.String("statedir", "", "Absolute path to directory for states") - passwords = flag.String("passwords", "", "Optional path to passwords file") - tlsBind = flag.String("tlsbind", "", "TLS address to bind to") - tlsPEM = flag.String("tlspem", "", "Path to TLS certificat+key PEM file") - verbose = flag.Bool("v", false, "Enable verbose logging.") + hostname = flag.String("hostname", "localhost", "hostname") + bind = flag.String("bind", "[::1]:6667", "address to bind to") + cloak = flag.String("cloak", "", "cloak user's host with the given hostname") + motd = flag.String("motd", "", "path to MOTD file") + logdir = flag.String("logdir", "", "absolute path to directory for logs") + statedir = flag.String("statedir", "", "absolute path to directory for states") + passwords = flag.String("passwd", "", "optional path to passwords file") + tlsBind = flag.String("tlsbind", "", "TLS address to bind to") + tlsPEM = flag.String("tlspem", "", "path to TLS certificat+key PEM file") + permStateDirS = flag.String("perm-state-dir", "755", "state directory permissions") + permStateFileS = flag.String("perm-state-file", "600", "state files permissions") + permLogFileS = flag.String("perm-log-file", "644", "log files permissions") + timestamped = flag.Bool("timestamped", false, "enable timestamps on stderr messages") + verbose = flag.Bool("verbose", false, "enable verbose logging") + debug = flag.Bool("debug", false, "enable debug (traffic) logging") + + permStateDir os.FileMode + permStateFile os.FileMode + permLogFile os.FileMode + + stateSink chan StateEvent = make(chan StateEvent) ) -func listenerLoop(sock net.Listener, events chan ClientEvent) { +func permParse(s string) os.FileMode { + r, err := strconv.ParseUint(s, 8, 16) + if err != nil { + log.Fatalln(err) + } + return os.FileMode(r) +} + +func listenerLoop(ln net.Listener, events chan ClientEvent) { for { - conn, err := sock.Accept() + conn, err := ln.Accept() if err != nil { - log.Println("Error during accepting connection", err) + log.Println("error during accept", err) continue } - client := NewClient(conn) - go client.Processor(events) + NewClient(conn, events) } } -func Run() { - events := make(chan ClientEvent) - log.SetFlags(log.Ldate | log.Lmicroseconds | log.Lshortfile) +func main() { + flag.Parse() + permStateDir = permParse(*permStateDirS) + permStateFile = permParse(*permStateFileS) + permLogFile = permParse(*permLogFileS) + + if *timestamped { + log.SetFlags(log.Ldate | log.Lmicroseconds | log.Lshortfile) + } else { + log.SetFlags(log.Lshortfile) + } + log.SetOutput(os.Stdout) if *logdir == "" { // Dummy logger go func() { - for _ = range logSink { + for range logSink { } }() } else { if !path.IsAbs(*logdir) { - log.Fatalln("Need absolute path for logdir") + log.Fatalln("need absolute path for logdir") } go Logger(*logdir, logSink) - log.Println(*logdir, "logger initialized") } - log.Println("goircd " + Version + " is starting") if *statedir == "" { // Dummy statekeeper go func() { - for _ = range stateSink { + for range stateSink { } }() } else { if !path.IsAbs(*statedir) { - log.Fatalln("Need absolute path for statedir") + log.Fatalln("need absolute path for statedir") } states, err := filepath.Glob(path.Join(*statedir, "#*")) if err != nil { - log.Fatalln("Can not read statedir", err) + log.Fatalln("can not read statedir", err) } for _, state := range states { - buf, err := ioutil.ReadFile(state) + buf, err := ioutil.ReadFile(path.Join(state, StateTopicFilename)) if err != nil { - log.Fatalf("Can not read state %s: %v", state, err) + log.Fatalf( + "can not read state %s/%s: %v", + state, StateTopicFilename, err, + ) } - room, _ := RoomRegister(path.Base(state)) - contents := strings.Split(string(buf), "\n") - if len(contents) < 2 { - log.Printf("State corrupted for %s: %q", *room.name, contents) + room := RoomRegister(path.Base(state)) + room.topic = strings.TrimRight(string(buf), "\n") + buf, err = ioutil.ReadFile(path.Join(state, StateKeyFilename)) + if err == nil { + room.key = strings.TrimRight(string(buf), "\n") } else { - room.topic = &contents[0] - room.key = &contents[1] - log.Println("Loaded state for room", *room.name) + if !os.IsNotExist(err) { + log.Fatalf( + "can not read state %s/%s: %v", + state, StateKeyFilename, err, + ) + } } + log.Println("loaded state for room:", room.name) } - go StateKeeper(*statedir, stateSink) - log.Println(*statedir, "statekeeper initialized") + + go func() { + for event := range stateSink { + statePath := path.Join(*statedir, event.where) + if _, err := os.Stat(statePath); os.IsNotExist(err) { + if err := os.Mkdir(statePath, permStateDir); err != nil { + log.Printf("can not create state %s: %v", statePath, err) + continue + } + } + + topicPath := path.Join(statePath, StateTopicFilename) + if err := ioutil.WriteFile( + topicPath, + []byte(event.topic+"\n"), + permStateFile, + ); err != nil { + log.Printf("can not write statefile %s: %v", topicPath, err) + continue + } + + keyPath := path.Join(statePath, StateKeyFilename) + if err := ioutil.WriteFile( + keyPath, + []byte(event.key+"\n"), + permStateFile, + ); err != nil { + log.Printf("can not write statefile %s: %v", keyPath, err) + } + } + }() } + events := make(chan ClientEvent) if *bind != "" { - listener, err := net.Listen("tcp", *bind) + ln, err := net.Listen("tcp", *bind) if err != nil { - log.Fatalf("Can not listen on %s: %v", *bind, err) + log.Fatalf("can not listen on %s: %v", *bind, err) } - log.Println("Raw listening on", *bind) - go listenerLoop(listener, events) + go listenerLoop(ln, events) } if *tlsBind != "" { cert, err := tls.LoadX509KeyPair(*tlsPEM, *tlsPEM) if err != nil { - log.Fatalf("Could not load TLS keys from %s: %s", *tlsPEM, err) + log.Fatalf("can not load TLS keys from %s: %s", *tlsPEM, err) } config := tls.Config{Certificates: []tls.Certificate{cert}} - listenerTLS, err := tls.Listen("tcp", *tlsBind, &config) + ln, err := tls.Listen("tcp", *tlsBind, &config) if err != nil { - log.Fatalf("Can not listen on %s: %v", *tlsBind, err) + log.Fatalf("can not listen on %s: %v", *tlsBind, err) } - log.Println("TLS listening on", *tlsBind) - go listenerLoop(listenerTLS, events) + go listenerLoop(ln, events) } - Processor(events, make(chan struct{})) -} + log.Println("goircd", Version, "started") -func main() { - flag.Parse() - Run() + needsShutdown := make(chan os.Signal, 0) + signal.Notify(needsShutdown, syscall.SIGTERM, syscall.SIGINT) + go func() { + <-needsShutdown + events <- ClientEvent{eventType: EventTerm} + log.Println("goircd shutting down") + }() + + finished := make(chan struct{}) + go Processor(events, finished) + <-finished } diff --git a/log.go b/log.go new file mode 100644 index 0000000..08367cb --- /dev/null +++ b/log.go @@ -0,0 +1,72 @@ +/* +goircd -- minimalistic simple Internet Relay Chat (IRC) server +Copyright (C) 2014-2020 Sergey Matveev + +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 . +*/ + +package main + +import ( + "fmt" + "log" + "os" + "path" + "time" +) + +const ( + FormatMsg = "[%s] <%s> %s\n" + FormatMeta = "[%s] * %s %s\n" +) + +var logSink chan LogEvent = make(chan LogEvent) + +type LogEvent struct { + where string + who string + what string + meta bool +} + +func Logger(logdir string, events <-chan LogEvent) { + mode := os.O_CREATE | os.O_WRONLY | os.O_APPEND + perm := os.FileMode(permLogFile) + var format string + var logfile string + var fd *os.File + var err error + for event := range events { + logfile = path.Join(logdir, event.where+".log") + fd, err = os.OpenFile(logfile, mode, perm) + if err != nil { + log.Println("can not open logfile", logfile, err) + continue + } + if event.meta { + format = FormatMeta + } else { + format = FormatMsg + } + _, err = fd.WriteString(fmt.Sprintf( + format, + time.Now().Format(time.RFC3339), + event.who, + event.what, + )) + fd.Close() + if err != nil { + log.Println("error writing to logfile", logfile, err) + } + } +} diff --git a/room.go b/room.go index 76b4c42..cc2b0d7 100644 --- a/room.go +++ b/room.go @@ -26,225 +26,213 @@ import ( "sync" ) -var ( - RERoom = regexp.MustCompile("^#[^\x00\x07\x0a\x0d ,:/]{1,200}$") -) - -// Sanitize room's name. It can consist of 1 to 50 ASCII symbols -// with some exclusions. All room names will have "#" prefix. -func RoomNameValid(name string) bool { - return RERoom.MatchString(name) -} - type Room struct { - name *string - topic *string - key *string + name string + topic string + key string members map[*Client]struct{} + events chan ClientEvent sync.RWMutex } -func (room *Room) String() (name string) { - room.RLock() - name = *room.name - room.RUnlock() - return -} +var ( + RERoom = regexp.MustCompile("^#[^\x00\x07\x0a\x0d ,:/]{1,200}$") -func NewRoom(name string) *Room { - topic := "" - key := "" - return &Room{ - name: &name, - topic: &topic, - key: &key, - members: make(map[*Client]struct{}), + rooms map[string]*Room = make(map[string]*Room) + roomsLock sync.RWMutex + roomsWG sync.WaitGroup +) + +func (r *Room) SendTopic(c *Client) { + t := r.topic + if t == "" { + c.ReplyNicknamed("331", r.name, "No topic is set") + } else { + c.ReplyNicknamed("332", r.name, t) } } -func (room *Room) SendTopic(client *Client) { - room.RLock() - if *room.topic == "" { - client.ReplyNicknamed("331", room.String(), "No topic is set") - } else { - client.ReplyNicknamed("332", room.String(), *room.topic) +func (r *Room) SendNames(c *Client) { + allowed := false + if r.key == "" { + allowed = true + } else if _, isMember := r.members[c]; isMember { + allowed = true + } + if !allowed { + c.ReplyNicknamed("475", r.name, "Cannot join channel (+k)") + return } - room.RUnlock() + r.RLock() + nicknames := make([]string, 0, len(r.members)) + for member := range r.members { + nicknames = append(nicknames, member.nickname) + } + r.RUnlock() + sort.Strings(nicknames) + maxLen := 512 - len(*hostname) - 2 - 2 + +MoreNicknames: + lenAll := 0 + lenName := 0 + for i, n := range nicknames { + lenName = len(n) + 1 + if lenAll+lenName >= maxLen { + c.ReplyNicknamed("353", "=", r.name, strings.Join(nicknames[:i-1], " ")) + nicknames = nicknames[i:] + goto MoreNicknames + } + lenAll += lenName + } + if len(nicknames) > 0 { + c.ReplyNicknamed("353", "=", r.name, strings.Join(nicknames, " ")) + } + c.ReplyNicknamed("366", r.name, "End of NAMES list") } -// Send message to all room's subscribers, possibly excluding someone. -func (room *Room) Broadcast(msg string, clientToIgnore ...*Client) { - room.RLock() - for member := range room.members { - if (len(clientToIgnore) > 0) && member == clientToIgnore[0] { +func (r *Room) Broadcast(msg string, excludes ...*Client) { + var exclude *Client + if len(excludes) > 0 { + exclude = excludes[0] + } + r.RLock() + for member := range r.members { + if member == exclude { continue } member.Msg(msg) } - room.RUnlock() + r.RUnlock() } -func (room *Room) StateSave() { - room.RLock() - stateSink <- StateEvent{room.String(), *room.topic, *room.key} - room.RUnlock() +func (r *Room) StateSave() { + stateSink <- StateEvent{r.name, r.topic, r.key} } -func (room *Room) Processor(events <-chan ClientEvent) { - var client *Client - for event := range events { - client = event.client - switch event.eventType { +func (r *Room) Processor(events <-chan ClientEvent) { + for e := range events { + c := e.client + switch e.eventType { case EventTerm: - roomsGroup.Done() + roomsWG.Done() return case EventNew: - room.Lock() - room.members[client] = struct{}{} + r.Lock() + r.members[c] = struct{}{} + r.Unlock() if *verbose { - log.Println(client, "joined", room.name) + log.Println(c, "joined", r.name) } - room.Unlock() - room.SendTopic(client) - room.Broadcast(fmt.Sprintf(":%s JOIN %s", client, room.String())) - logSink <- LogEvent{room.String(), *client.nickname, "joined", true} - nicknames := make([]string, 0) - room.RLock() - for member := range room.members { - nicknames = append(nicknames, *member.nickname) - } - room.RUnlock() - sort.Strings(nicknames) - client.ReplyNicknamed("353", "=", room.String(), strings.Join(nicknames, " ")) - client.ReplyNicknamed("366", room.String(), "End of NAMES list") + r.SendTopic(c) + r.Broadcast(fmt.Sprintf(":%s JOIN %s", c, r.name)) + logSink <- LogEvent{r.name, c.nickname, "joined", true} + r.SendNames(c) case EventDel: - room.RLock() - if _, subscribed := room.members[client]; !subscribed { - client.ReplyNicknamed("442", room.String(), "You are not on that channel") - room.RUnlock() + if _, subscribed := r.members[c]; !subscribed { + c.ReplyNicknamed("442", r.name, "You are not on that channel") continue } - room.RUnlock() - room.Lock() - delete(room.members, client) - room.Unlock() - room.RLock() - msg := fmt.Sprintf(":%s PART %s :%s", client, room.String(), *client.nickname) - room.Broadcast(msg) - logSink <- LogEvent{room.String(), *client.nickname, "left", true} - room.RUnlock() + msg := fmt.Sprintf(":%s PART %s :%s", c, r.name, c.nickname) + r.Broadcast(msg) + r.Lock() + delete(r.members, c) + r.Unlock() + logSink <- LogEvent{r.name, c.nickname, "left", true} + if *verbose { + log.Println(c, "left", r.name) + } case EventTopic: - room.RLock() - if _, subscribed := room.members[client]; !subscribed { - client.ReplyParts("442", room.String(), "You are not on that channel") - room.RUnlock() + if _, subscribed := r.members[c]; !subscribed { + c.ReplyParts("442", r.name, "You are not on that channel") continue } - if event.text == "" { - room.SendTopic(client) - room.RUnlock() + if e.text == "" { + r.SendTopic(c) continue } - room.RUnlock() - topic := strings.TrimLeft(event.text, ":") - room.Lock() - room.topic = &topic - room.Unlock() - room.RLock() - msg := fmt.Sprintf(":%s TOPIC %s :%s", client, room.String(), *room.topic) - room.Broadcast(msg) - logSink <- LogEvent{ - room.String(), - *client.nickname, - "set topic to " + *room.topic, - true, - } - room.RUnlock() - room.StateSave() + topic := strings.TrimLeft(e.text, ":") + r.topic = topic + msg := fmt.Sprintf(":%s TOPIC %s :%s", c, r.name, r.topic) + r.Broadcast(msg) + logSink <- LogEvent{r.name, c.nickname, "set topic to " + r.topic, true} + r.StateSave() case EventWho: - room.RLock() - for m := range room.members { - client.ReplyNicknamed( + r.RLock() + for m := range r.members { + c.ReplyNicknamed( "352", - room.String(), - *m.username, + r.name, + m.username, m.Host(), *hostname, - *m.nickname, + m.nickname, "H", - "0 "+*m.realname, + "0 "+m.realname, ) } - client.ReplyNicknamed("315", room.String(), "End of /WHO list") - room.RUnlock() + c.ReplyNicknamed("315", r.name, "End of /WHO list") + r.RUnlock() case EventMode: - room.RLock() - if event.text == "" { - mode := "+" - if *room.key != "" { + if e.text == "" { + mode := "+n" + if r.key != "" { mode = mode + "k" } - client.Msg(fmt.Sprintf("324 %s %s %s", *client.nickname, room.String(), mode)) - room.RUnlock() + c.Msg(fmt.Sprintf("324 %s %s %s", c.nickname, r.name, mode)) continue } - if strings.HasPrefix(event.text, "b") { - client.ReplyNicknamed("368", room.String(), "End of channel ban list") - room.RUnlock() + if strings.HasPrefix(e.text, "b") { + c.ReplyNicknamed("368", r.name, "End of channel ban list") continue } - if strings.HasPrefix(event.text, "-k") || strings.HasPrefix(event.text, "+k") { - if _, subscribed := room.members[client]; !subscribed { - client.ReplyParts("442", room.String(), "You are not on that channel") - room.RUnlock() + if strings.HasPrefix(e.text, "-k") || strings.HasPrefix(e.text, "+k") { + if _, subscribed := r.members[c]; !subscribed { + c.ReplyParts("442", r.name, "You are not on that channel") continue } } else { - client.ReplyNicknamed("472", event.text, "Unknown MODE flag") - room.RUnlock() + c.ReplyNicknamed("472", e.text, "Unknown MODE flag") continue } - room.RUnlock() var msg string var msgLog string - if strings.HasPrefix(event.text, "+k") { - cols := strings.Split(event.text, " ") + if strings.HasPrefix(e.text, "+k") { + cols := strings.Split(e.text, " ") if len(cols) == 1 { - client.ReplyNotEnoughParameters("MODE") + c.ReplyNotEnoughParameters("MODE") continue } - room.Lock() - room.key = &cols[1] - msg = fmt.Sprintf(":%s MODE %s +k %s", client, *room.name, *room.key) - msgLog = "set channel key to " + *room.key - room.Unlock() + r.key = cols[1] + msg = fmt.Sprintf(":%s MODE %s +k %s", c, r.name, r.key) + msgLog = "set channel key" } else { - key := "" - room.Lock() - room.key = &key - msg = fmt.Sprintf(":%s MODE %s -k", client, *room.name) - room.Unlock() + r.key = "" + msg = fmt.Sprintf(":%s MODE %s -k", c, r.name) msgLog = "removed channel key" } - room.Broadcast(msg) - logSink <- LogEvent{room.String(), *client.nickname, msgLog, true} - room.StateSave() + r.Broadcast(msg) + logSink <- LogEvent{r.name, c.nickname, msgLog, true} + r.StateSave() case EventMsg: - sep := strings.Index(event.text, " ") - room.Broadcast(fmt.Sprintf( - ":%s %s %s :%s", - client, - event.text[:sep], - room.String(), - event.text[sep+1:]), - client, - ) - logSink <- LogEvent{ - room.String(), - *client.nickname, - event.text[sep+1:], - false, - } + sep := strings.Index(e.text, " ") + r.Broadcast(fmt.Sprintf( + ":%s %s %s :%s", c, e.text[:sep], r.name, e.text[sep+1:], + ), c) + logSink <- LogEvent{r.name, c.nickname, e.text[sep+1:], false} } } } + +func RoomRegister(name string) *Room { + r := &Room{ + name: name, + members: make(map[*Client]struct{}), + events: make(chan ClientEvent), + } + roomsLock.Lock() + roomsWG.Add(1) + rooms[name] = r + roomsLock.Unlock() + go r.Processor(r.events) + return r +} diff --git a/room_test.go b/room_test.go index 9694818..97c202b 100644 --- a/room_test.go +++ b/room_test.go @@ -46,10 +46,9 @@ func TestTwoUsers(t *testing.T) { host := "foohost" hostname = &host events := make(chan ClientEvent) - roomsM.Lock() + roomsLock.Lock() rooms = make(map[string]*Room) - roomSinks = make(map[*Room]chan ClientEvent) - roomsM.Unlock() + roomsLock.Unlock() clients = make(map[*Client]struct{}) finished := make(chan struct{}) go Processor(events, finished) @@ -60,10 +59,8 @@ func TestTwoUsers(t *testing.T) { conn1 := NewTestingConn() conn2 := NewTestingConn() - client1 := NewClient(conn1) - client2 := NewClient(conn2) - go client1.Processor(events) - go client2.Processor(events) + client1 := NewClient(conn1, events) + NewClient(conn2, events) conn1.inbound <- "NICK nick1\r\nUSER foo1 bar1 baz1 :Long name1" conn2.inbound <- "NICK nick2\r\nUSER foo2 bar2 baz2 :Long name2" @@ -72,7 +69,7 @@ func TestTwoUsers(t *testing.T) { <-conn2.outbound } - SendLusers(client1) + client1.SendLusers() if r := <-conn1.outbound; !strings.Contains(r, "There are 2 users") { t.Fatal("LUSERS", r) } @@ -139,7 +136,6 @@ func TestJoin(t *testing.T) { events := make(chan ClientEvent) rooms = make(map[string]*Room) clients = make(map[*Client]struct{}) - roomSinks = make(map[*Room]chan ClientEvent) finished := make(chan struct{}) go Processor(events, finished) defer func() { @@ -147,8 +143,7 @@ func TestJoin(t *testing.T) { <-finished }() conn := NewTestingConn() - client := NewClient(conn) - go client.Processor(events) + NewClient(conn, events) conn.inbound <- "NICK nick2\r\nUSER foo2 bar2 baz2 :Long name2" for i := 0; i < 6; i++ { @@ -183,14 +178,14 @@ func TestJoin(t *testing.T) { for i := 0; i < 4*2; i++ { <-conn.outbound } - roomsM.RLock() + roomsLock.RLock() if _, ok := rooms["#bar"]; !ok { t.Fatal("#bar does not exist") } if _, ok := rooms["#baz"]; !ok { t.Fatal("#baz does not exist") } - roomsM.RUnlock() + roomsLock.RUnlock() if r := <-logSink; (r.what != "joined") || (r.where != "#bar") || (r.who != "nick2") || (r.meta != true) { t.Fatal("invalid join log event #bar", r) } @@ -202,14 +197,14 @@ func TestJoin(t *testing.T) { for i := 0; i < 4*2; i++ { <-conn.outbound } - roomsM.RLock() - if *rooms["#barenc"].key != "key1" { + roomsLock.RLock() + if rooms["#barenc"].key != "key1" { t.Fatal("no room with key1") } - if *rooms["#bazenc"].key != "key2" { + if rooms["#bazenc"].key != "key2" { t.Fatal("no room with key2") } - roomsM.RUnlock() + roomsLock.RUnlock() if r := <-logSink; (r.what != "joined") || (r.where != "#barenc") || (r.who != "nick2") || (r.meta != true) { t.Fatal("invalid join log event #barenc", r) } @@ -227,11 +222,11 @@ func TestJoin(t *testing.T) { if r := <-conn.outbound; r != ":nick2!foo2@someclient MODE #barenc -k\r\n" { t.Fatal("remove #barenc key", r) } - roomsM.RLock() - if *rooms["#barenc"].key != "" { + roomsLock.RLock() + if rooms["#barenc"].key != "" { t.Fatal("removing key from #barenc") } - roomsM.RUnlock() + roomsLock.RUnlock() if r := <-logSink; (r.what != "removed channel key") || (r.where != "#barenc") || (r.who != "nick2") || (r.meta != true) { t.Fatal("removed channel key log", r) } @@ -240,6 +235,9 @@ func TestJoin(t *testing.T) { } conn.inbound <- "PART #bazenc\r\nMODE #bazenc -k" + if r := <-conn.outbound; r != ":nick2!foo2@someclient PART #bazenc :nick2\r\n" { + t.Fatal("part", r) + } if r := <-conn.outbound; r != ":foohost 442 #bazenc :You are not on that channel\r\n" { t.Fatal("not on that channel", r) } @@ -256,7 +254,7 @@ func TestJoin(t *testing.T) { if r := <-conn.outbound; r != ":nick2!foo2@someclient MODE #barenc +k newkey\r\n" { t.Fatal("+k MODE setting", r) } - if r := <-logSink; (r.what != "set channel key to newkey") || (r.where != "#barenc") || (r.who != "nick2") || (r.meta != true) { + if r := <-logSink; (r.what != "set channel key") || (r.where != "#barenc") || (r.who != "nick2") || (r.meta != true) { t.Fatal("set channel key", r) } if r := <-stateSink; (r.topic != "") || (r.where != "#barenc") || (r.key != "newkey") { -- 2.44.0