diff --git a/README.md b/README.md index 96ffe620..f88d3754 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,11 @@ and issues are welcome. ## Some Features - follows the RFC where possible -- JSON-based configuration -- server password -- channels with many standard modes -- IRC operators -- TLS support (but better to use stunnel with proxy protocol) -- haproxy PROXY protocol header for hostname setting +- gcfg gitconfig-style configuration +- server password (PASS command) +- channels with most standard modes +- IRC operators (OPER command) +- haproxy [PROXY protocol](http://haproxy.1wt.eu/download/1.5/doc/proxy-protocol.txt) header for hostname setting - passwords stored in bcrypt format - channels that persist between restarts (+P) @@ -23,27 +22,39 @@ I wanted to learn Go. "Ergonomadic" is an anagram of "Go IRC Daemon". +## What about SSL/TLS support? + +Go has a not-yet-verified-as-safe TLS 1.2 implementation. Sadly, many +popular IRC clients will negotiate nothing newer than SSLv2. If you +want to use SSL to protect traffic, I recommend using +[stunnel](https://www.stunnel.org/index.html) version 4.56 with +haproxy's +[PROXY protocol](http://haproxy.1wt.eu/download/1.5/doc/proxy-protocol.txt). This +will allow the server to get the client's original addresses for +hostname lookups. + ## Installation ```sh go get go install -ergonomadic -conf '/path/to/config.json' -initdb +ergonomadic -conf ergonomadic.conf -initdb ``` ## Configuration -See the example `config.json`. Passwords are base64-encoded bcrypted -byte strings. You can generate them with the `genpasswd` subcommand. +See the example `ergonomadic.conf`. Passwords are base64-encoded +bcrypted byte strings. You can generate them with the `genpasswd` +subcommand. ```sh -ergonomadic -genpasswd 'hunter21!' +ergonomadic -genpasswd 'hunter2!' ``` ## Running the Server ```sh -ergonomadic -conf '/path/to/config.json' +ergonomadic -conf ergonomadic.conf ``` ## Helpful Documentation diff --git a/config.json b/config.json deleted file mode 100644 index b607611a..00000000 --- a/config.json +++ /dev/null @@ -1,39 +0,0 @@ -// Ergonomadic IRC Server Config -// ----------------------------- -// Passwords are generated by `ergonomadic -genpasswd "$plaintext"`. -// Comments are not allowed in the actual config file. -{ - // `name` is usually a hostname. - "name": "irc.example.com", - - // The path to the MOTD is relative to this file's directory. - "motd": "motd.txt", - - // PASS command password - "password": "JDJhJDA0JHBBenUyV3Z5UU5iWUpiYmlNMlNLZC5VRDZDM21HUzFVbmxLUUI3NTVTLkZJOERLdUFaUWNt", - - // `listeners` are places to bind and listen for - // connections. http://golang.org/pkg/net/#Dial demonstrates valid - // values for `net` and `address`. `net` is optional and defaults - // to `tcp`. - "listeners": [ { - "address": "localhost:7777" - }, { - "net": "tcp6", - "address": "[::1]:7777" - } ], - - // Operators for the OPER command - "operators": [ { - "name": "root", - "password": "JDJhJDA0JHBBenUyV3Z5UU5iWUpiYmlNMlNLZC5VRDZDM21HUzFVbmxLUUI3NTVTLkZJOERLdUFaUWNt" - } ], - - // Global debug flags. `net` generates a lot of output. - "debug": { - "net": true, - "client": false, - "channel": false, - "server": false - } -} diff --git a/ergonomadic.conf b/ergonomadic.conf new file mode 100644 index 00000000..2f360024 --- /dev/null +++ b/ergonomadic.conf @@ -0,0 +1,16 @@ +[server] +name = "irc.example.com" ; required, usually a hostname +database = "ergonomadic.db" ; path relative to this file +listen = "localhost:6667" ; see `net.Listen` for examples +listen = "[::1]:6667" ; multiple `listen`s are allowed. +motd = "motd.txt" ; path relative to this file +password = "JDJhJDA0JHJzVFFlNXdOUXNhLmtkSGRUQVVEVHVYWXRKUmdNQ3FKVTRrczRSMTlSWGRPZHRSMVRzQmtt" ; 'test' + +[operator "root"] +password = "JDJhJDA0JEhkcm10UlNFRkRXb25iOHZuSDVLZXVBWlpyY0xyNkQ4dlBVc1VMWVk1LlFjWFpQbGxZNUtl" ; 'toor' + +[debug] +net = true +client = false +channel = false +server = false diff --git a/ergonomadic.go b/ergonomadic.go index e04f9e8a..dbd45ddb 100644 --- a/ergonomadic.go +++ b/ergonomadic.go @@ -1,73 +1,49 @@ package main import ( - "code.google.com/p/go.crypto/bcrypt" - "database/sql" - "encoding/base64" "flag" "fmt" "github.com/jlatt/ergonomadic/irc" - _ "github.com/mattn/go-sqlite3" "log" "os" + "path/filepath" ) -func genPasswd(passwd string) { - crypted, err := bcrypt.GenerateFromPassword([]byte(passwd), bcrypt.MinCost) - if err != nil { - log.Fatal(err) - } - encoded := base64.StdEncoding.EncodeToString(crypted) - fmt.Println(encoded) -} - -func initDB(config *irc.Config) { - os.Remove(config.Database()) - - db, err := sql.Open("sqlite3", config.Database()) - if err != nil { - log.Fatal(err) - } - defer db.Close() - - _, err = db.Exec(` - CREATE TABLE channel ( - name TEXT NOT NULL UNIQUE, - flags TEXT NOT NULL, - key TEXT NOT NULL, - topic TEXT NOT NULL, - user_limit INTEGER DEFAULT 0)`) - if err != nil { - log.Fatal(err) - } -} - func main() { - conf := flag.String("conf", "ergonomadic.json", "ergonomadic config file") + conf := flag.String("conf", "ergonomadic.conf", "ergonomadic config file") initdb := flag.Bool("initdb", false, "initialize database") passwd := flag.String("genpasswd", "", "bcrypt a password") flag.Parse() if *passwd != "" { - genPasswd(*passwd) + encoded, err := irc.GenerateEncodedPassword(*passwd) + if err != nil { + log.Fatal("encoding error: ", err) + } + fmt.Println(encoded) return } config, err := irc.LoadConfig(*conf) if err != nil { - log.Fatal(err) + log.Fatal("error loading config: ", err) + } + err = os.Chdir(filepath.Dir(*conf)) + if err != nil { + log.Fatal("chdir error: ", err) } if *initdb { - initDB(config) + irc.InitDB(config.Server.Database) + log.Println("database initialized: " + config.Server.Database) return } // TODO move to data structures - irc.DEBUG_NET = config.Debug["net"] - irc.DEBUG_CLIENT = config.Debug["client"] - irc.DEBUG_CHANNEL = config.Debug["channel"] - irc.DEBUG_SERVER = config.Debug["server"] + irc.DEBUG_NET = config.Debug.Net + irc.DEBUG_CLIENT = config.Debug.Client + irc.DEBUG_CHANNEL = config.Debug.Channel + irc.DEBUG_SERVER = config.Debug.Server irc.NewServer(config).Run() } diff --git a/irc/channel.go b/irc/channel.go index 3e06b5ed..d7fa7371 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -464,9 +464,8 @@ func (channel *Channel) Invite(invitee *Client, inviter *Client) { return } - // TODO Modify channel masks inviter.RplInviting(invitee, channel.name) - invitee.Reply(RplInviteMsg(inviter, channel.name)) + invitee.Reply(RplInviteMsg(inviter, invitee, channel.name)) if invitee.flags[Away] { inviter.RplAway(invitee) } diff --git a/irc/commands.go b/irc/commands.go index 40431d49..e7d36130 100644 --- a/irc/commands.go +++ b/irc/commands.go @@ -1,7 +1,6 @@ package irc import ( - "code.google.com/p/go.crypto/bcrypt" "code.google.com/p/go.text/unicode/norm" "errors" "fmt" @@ -214,7 +213,7 @@ func (cmd *PassCommand) CheckPassword() { if cmd.hash == nil { return } - cmd.err = bcrypt.CompareHashAndPassword(cmd.hash, cmd.password) + cmd.err = ComparePassword(cmd.hash, cmd.password) } func NewPassCommand(args []string) (editableCommand, error) { diff --git a/irc/config.go b/irc/config.go index 07518f00..6c5aa14d 100644 --- a/irc/config.go +++ b/irc/config.go @@ -1,91 +1,67 @@ package irc import ( - "encoding/base64" - "encoding/json" + "code.google.com/p/gcfg" + "errors" "log" - "os" - "path/filepath" ) -func decodePassword(password string) []byte { - if password == "" { - return nil - } - bytes, err := base64.StdEncoding.DecodeString(password) +type PassConfig struct { + Password string +} + +func (conf *PassConfig) PasswordBytes() []byte { + bytes, err := DecodePassword(conf.Password) if err != nil { - log.Fatal(err) + log.Fatal("decode password error: ", err) } return bytes } type Config struct { - Debug map[string]bool - Listeners []ListenerConfig - MOTD string - Name string - Operators []OperatorConfig - Password string - directory string + Server struct { + PassConfig + Database string + Listen []string + MOTD string + Name string + } + + Operator map[string]*PassConfig + + Debug struct { + Net bool + Client bool + Channel bool + Server bool + } } -func (conf *Config) Database() string { - return filepath.Join(conf.directory, "ergonomadic.db") -} - -func (conf *Config) PasswordBytes() []byte { - return decodePassword(conf.Password) -} - -func (conf *Config) OperatorsMap() map[string][]byte { +func (conf *Config) Operators() map[string][]byte { operators := make(map[string][]byte) - for _, opConf := range conf.Operators { - operators[opConf.Name] = opConf.PasswordBytes() + for name, opConf := range conf.Operator { + operators[name] = opConf.PasswordBytes() } return operators } -type OperatorConfig struct { - Name string - Password string -} - -func (conf *OperatorConfig) PasswordBytes() []byte { - return decodePassword(conf.Password) -} - -type ListenerConfig struct { - Net string - Address string - Key string - Certificate string -} - -func (config *ListenerConfig) IsTLS() bool { - return (config.Key != "") && (config.Certificate != "") -} - func LoadConfig(filename string) (config *Config, err error) { config = &Config{} - - file, err := os.Open(filename) + err = gcfg.ReadFileInto(config, filename) if err != nil { return } - defer file.Close() - - decoder := json.NewDecoder(file) - err = decoder.Decode(config) - if err != nil { + if config.Server.Name == "" { + err = errors.New("server.name missing") return } - - config.directory = filepath.Dir(filename) - config.MOTD = filepath.Join(config.directory, config.MOTD) - for _, lconf := range config.Listeners { - if lconf.Net == "" { - lconf.Net = "tcp" - } + if config.Server.Database == "" { + err = errors.New("server.database missing") + return + } + if len(config.Server.Listen) == 0 { + err = errors.New("server.listen missing") + return } return } diff --git a/irc/constants.go b/irc/constants.go index dd84f352..131ac2a0 100644 --- a/irc/constants.go +++ b/irc/constants.go @@ -19,11 +19,11 @@ var ( // regexps ChannelNameExpr = regexp.MustCompile(`^[&!#+][\pL\pN]{1,63}$`) NicknameExpr = regexp.MustCompile( - "^[\\pL\\[\\]{}^`][\\pL\\pN\\[\\]{}^`]{1,31}$") + "^[\\pL\\[\\]{}^`_][\\pL\\pN\\[\\]{}^`_|]{1,31}$") ) const ( - SEM_VER = "ergonomadic-1.2.11" + SEM_VER = "ergonomadic-1.2.13" CRLF = "\r\n" MAX_REPLY_LEN = 512 - len(CRLF) diff --git a/irc/database.go b/irc/database.go new file mode 100644 index 00000000..c7f9264a --- /dev/null +++ b/irc/database.go @@ -0,0 +1,32 @@ +package irc + +import ( + "database/sql" + _ "github.com/mattn/go-sqlite3" + "log" + "os" +) + +func InitDB(path string) { + os.Remove(path) + db := OpenDB(path) + defer db.Close() + _, err := db.Exec(` + CREATE TABLE channel ( + name TEXT NOT NULL UNIQUE, + flags TEXT NOT NULL, + key TEXT NOT NULL, + topic TEXT NOT NULL, + user_limit INTEGER DEFAULT 0)`) + if err != nil { + log.Fatal("initdb error: ", err) + } +} + +func OpenDB(path string) *sql.DB { + db, err := sql.Open("sqlite3", path) + if err != nil { + log.Fatal("open db error: ", err) + } + return db +} diff --git a/irc/password.go b/irc/password.go new file mode 100644 index 00000000..1426c402 --- /dev/null +++ b/irc/password.go @@ -0,0 +1,37 @@ +package irc + +import ( + "code.google.com/p/go.crypto/bcrypt" + "encoding/base64" + "errors" +) + +var ( + EmptyPasswordError = errors.New("empty password") +) + +func GenerateEncodedPassword(passwd string) (encoded string, err error) { + if passwd == "" { + err = EmptyPasswordError + return + } + bcrypted, err := bcrypt.GenerateFromPassword([]byte(passwd), bcrypt.MinCost) + if err != nil { + return + } + encoded = base64.StdEncoding.EncodeToString(bcrypted) + return +} + +func DecodePassword(encoded string) (decoded []byte, err error) { + if encoded == "" { + err = EmptyPasswordError + return + } + decoded, err = base64.StdEncoding.DecodeString(encoded) + return +} + +func ComparePassword(hash, password []byte) error { + return bcrypt.CompareHashAndPassword(hash, password) +} diff --git a/irc/reply.go b/irc/reply.go index 76def9bb..2f85588e 100644 --- a/irc/reply.go +++ b/irc/reply.go @@ -128,8 +128,8 @@ func RplError(message string) string { return NewStringReply(nil, ERROR, ":%s", message) } -func RplInviteMsg(inviter *Client, channel string) string { - return NewStringReply(inviter, INVITE, channel) +func RplInviteMsg(inviter *Client, invitee *Client, channel string) string { + return NewStringReply(inviter, INVITE, "%s :%s", invitee.Nick(), channel) } func RplKick(channel *Channel, client *Client, target *Client, comment string) string { diff --git a/irc/server.go b/irc/server.go index d3ac14b5..c5d5edaa 100644 --- a/irc/server.go +++ b/irc/server.go @@ -2,12 +2,8 @@ package irc import ( "bufio" - "crypto/rand" - "crypto/tls" "database/sql" - "encoding/binary" "fmt" - _ "github.com/mattn/go-sqlite3" "log" "net" "os" @@ -16,6 +12,7 @@ import ( "runtime/debug" "runtime/pprof" "strings" + "syscall" "time" ) @@ -35,34 +32,30 @@ type Server struct { } func NewServer(config *Config) *Server { - db, err := sql.Open("sqlite3", config.Database()) - if err != nil { - log.Fatal(err) - } - server := &Server{ channels: make(ChannelNameMap), clients: make(ClientNameMap), commands: make(chan Command, 16), ctime: time.Now(), - db: db, + db: OpenDB(config.Server.Database), idle: make(chan *Client, 16), - motdFile: config.MOTD, - name: config.Name, + motdFile: config.Server.MOTD, + name: config.Server.Name, newConns: make(chan net.Conn, 16), - operators: config.OperatorsMap(), - password: config.PasswordBytes(), + operators: config.Operators(), + password: config.Server.PasswordBytes(), signals: make(chan os.Signal, 1), } - signal.Notify(server.signals, os.Interrupt, os.Kill) - server.loadChannels() - for _, listenerConf := range config.Listeners { - go server.listen(listenerConf) + for _, addr := range config.Server.Listen { + go server.listen(addr) } + signal.Notify(server.signals, syscall.SIGINT, syscall.SIGHUP, + syscall.SIGTERM, syscall.SIGQUIT) + return server } @@ -71,7 +64,7 @@ func (server *Server) loadChannels() { SELECT name, flags, key, topic, user_limit FROM channel`) if err != nil { - log.Fatal(err) + log.Fatal("error loading channels: ", err) } for rows.Next() { var name, flags, key, topic string @@ -128,14 +121,20 @@ func (server *Server) processCommand(cmd Command) { } } +func (server *Server) Shutdown() { + server.db.Close() + for _, client := range server.clients { + client.Reply(RplNotice(server, client, "shutting down")) + } +} + func (server *Server) Run() { done := false for !done { select { case <-server.signals: - server.db.Close() + server.Shutdown() done = true - continue case conn := <-server.newConns: NewClient(server, conn) @@ -149,33 +148,18 @@ func (server *Server) Run() { } } -func newListener(config ListenerConfig) (net.Listener, error) { - if config.IsTLS() { - certificate, err := tls.LoadX509KeyPair(config.Certificate, config.Key) - if err != nil { - return nil, err - } - return tls.Listen("tcp", config.Address, &tls.Config{ - Certificates: []tls.Certificate{certificate}, - PreferServerCipherSuites: true, - }) - } - - return net.Listen("tcp", config.Address) -} - // // listen goroutine // -func (s *Server) listen(config ListenerConfig) { - listener, err := newListener(config) +func (s *Server) listen(addr string) { + listener, err := net.Listen("tcp", addr) if err != nil { log.Fatal(s, "listen error: ", err) } if DEBUG_SERVER { - log.Printf("%s listening on %s", s, config.Address) + log.Printf("%s listening on %s", s, addr) } for { @@ -194,24 +178,6 @@ func (s *Server) listen(config ListenerConfig) { } } -func (s *Server) GenerateGuestNick() string { - bytes := make([]byte, 8) - for { - _, err := rand.Read(bytes) - if err != nil { - panic(err) - } - randInt, n := binary.Uvarint(bytes) - if n <= 0 { - continue // TODO handle error - } - nick := fmt.Sprintf("guest%d", randInt) - if s.clients.Get(nick) == nil { - return nick - } - } -} - // // server functionality // @@ -880,9 +846,8 @@ func (msg *InviteCommand) HandleServer(server *Server) { channel := server.channels.Get(msg.channel) if channel == nil { - name := strings.ToLower(msg.channel) - client.RplInviting(target, name) - target.Reply(RplInviteMsg(client, name)) + client.RplInviting(target, msg.channel) + target.Reply(RplInviteMsg(client, target, msg.channel)) return }