From cef326e512d9d5fd7dfb5dd11bf0d7a63e6338c5 Mon Sep 17 00:00:00 2001 From: rubenseyer Date: Mon, 12 Feb 2018 22:19:42 +0100 Subject: [PATCH] Config system see also issue #21 --- cmd/grumble/args.go | 24 +++--- cmd/grumble/freeze.go | 15 ++-- cmd/grumble/grumble.go | 55 +++++++++--- cmd/grumble/murmurdb.go | 7 +- cmd/grumble/server.go | 30 ++++--- pkg/serverconf/config.go | 18 +++- pkg/serverconf/file.go | 100 +++++++++++++++++++++ pkg/serverconf/file_ini.go | 102 ++++++++++++++++++++++ pkg/serverconf/file_json.go | 153 +++++++++++++++++++++++++++++++++ pkg/serverconf/murmurcompat.go | 59 +++++++++++++ 10 files changed, 516 insertions(+), 47 deletions(-) create mode 100644 pkg/serverconf/file.go create mode 100644 pkg/serverconf/file_ini.go create mode 100644 pkg/serverconf/file_json.go create mode 100644 pkg/serverconf/murmurcompat.go diff --git a/cmd/grumble/args.go b/cmd/grumble/args.go index 4b2331c..37fd47e 100644 --- a/cmd/grumble/args.go +++ b/cmd/grumble/args.go @@ -30,6 +30,9 @@ var usageTmpl = `usage: grumble [options] --log (default: $DATADIR/grumble.log) Log file path. + --config, --ini (default: $DATADIR/grumble.ini) + Config file path. + --regen-keys Force grumble to regenerate its global RSA keypair (and certificate). @@ -46,12 +49,13 @@ var usageTmpl = `usage: grumble [options] ` type args struct { - ShowHelp bool - DataDir string - LogPath string - RegenKeys bool - SQLiteDB string - CleanUp bool + ShowHelp bool + DataDir string + LogPath string + ConfigPath string + RegenKeys bool + SQLiteDB string + CleanUp bool } func defaultDataDir() string { @@ -63,10 +67,6 @@ func defaultDataDir() string { return filepath.Join(homedir, dirname) } -func defaultLogPath() string { - return filepath.Join(defaultDataDir(), "grumble.log") -} - func Usage() { t, err := template.New("usage").Parse(usageTmpl) if err != nil { @@ -92,7 +92,9 @@ func init() { flag.BoolVar(&Args.ShowHelp, "help", false, "") flag.StringVar(&Args.DataDir, "datadir", defaultDataDir(), "") - flag.StringVar(&Args.LogPath, "log", defaultLogPath(), "") + flag.StringVar(&Args.LogPath, "log", "", "") + flag.StringVar(&Args.ConfigPath, "ini", "", "") + flag.StringVar(&Args.ConfigPath, "config", "", "") flag.BoolVar(&Args.RegenKeys, "regen-keys", false, "") flag.StringVar(&Args.SQLiteDB, "import-murmurdb", "", "") diff --git a/cmd/grumble/freeze.go b/cmd/grumble/freeze.go index edd7f7f..a2c991c 100644 --- a/cmd/grumble/freeze.go +++ b/cmd/grumble/freeze.go @@ -6,19 +6,19 @@ package main import ( "errors" - "github.com/golang/protobuf/proto" "io" "io/ioutil" "log" - "mumble.info/grumble/pkg/acl" - "mumble.info/grumble/pkg/ban" - "mumble.info/grumble/pkg/freezer" - "mumble.info/grumble/pkg/mumbleproto" - "mumble.info/grumble/pkg/serverconf" "os" "path/filepath" "strconv" "time" + + "github.com/golang/protobuf/proto" + "mumble.info/grumble/pkg/acl" + "mumble.info/grumble/pkg/ban" + "mumble.info/grumble/pkg/freezer" + "mumble.info/grumble/pkg/mumbleproto" ) // Freeze a server to disk and closes the log file. @@ -418,11 +418,10 @@ func NewServerFromFrozen(name string) (s *Server, err error) { } } - s, err = NewServer(id) + s, err = NewServer(id, configFile.ServerConfig(id, cfgMap)) if err != nil { return nil, err } - s.cfg = serverconf.New(cfgMap) // Unfreeze the server's frozen bans. s.UnfreezeBanList(fs.BanList) diff --git a/cmd/grumble/grumble.go b/cmd/grumble/grumble.go index 6807165..c0d3187 100644 --- a/cmd/grumble/grumble.go +++ b/cmd/grumble/grumble.go @@ -8,15 +8,18 @@ import ( "flag" "fmt" "log" - "mumble.info/grumble/pkg/blobstore" - "mumble.info/grumble/pkg/logtarget" "os" "path/filepath" "regexp" + + "mumble.info/grumble/pkg/blobstore" + "mumble.info/grumble/pkg/logtarget" + "mumble.info/grumble/pkg/serverconf" ) var servers map[int64]*Server var blobStore blobstore.BlobStore +var configFile *serverconf.ConfigFile func main() { var err error @@ -35,10 +38,42 @@ func main() { } dataDir.Close() + // Open the config file + var configFn string + if Args.ConfigPath != "" { + configFn = Args.ConfigPath + } else { + configFn = filepath.Join(Args.DataDir, "grumble.ini") + } + if filepath.Ext(configFn) == ".ini" { + // Create it if it doesn't exist + configFd, err := os.OpenFile(configFn, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0700) + if err == nil { + configFd.WriteString(serverconf.DefaultConfigFile) + log.Fatalf("Default config written to %v\n", configFn) + configFd.Close() + } else if err != nil && !os.IsExist(err) { + log.Fatalf("Unable to open config file (%v): %v", configFn, err) + return + } + } + configFile, err = serverconf.NewConfigFile(configFn) + if err != nil { + log.Fatalf("Unable to open config file (%v): %v", configFn, err) + return + } + config := configFile.GlobalConfig() + // Set up logging - err = logtarget.Target.OpenFile(Args.LogPath) + var logFn string + if Args.LogPath != "" { + logFn = Args.LogPath + } else { + logFn = config.PathValue("LogPath", Args.DataDir) + } + err = logtarget.Target.OpenFile(logFn) if err != nil { - fmt.Fprintf(os.Stderr, "Unable to open log file (%v): %v", Args.LogPath, err) + fmt.Fprintf(os.Stderr, "Unable to open log file (%v): %v", logFn, err) return } log.SetPrefix("[G] ") @@ -64,9 +99,9 @@ func main() { // and corresponding certificate. // These are used as the default certificate of all virtual servers // and the SSH admin console, but can be overridden using the "key" - // and "cert" arguments to Grumble. - certFn := filepath.Join(Args.DataDir, "cert.pem") - keyFn := filepath.Join(Args.DataDir, "key.pem") + // and "cert" arguments to Grumble. todo(rubenseyer) implement override by cli + certFn := config.PathValue("CertPath", Args.DataDir) + keyFn := config.PathValue("KeyPath", Args.DataDir) shouldRegen := false if Args.RegenKeys { shouldRegen = true @@ -163,10 +198,10 @@ func main() { if err != nil { log.Fatalf("Unable to read file from data directory: %v", err.Error()) } - // The data dir file descriptor. + // The servers dir file descriptor. err = serversDir.Close() if err != nil { - log.Fatalf("Unable to close data directory: %v", err.Error()) + log.Fatalf("Unable to close servers directory: %v", err.Error()) return } @@ -190,7 +225,7 @@ func main() { // If no servers were found, create the default virtual server. if len(servers) == 0 { - s, err := NewServer(1) + s, err := NewServer(1, configFile.ServerConfig(1, nil)) if err != nil { log.Fatalf("Couldn't start server: %s", err.Error()) } diff --git a/cmd/grumble/murmurdb.go b/cmd/grumble/murmurdb.go index a3a5116..61bcf5c 100644 --- a/cmd/grumble/murmurdb.go +++ b/cmd/grumble/murmurdb.go @@ -13,12 +13,13 @@ import ( "database/sql" "errors" "log" - "mumble.info/grumble/pkg/acl" - "mumble.info/grumble/pkg/ban" "net" "os" "path/filepath" "strconv" + + "mumble.info/grumble/pkg/acl" + "mumble.info/grumble/pkg/ban" ) const ( @@ -85,7 +86,7 @@ func MurmurImport(filename string) (err error) { // Create a new Server from a Murmur SQLite database func NewServerFromSQLite(id int64, db *sql.DB) (s *Server, err error) { - s, err = NewServer(id) + s, err = NewServer(id, nil) if err != nil { return nil, err } diff --git a/cmd/grumble/server.go b/cmd/grumble/server.go index f6f627a..c1ef291 100644 --- a/cmd/grumble/server.go +++ b/cmd/grumble/server.go @@ -15,9 +15,15 @@ import ( "encoding/hex" "errors" "fmt" - "github.com/golang/protobuf/proto" "hash" "log" + "net" + "net/http" + "strings" + "sync" + "time" + + "github.com/golang/protobuf/proto" "mumble.info/grumble/pkg/acl" "mumble.info/grumble/pkg/ban" "mumble.info/grumble/pkg/freezer" @@ -27,12 +33,6 @@ import ( "mumble.info/grumble/pkg/serverconf" "mumble.info/grumble/pkg/sessionpool" "mumble.info/grumble/pkg/web" - "net" - "net/http" - "path/filepath" - "strings" - "sync" - "time" ) // The default port a Murmur server listens on @@ -137,12 +137,16 @@ func (lf clientLogForwarder) Write(incoming []byte) (int, error) { } // Allocate a new Murmur instance -func NewServer(id int64) (s *Server, err error) { +func NewServer(id int64, config *serverconf.Config) (s *Server, err error) { s = new(Server) s.Id = id - s.cfg = serverconf.New(nil) + if config == nil { + s.cfg = serverconf.New(nil) + } else { + s.cfg = config + } s.Users = make(map[uint32]*User) s.UserCertMap = make(map[string]*User) @@ -1421,8 +1425,8 @@ func (server *Server) Start() (err error) { */ // Wrap a TLS listener around the TCP connection - certFn := filepath.Join(Args.DataDir, "cert.pem") - keyFn := filepath.Join(Args.DataDir, "key.pem") + certFn := server.cfg.PathValue("CertPath", Args.DataDir) + keyFn := server.cfg.PathValue("KeyPath", Args.DataDir) cert, err := tls.LoadX509KeyPair(certFn, keyFn) if err != nil { return err @@ -1452,9 +1456,9 @@ func (server *Server) Start() (err error) { // Set sensible timeouts, in case no reverse proxy is in front of Grumble. // Non-conforming (or malicious) clients may otherwise block indefinitely and cause // file descriptors (or handles, depending on your OS) to leak and/or be exhausted - ReadTimeout: 5 * time.Second, + ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, - IdleTimeout: 2 * time.Minute, + IdleTimeout: 2 * time.Minute, } go func() { err := server.webhttp.ListenAndServeTLS("", "") diff --git a/pkg/serverconf/config.go b/pkg/serverconf/config.go index bf0c28b..902a66f 100644 --- a/pkg/serverconf/config.go +++ b/pkg/serverconf/config.go @@ -7,6 +7,7 @@ package serverconf import ( "strconv" "sync" + "path/filepath" ) var defaultCfg = map[string]string{ @@ -20,6 +21,9 @@ var defaultCfg = map[string]string{ "RememberChannel": "true", "WelcomeText": "Welcome to this server running Grumble.", "SendVersion": "true", + "LogPath": "grumble.log", + "CertPath": "cert.pem", + "KeyPath": "key.pem", } type Config struct { @@ -80,7 +84,7 @@ func (cfg *Config) StringValue(key string) (value string) { return "" } -// Get the value of a speific config key as an int +// Get the value of a specific config key as an int func (cfg *Config) IntValue(key string) (intval int) { str := cfg.StringValue(key) intval, _ = strconv.Atoi(str) @@ -94,9 +98,19 @@ func (cfg *Config) Uint32Value(key string) (uint32val uint32) { return uint32(uintval) } -// Get the value fo a sepcific config key as a bool +// Get the value of a specific config key as a bool func (cfg *Config) BoolValue(key string) (boolval bool) { str := cfg.StringValue(key) boolval, _ = strconv.ParseBool(str) return } + +// Get the value of a specific config key as a path, +// joined with the path in rel if not absolute. +func (cfg *Config) PathValue(key string, rel string) (path string) { + str := cfg.StringValue(key) + if filepath.IsAbs(str) { + return filepath.Clean(str) + } + return filepath.Join(rel, str) +} diff --git a/pkg/serverconf/file.go b/pkg/serverconf/file.go new file mode 100644 index 0000000..6ab4f3f --- /dev/null +++ b/pkg/serverconf/file.go @@ -0,0 +1,100 @@ +package serverconf + +import ( + "errors" + "path/filepath" + "strconv" +) + +var globalKeys = map[string]bool{ + "LogPath": true, +} + +type cfg interface { + // GlobalMap returns a copy of the top-level (global) configuration map. + GlobalMap() map[string]string + + // SubMap returns a copy of the server-specific (if existing) configuration map. + SubMap(sub int64) map[string]string +} + +type ConfigFile struct { + cfg +} + +func NewConfigFile(path string) (*ConfigFile, error) { + var f cfg + var err error + switch filepath.Ext(path) { + case ".ini": + f, err = newinicfg(path) + case ".json": + f, err = newjsoncfg(path) + default: + return nil, errors.New("unknown config file format") + } + if err != nil { + return nil, err + } + return &ConfigFile{f}, nil +} + +// GlobalConfig returns a new *serverconf.Config representing the top-level +// (global) configuration. +func (c *ConfigFile) GlobalConfig() *Config { + return New(c.GlobalMap()) +} + +// ServerConfig returns a new *serverconf.Config representing the union +// between the global configuration and the server-specific overrides. +// Optionally a base map m which to merge into may be passed. This map +// is consumed and cannot be reused. +func (c *ConfigFile) ServerConfig(id int64, m map[string]string) *Config { + if m == nil { + m = c.GlobalMap() + + // Strip the global keys so they don't get repeated in the freeze. + for k := range globalKeys { + delete(m, k) + } + } else { + // Merge the global config into the base map + for k, v := range c.GlobalMap() { + if _, ok := globalKeys[k]; ok { + // Ignore the global keys so they don't get repeated in the freeze. + continue + } + if v != "" { + m[k] = v + } else { + // Allow unset of base values through empty keys. + delete(m, k) + } + } + } + + // Some server specific values from the global config must be offset. + if v, ok := m["Port"]; ok { + i, err := strconv.ParseInt(v, 10, 64) + if err == nil { + m["Port"] = strconv.FormatInt(i+id-1, 10) + } + } + if v, ok := m["WebPort"]; ok { + i, err := strconv.ParseInt(v, 10, 64) + if err == nil { + m["WebPort"] = strconv.FormatInt(i+id-1, 10) + } + } + + // Merge the server-specific override (if one exists). + for k, v := range c.SubMap(id) { + if v != "" { + m[k] = v + } else { + // Allow unset of global values through empty keys. + delete(m, k) + } + } + return New(m) +} diff --git a/pkg/serverconf/file_ini.go b/pkg/serverconf/file_ini.go new file mode 100644 index 0000000..6520f8b --- /dev/null +++ b/pkg/serverconf/file_ini.go @@ -0,0 +1,102 @@ +package serverconf + +import ( + "strconv" + + "path/filepath" + + "gopkg.in/ini.v1" +) + +type inicfg struct { + file *ini.File + murmurCompat bool +} + +func newinicfg(path string) (*inicfg, error) { + file, err := ini.LoadSources(ini.LoadOptions{AllowBooleanKeys: true, UnescapeValueDoubleQuotes: true}, path) + if err != nil { + return nil, err + } + file.BlockMode = false // read only, avoid locking + return &inicfg{file, filepath.Base(path) == "murmur.ini"}, nil +} + +func (f *inicfg) GlobalMap() map[string]string { + if !f.murmurCompat { + return f.file.Section("").KeysHash() + } else { + return TranslateMurmur(f.file.Section("").KeysHash()) + } +} + +func (f *inicfg) SubMap(sub int64) map[string]string { + return f.file.Section(strconv.FormatInt(sub, 10)).KeysHash() +} + +// todo(rubenseyer): not all of these even work.. make sure to implement them +var DefaultConfigFile = `# Grumble configuration file. +# +# The commented out settings represent the defaults. +# Settings are additionally persisted separately for each virtual server, +# but this configuration will always override them. +# To revert a persisted value to defaults, set a key with an empty value. + +# Address to bind the listeners to. +#Address = 0.0.0.0 + +# Port is the port to bind the native Mumble protocol to. +# WebPort is the port to bind the WebSocket Mumble protocol to. +# They are incremented for each virtual server. +#Port = 64738 +#WebPort = 443 + +# "Message of the day" HTML string sent to connecting clients. +#WelcomeText = "Welcome to this server running Grumble." + +# Maximum bandwidth (in bits per second) per client for voice. +# Grumble does not yet enforce this limit, but some clients nicely follow it. +#MaxBandwidth = 72000 + +# Maximum number of concurrent clients. +#MaxUsers = 1000 +#MaxUsersPerChannel = 0 + +#MaxTextMessageLength = 5000 +#MaxImageMessageLength = 131072 +#AllowHTML + +# DefaultChannel is the channel (by ID) new users join. +# The root channel is the default. +#DefaultChannel = 0 + +# Whether users will rejoin the last channel they were in. +#RememberChannel + +# Whether to include server version and server os in ping response. +#SendVersion +#SendOSInfo + +# Path to the log file (relative to the data directory). +#LogPath = grumble.log + +# Path to TLS certificate and key (relative to the data directory). +# The certificate needs to have the entire chain concatenated to be validate. +# If these paths do not exist, Grumble will autogenerate a certificate +#CertPath = cert.pem +#KeyPath = key.pem + +# Options for public server registration. +# All of these have to be set to make the server public. +# RegisterName additionally sets the name of the root channel. +# RegisterPassword is a simple, arbitrary secret to guard your registration. Don't lose it. +#RegisterName = +#RegisterHost = +#RegisterPassword = +#RegisterWebUrl = + +# Subsections set options specific to the given virtual server. +# To revert a persisted value to defaults, set a key with an empty value. +#[1] +#Port = +` diff --git a/pkg/serverconf/file_json.go b/pkg/serverconf/file_json.go new file mode 100644 index 0000000..ebe9cce --- /dev/null +++ b/pkg/serverconf/file_json.go @@ -0,0 +1,153 @@ +package serverconf + +import ( + "bufio" + "bytes" + + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" +) + +type jsoncfg struct { + top map[string]string + sub map[int64]map[string]string +} + +func newjsoncfg(path string) (*jsoncfg, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + jpp := newjsonpp(f) + defer jpp.Close() + + dec := json.NewDecoder(jpp) + t, err := dec.Token() + if err != nil { + return nil, err + } + if t != json.Delim('{') { + return nil, errors.New("json config: top level must be object") + } + top := make(map[string]string) + sub := make(map[int64]map[string]string) + err = parseobj(dec, top, sub) + if err != nil { + return nil, err + } + return &jsoncfg{top: top, sub: sub}, nil +} + +func parseobj(dec *json.Decoder, top map[string]string, sub map[int64]map[string]string) error { + for { + k, err := dec.Token() + if err != nil { + return err + } + t, err := dec.Token() + if err != nil { + return err + } + switch v := t.(type) { + case string: + top[k.(string)] = v + case json.Number: + n, err := v.Int64() + if err != nil { + return err + } + // The back-and-forth conversion enforces integers only, at + // full precision, but strconv does not understand E-notation. + top[k.(string)] = strconv.FormatInt(n, 10) + case bool: + if v { + top[k.(string)] = "true" + } else { + top[k.(string)] = "false" + } + case nil: + top[k.(string)] = "" + case json.Delim: + if sub == nil { + return errors.New(fmt.Sprintf("json config: nested more than once, at %v", k)) + } + if v != json.Delim('{') { + return errors.New(fmt.Sprintf("json config: can only nest objects, at %v", k)) + } + i, err := strconv.ParseInt(k.(string), 10, 64) + if err != nil { + return errors.New(fmt.Sprintf("json config: nested object key must be int, at %v", k)) + } + sub[i] = make(map[string]string) + err = parseobj(dec, sub[i], nil) + if err != nil { + return err + } + default: + return errors.New(fmt.Sprintf("json config: unknown token type, at %v", k)) + } + if !dec.More() { + break + } + } + return nil +} + +func (f *jsoncfg) GlobalMap() map[string]string { + m := make(map[string]string, len(f.top)) + for k, v := range f.top { + m[k] = v + } + return m +} + +func (f *jsoncfg) SubMap(sub int64) map[string]string { + if _, ok := f.sub[sub]; !ok { + return nil + } + m := make(map[string]string, len(f.sub[sub])) + for k, v := range f.sub[sub] { + m[k] = v + } + return m +} + +type jsonpp struct { + f io.ReadCloser + buf bytes.Buffer + scan *bufio.Scanner + // Note: bufio.Scanner will not allocate more than 65536 bytes per line. +} + +func newjsonpp(f io.ReadCloser) *jsonpp { + return &jsonpp{f: f, scan: bufio.NewScanner(f)} +} + +func (j *jsonpp) Read(p []byte) (n int, err error) { + for j.buf.Len() < len(p) { + // This JSON-with-comments preprocessor is simple, but will break + // some valid JSON by splitting on the // sequence inside a string. + // Fortunately, the escape /\/ is valid and parsed without problems. + if j.scan.Scan() { + if strings.Contains(j.scan.Text(), "//") { + j.buf.WriteString(strings.SplitN(j.scan.Text(), "//", 2)[0]) + } else { + j.buf.Write(j.scan.Bytes()) + } + } else if j.scan.Err() != nil { + return 0, j.scan.Err() + } else { + break + } + } + return j.buf.Read(p) +} + +func (j *jsonpp) Close() error { + return j.f.Close() +} diff --git a/pkg/serverconf/murmurcompat.go b/pkg/serverconf/murmurcompat.go new file mode 100644 index 0000000..a094031 --- /dev/null +++ b/pkg/serverconf/murmurcompat.go @@ -0,0 +1,59 @@ +package serverconf + +import ( + "log" +) + +var murmurCompatRules = map[string]string{ + "logfile": "LogPath", + "welcometext": "WelcomeText", + "port": "Port", + "host": "Address", + "bandwidth": "MaxBandwidth", + "users": "MaxUsers", + "textmessagelength": "MaxTextMessageLength", + "imagemessagelength": "MaxImageMessageLength", + "allowhtml": "AllowHTML", + "sslCert": "CertPath", + "sslKey": "KeyPath", + "sendversion": "SendVersion", + "usersperchannel": "MaxUsersPerChannel", + "defaultchannel": "DefaultChannel", + "rememberchannel": "RememberChannel", + "registerName": "RegisterName", + "registerHostname": "RegisterHost", + "registerPassword": "RegisterPassword", + "registerUrl": "RegisterWebUrl", + "registerLocation": "RegisterLocation", +} + +// TranslateMurmur converts a source map with supported options from a murmur.ini +// into a normal config map. It also emits warnings for some common, but unsupported +// Murmur options. +func TranslateMurmur(source map[string]string) (target map[string]string) { + log.Println("Using Murmur compatibility mode for configuration file") + target = make(map[string]string) + for kmurmur, v := range source { + if kgrumble, ok := murmurCompatRules[kmurmur]; ok { + target[kgrumble] = v + } + switch kmurmur { + case "serverpassword": + log.Println("* Grumble does not yet support server-wide passwords.") + case "database": + log.Println("* Grumble does not yet support Murmur databases directly (see issue #21 on github).") + if driver, ok := source["dbDriver"]; !ok || driver == "QSQLITE" { + log.Println(" To convert a previous SQLite database, use the --import-murmurdb flag.") + } + case "sslDHParams": + log.Println("* Go does not implement DHE modes in TLS, so the configured dhparams are ignored.") + case "sslCiphers": + log.Println("* Support for changing TLS ciphers is not implemented yet.") + case "ice": + log.Println("* Grumble does not support ZeroC ICE.") + case "grpc": + log.Println("* Grumble does not yet support gRPC (see issue #23 on github).") + } + } + return target +}