From 73aa108bf4cfae8bb25c96e15506a3ce2a969b61 Mon Sep 17 00:00:00 2001 From: meera Date: Sun, 12 Jul 2026 20:19:15 +0300 Subject: [PATCH] Add remote starter files and local code --- build.sh | 1 + go.mod | 5 + go.sum | 2 + http.go | 19 ++ main.go | 719 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ motd.txt | 4 + 6 files changed, 750 insertions(+) create mode 100755 build.sh create mode 100644 go.mod create mode 100644 go.sum create mode 100644 http.go create mode 100644 main.go create mode 100644 motd.txt diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..4bb5338 --- /dev/null +++ b/build.sh @@ -0,0 +1 @@ +go build -o ircserver -ldflags "-s -w" -trimpath main.go http.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7f8861b --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module ircserve + +go 1.26.3 + +require github.com/ergochat/irc-go v0.6.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f1074c0 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/ergochat/irc-go v0.6.0 h1:Y0AGV76aeihJfCtLaQh+OyJKFiKGrYC0VTkeMZ6XW28= +github.com/ergochat/irc-go v0.6.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0= diff --git a/http.go b/http.go new file mode 100644 index 0000000..cb21f54 --- /dev/null +++ b/http.go @@ -0,0 +1,19 @@ +package main + +import ( + "fmt" + "net/http" +) + +func startHTTP(cfg Config) { + htport := cfg.Webapiport + http.HandleFunc("/admin/", handleAdmin) + fmt.Printf("[web] starting API on port %d\n", htport) + http.ListenAndServe(fmt.Sprintf(":%d", htport), nil) +} +func handleAdmin(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + default: + http.NotFound(w, r) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..1d3f7ba --- /dev/null +++ b/main.go @@ -0,0 +1,719 @@ +// IRC server +// this is a basic ad-hoc server with a few commands +// works for small group chats and stuff +// now has SASL support +package main + +import ( + "bufio" + "encoding/base64" + "encoding/json" + "fmt" + "log" + "net" + "os" + "strings" + "sync" + "time" + + "github.com/ergochat/irc-go/ircmsg" +) + +type Client struct { + nick string + user string + conn net.Conn + host string + channels map[string]*Channel + registered bool + saslRequired bool + saslComplete bool + awaitingSASLData bool +} + +type Channel struct { + name string + description string + members map[string]*Client + ops map[string]bool + mu sync.RWMutex +} +type Channelconfig struct { + Channel string `json:"channel"` + Description string `json:"description"` +} +type Config struct { + Port int `json:"port"` + Webapiport int `json:"webapiport"` + Motd string `json:"motd"` + Channels []Channelconfig `json:"channels"` + Ops []string `json:"ops"` +} + +var ( + channels = make(map[string]*Channel) + clients = make(map[string]*Client) + globalOps = make(map[string]bool) + chMu sync.Mutex // mutex for channels + cliMu sync.RWMutex // mutex for clients + motdString []string + motdFile string +) + +func loadConfig() (*Config, error) { + var conf Config + jsdata, err := os.ReadFile("config.json") + if err != nil { + return 0, err + } + err = json.Unmarshal(jsdata, &conf) + if err != nil { + return 0, err + } + for _, cfg := range conf.Channels { + addChannel(cfg.Channel, cfg.Description) + } + for _, op := range conf.Ops { + cleanedNick := strings.TrimPrefix(op, "@") + globalOps[cleanedNick] = true + } + motdFile = conf.Motd + return conf.Port, nil +} +func loadMOTD(filename string) error { + file, err := os.Open(filename) + if err != nil { + return err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + text := scanner.Text() + motdString = append(motdString, text) + } + if err := scanner.Err(); err != nil { + return err + } + return nil +} +func main() { + cfg, err := loadConfig() + if err != nil { + log.Fatalf("could not load config file: %v\n", err) + } + errmotd := loadMOTD(motdFile) + if errmotd != nil { + log.Fatalf("could not load message of the day file: %v", err) + } + ln, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.Port)) + if err != nil { + log.Fatalf("Error listening: %v\n", err) + } + log.Printf("IRC server listening on port %d\n", cfg.Port) + go startHTTP(*cfg) + for { + conn, err := ln.Accept() + if err != nil { + log.Printf("Error accepting: %v", err) + continue + } + go handleConn(conn) + } +} + +func handleConn(conn net.Conn) { + fmt.Printf("connection from %s\n", conn.RemoteAddr().String()) + defer conn.Close() + host, _, _ := net.SplitHostPort(conn.RemoteAddr().String()) + + reader := bufio.NewReader(conn) + client := &Client{ + conn: conn, + host: host, + channels: make(map[string]*Channel), + registered: false, + } + defer client.cleanup() + for { + line, err := reader.ReadString('\n') + if err != nil { + return + } + line = strings.TrimRight(line, "\r\n") + msg, err := ircmsg.ParseLine(line) + if err != nil { + continue + } + //fmt.Printf(" %s\n", line) + switch strings.ToUpper(msg.Command) { + case "NICK": + if len(msg.Params) > 0 { + newNick := msg.Params[0] + if nicknameExists(newNick) { + client.fwrite(":server 433 * %s :Nickname is already in use\r\n", newNick) + } else { + oldNick := client.nick + if oldNick != "" { + // nick change + cliMu.Lock() + delete(clients, oldNick) + clients[newNick] = client + cliMu.Unlock() + client.nick = newNick + for _, ch := range client.channels { + broadcastNames(ch) + } + for _, other := range clients { + if other != client { + other.fwrite(":%s NICK %s\r\n", oldNick, newNick) + } + } + } else { + client.nick = newNick + cliMu.Lock() + clients[newNick] = client + cliMu.Unlock() + } + client.checkRegistration() + } + } + case "CAP": + if len(msg.Params) < 1 { + client.fwrite(":server 461 %s CAP :Not enough parameters\r\n", client.nick) + continue + } + subcmd := strings.ToUpper(msg.Params[0]) + switch subcmd { + case "LS": + // explicitly tell we only support PLAIN SASL + client.fwrite(":server CAP * LS :sasl=PLAIN\r\n") + + case "REQ": + if len(msg.Params) >= 2 && strings.Contains(strings.ToLower(msg.Params[1]), "sasl") { + client.saslRequired = true + client.fwrite(":server CAP * ACK :sasl\r\n") + } else { + client.fwrite(":server CAP * NAK :%s\r\n", msg.Params[1]) + } + + case "END": + client.checkRegistration() + + default: + client.fwrite(":server 410 %s CAP :Invalid CAP subcommand\r\n", client.nick) + } + case "AUTHENTICATE": + if len(msg.Params) == 0 { + client.fwrite(":server 461 %s AUTHENTICATE :Not enough parameters\r\n", client.nick) + continue + } + arg := msg.Params[0] + + if strings.ToUpper(arg) == "PLAIN" { + client.awaitingSASLData = true + client.fwrite("AUTHENTICATE +\r\n") + continue + } + + if client.awaitingSASLData { + data, err := base64.StdEncoding.DecodeString(arg) + if err != nil { + client.fwrite(":server 904 %s :Invalid base64\r\n", client.nick) + client.awaitingSASLData = false + continue + } + parts := strings.Split(string(data), "\x00") + if len(parts) != 3 { + client.fwrite(":server 904 %s :Malformed SASL data\r\n", client.nick) + client.awaitingSASLData = false + continue + } + authcid, password := parts[1], parts[2] + if checkCredentials(authcid, password) { + client.saslComplete = true + client.fwrite(":server 903 %s :SASL authentication successful\r\n", client.nick) + client.checkRegistration() + } else { + client.fwrite(":server 904 %s :SASL authentication failed\r\n", client.nick) + } + client.awaitingSASLData = false + } else { + client.fwrite(":server 904 %s :Unsupported SASL mechanism\r\n", client.nick) + } + case "USER": + if len(msg.Params) >= 4 { + uname := msg.Params[0] + client.user = uname + client.checkRegistration() + } else { + fmt.Fprintf(conn, ":server 461 * USER :Not enough parameters\r\n") + } + case "JOIN": + if !client.registered { + client.sendUnregisteredNotice() + continue + } + if len(msg.Params) > 0 && client.registered { + joinChannel(client, msg.Params[0]) + } + case "PRIVMSG": + if !client.registered { + client.sendUnregisteredNotice() + continue + } + if len(msg.Params) >= 2 && client.registered { + target := msg.Params[0] + text := msg.Params[1] + sendMessage(client, target, text) + } + case "PING": + if len(msg.Params) > 0 { + fmt.Fprintf(conn, "PONG :%s\r\n", msg.Params[0]) + } + case "WHO": + if !client.registered { + client.sendUnregisteredNotice() + continue + } + if len(msg.Params) > 0 { + showWho(client, msg.Params[0]) + } + case "LIST": + if !client.registered { + client.sendUnregisteredNotice() + continue + } + client.handleList() + case "WHOIS": + if !client.registered { + client.sendUnregisteredNotice() + continue + } + if len(msg.Params) > 0 { + usertolookUp := msg.Params[0] + client.performWhois(usertolookUp) + } + case "PART": + if !client.registered { + client.sendUnregisteredNotice() + continue + } + if len(msg.Params) > 0 { + client.partChannel(msg.Params[0]) + } + case "NOTICE": + if !client.registered { + client.sendUnregisteredNotice() + continue + } + if len(msg.Params) >= 2 { + target := msg.Params[0] + text := msg.Params[1] + sendNotice(client, target, text) + } + case "QUIT": + if !client.registered { + client.sendUnregisteredNotice() + return + } + client.cleanup() + return + // mod commands + case "KICK": + if !client.registered { + client.sendUnregisteredNotice() + continue + } + if len(msg.Params) < 2 { + client.fwrite(":server 461 %s KICK :Not enough parameters\r\n", client.nick) + continue + } + channelName := normalizeChannelName(msg.Params[0]) + targetNick := msg.Params[1] + reason := "Kicked" + if len(msg.Params) > 2 { + reason = msg.Params[2] + } + kickUser(client, channelName, targetNick, reason) + case "MODE": + if !client.registered { + client.sendUnregisteredNotice() + continue + } + if len(msg.Params) < 2 { + client.fwrite(":server 461 %s MODE :Not enough parameters\r\n", client.nick) + continue + } + channelName := normalizeChannelName(msg.Params[0]) + mode := msg.Params[1] + targetNick := "" + if len(msg.Params) > 2 { + targetNick = msg.Params[2] + } + handleMode(client, channelName, mode, targetNick) + // end of mod cmds + case "NAMES": + if !client.registered { + client.sendUnregisteredNotice() + continue + } + if len(msg.Params) > 0 { + name := normalizeChannelName(msg.Params[0]) + chMu.Lock() + ch, ok := channels[name] + chMu.Unlock() + if ok { + client.sendNames(ch) + } else { + fmt.Fprintf(client.conn, ":server 403 %s %s :No such channel\r\n", client.nick, name) + } + } else { + fmt.Fprintf(client.conn, ":server 461 %s NAMES :Not enough parameters\r\n", client.nick) + } + default: + if client.nick != "" { + fmt.Fprintf(conn, ":server 421 %s %s :Unknown command or command is not implemented\r\n", client.nick, msg.Command) + } else { + fmt.Fprintf(conn, ":server 421 * %s :Unknown command or command is not implemented\r\n", msg.Command) + } + } + } +} +func (c *Client) sendNames(ch *Channel) { + ch.mu.Lock() + defer ch.mu.Unlock() + + var nicks []string + for nick := range ch.members { + if ch.ops[nick] { + nicks = append(nicks, "@"+nick) + } else { + nicks = append(nicks, nick) + } + } + // the code 353 indicates start of name list + fmt.Fprintf(c.conn, ":server 353 %s = %s :%s\r\n", c.nick, ch.name, strings.Join(nicks, " ")) + fmt.Fprintf(c.conn, ":server 366 %s %s :End of /NAMES list.\r\n", c.nick, ch.name) +} +func kickUser(sender *Client, channelName, targetNick, reason string) { + chMu.Lock() + ch, ok := channels[channelName] + chMu.Unlock() + if !ok { + sender.fwrite(":server 403 %s %s :No such channel\r\n", sender.nick, channelName) + return + } + + ch.mu.Lock() + defer ch.mu.Unlock() + + if !ch.ops[sender.nick] { + sender.fwrite(":server 482 %s %s :You're not channel operator\r\n", sender.nick, channelName) + return + } + target, ok := ch.members[targetNick] + if !ok { + sender.fwrite(":server 441 %s %s %s :target user is not on that channel\r\n", sender.nick, targetNick, channelName) + return + } + if globalOps[targetNick] { + sender.fwrite(":server 482 %s %s :Cannot kick global operator\r\n", sender.nick, channelName) + return + } + for _, member := range ch.members { + member.fwrite(":%s KICK %s %s :%s\r\n", sender.nick, channelName, targetNick, reason) + } + + // Remove target + + delete(ch.members, targetNick) + delete(target.channels, channelName) +} +func handleMode(sender *Client, channelName, mode, targetNick string) { + chMu.Lock() + ch, ok := channels[channelName] + chMu.Unlock() + if !ok { + sender.fwrite(":server 403 %s %s :No such channel\r\n", sender.nick, channelName) + return + } + + ch.mu.Lock() + defer ch.mu.Unlock() + + if !ch.ops[sender.nick] { + sender.fwrite(":server 482 %s %s :You're not channel operator\r\n", sender.nick, channelName) + return + } + + switch mode { + case "+o": + if targetNick != "" { + ch.ops[targetNick] = true + for _, member := range ch.members { + member.fwrite(":%s MODE %s +o %s\r\n", sender.nick, channelName, targetNick) + } + } + case "-o": + if targetNick != "" { + if globalOps[targetNick] { + sender.fwrite(":server 482 %s %s :Cannot remove global operator\r\n", sender.nick, channelName) + return + } + delete(ch.ops, targetNick) + for _, member := range ch.members { + member.fwrite(":%s MODE %s -o %s\r\n", sender.nick, channelName, targetNick) + } + } + default: + sender.fwrite(":server 501 %s :Unknown MODE flag\r\n", sender.nick) + } +} + +func joinChannel(c *Client, name string) { + name = normalizeChannelName(name) + chMu.Lock() + ch, ok := channels[name] + if !ok { + ch = &Channel{ + name: name, + members: make(map[string]*Client), + ops: make(map[string]bool), + description: "none", + } + channels[name] = ch + if globalOps[c.nick] { + ch.ops[c.nick] = true + } + } + if globalOps[c.nick] { + ch.ops[c.nick] = true + } + chMu.Unlock() + + ch.mu.Lock() + ch.members[c.nick] = c + c.channels[name] = ch + ch.mu.Unlock() + for _, member := range ch.members { + fmt.Fprintf(member.conn, ":%s JOIN %s\r\n", c.nick, name) + } + c.sendNames(ch) +} + +func (c *Client) partChannel(name string) { + chMu.Lock() + ch, ok := channels[name] + chMu.Unlock() + if !ok { + return + } + + ch.mu.Lock() + delete(ch.members, c.nick) + delete(c.channels, name) + for _, member := range ch.members { + fmt.Fprintf(member.conn, ":%s PART %s\r\n", c.nick, name) + } + ch.mu.Unlock() + //broadcastNames(ch) +} +func addChannel(name string, description string) { + name = normalizeChannelName(name) + chMu.Lock() + defer chMu.Unlock() + + if _, ok := channels[name]; !ok { + channels[name] = &Channel{ + name: name, + members: make(map[string]*Client), + ops: make(map[string]bool), + description: description, + } + } +} +func sendMessage(sender *Client, target, text string) { + cliMu.Lock() + if c, ok := clients[target]; ok { + c.fwrite(":%s!%s@%s PRIVMSG %s :%s\r\n", sender.nick, sender.user, sender.host, target, text) + cliMu.Unlock() + return + } + cliMu.Unlock() + chMu.Lock() + ch, ok := channels[target] + chMu.Unlock() + if !ok { + return + } + ch.mu.Lock() + defer ch.mu.Unlock() + for _, member := range ch.members { + if member.nick != "" && member.nick != sender.nick { + fmt.Fprintf(member.conn, ":%s!%s@%s PRIVMSG %s :%s\r\n", sender.nick, sender.user, sender.host, target, text) + } + } +} +func (c *Client) checkRegistration() { + //log.Printf("checkRegistration called: nick=%q user=%q registered=%v", c.nick, c.user, c.registered) + if c.nick != "" && c.user != "" && !c.registered { + if c.saslRequired && !c.saslComplete { + return + } + c.registered = true + c.fwrite(":server 001 %s :Welcome to Gocirc, %s!\r\n", c.nick, c.nick) + c.fwrite(":server 002 %s :Your host is server\r\n", c.nick) + c.fwrite(":server 003 %s :This server was created just now\r\n", c.nick) + c.fwrite(":server 004 %s server irc 0.1\r\n", c.nick) + fmt.Fprintf(c.conn, ":server 375 %s :- Message of the Day -\r\n", c.nick) + for _, i := range motdString { + c.fwrite(":server 372 %s :- %s\r\n", c.nick, i) + } + fmt.Fprintf(c.conn, ":server 376 %s :End of MOTD\r\n", c.nick) + } +} +func reloadMOTD() { + for { + time.Sleep(3 * time.Minute) + motdString = []string{} + loadMOTD(motdFile) + } +} +func (c *Client) performWhois(nick string) { + cliMu.RLock() + target, ok := clients[nick] + cliMu.RUnlock() + if !ok { + c.fwrite(":server 401 %s %s :No such nick/channel\r\n", c.nick, nick) + return + } + c.fwrite(":server 311 %s %s %s %s * :%s\r\n", c.nick, target.nick, target.user, target.host, target.nick) + var chans []string + for name := range target.channels { + chans = append(chans, normalizeChannelName(name)) + } + if len(chans) > 0 { + c.fwrite(":server 319 %s %s :%s\r\n", c.nick, target.nick, strings.Join(chans, " ")) + } + c.fwrite(":server 318 %s %s :End of WHOIS list\r\n", c.nick, nick) +} +func (c *Client) sendUnregisteredNotice() { + fmt.Fprintf(c.conn, ":server 451 * :You have not registered\r\n") +} +func showWho(c *Client, name string) { + name = normalizeChannelName(name) + chMu.Lock() + ch, ok := channels[name] + chMu.Unlock() + if !ok { + return + } + ch.mu.Lock() + defer ch.mu.Unlock() + for _, member := range ch.members { + fmt.Fprintf(c.conn, ":server 352 %s %s %s server %s H :0 %s\r\n", + c.nick, name, member.host, member.nick, member.nick) + } + fmt.Fprintf(c.conn, ":server 315 %s %s :End of WHO list\r\n", c.nick, name) +} +func (c *Client) handleList() { + chMu.Lock() + defer chMu.Unlock() + // Start of list + fmt.Fprintf(c.conn, ":server 321 %s Channel :Users Name\r\n", c.nick) + for _, ch := range channels { + ch.mu.Lock() + count := len(ch.members) + ch.mu.Unlock() + // 322 = RPL_LIST + fmt.Fprintf(c.conn, ":server 322 %s %s %d :%s\r\n", c.nick, ch.name, count, ch.description) + } + // End of list + c.fwrite(":server 323 %s :End of /LIST\r\n", c.nick) +} +func nicknameExists(nick string) bool { + cliMu.RLock() + defer cliMu.RUnlock() + _, exists := clients[nick] + return exists +} +func (c *Client) cleanup() { + cliMu.Lock() + delete(clients, c.nick) + cliMu.Unlock() + for name, ch := range c.channels { + ch.mu.Lock() + delete(ch.members, c.nick) + for _, member := range ch.members { + member.fwrite(":%s PART %s\r\n", c.nick, name) + } + ch.mu.Unlock() + broadcastNames(ch) + delete(c.channels, name) + } + cliMu.RLock() + for _, other := range clients { + other.fwrite(":%s QUIT :Client disconnected\r\n", c.nick) + } + cliMu.RUnlock() +} + +func broadcastNames(ch *Channel) { + ch.mu.Lock() + defer ch.mu.Unlock() + var nicks []string + for nick := range ch.members { + if ch.ops[nick] { + nicks = append(nicks, "@"+nick) + } else { + nicks = append(nicks, nick) + } + } + for _, member := range ch.members { + fmt.Fprintf(member.conn, ":server 353 %s = %s :%s\r\n", member.nick, ch.name, strings.Join(nicks, " ")) + fmt.Fprintf(member.conn, ":server 366 %s %s :End of /NAMES list.\r\n", member.nick, ch.name) + } +} +func sendNotice(sender *Client, target string, text string) { + cliMu.RLock() + defer cliMu.RUnlock() + if c, ok := clients[target]; ok { + c.fwrite(":%s NOTICE %s :%s\r\n", sender.nick, target, text) + } +} + +// utils +func (c *Client) fwrite(format string, args ...interface{}) { + fmt.Fprintf(c.conn, format, args...) +} + +// usage: input: main return value: #main, 2nd input: #other output: #other +// only prepends a # to a channel name +func normalizeChannelName(name string) string { + if !strings.HasPrefix(name, "#") { + return "#" + name + } + return name +} + +// authentication +func checkCredentials(user, pass string) bool { + file, err := os.Open("accounts.txt") + if err != nil { + return false + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 2 { + continue + } + if fields[0] == user && fields[1] == pass { + return true + } + } + return scanner.Err() == nil && false +} diff --git a/motd.txt b/motd.txt new file mode 100644 index 0000000..adcc736 --- /dev/null +++ b/motd.txt @@ -0,0 +1,4 @@ +welcome to thinIRC +plz follow the rules: +no discriminating by religion +no racial slurs allowed \ No newline at end of file