From b103d5e2eb39682e6285a82fd6bf4b536c420cc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=99emysl=20Janouch?= Date: Tue, 31 Jul 2018 20:53:23 +0200 Subject: [PATCH] hid: port configuration and initialization All the basic elements should be there now, we just need to port PING timers and fix some remaining issues and we're basically done. --- hid/main.go | 557 +++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 439 insertions(+), 118 deletions(-) diff --git a/hid/main.go b/hid/main.go index eec67d9..2c34f08 100644 --- a/hid/main.go +++ b/hid/main.go @@ -22,9 +22,11 @@ import ( "crypto/sha256" "crypto/tls" "encoding/hex" + "errors" "flag" "fmt" "io" + "io/ioutil" "log" "net" "os" @@ -66,67 +68,6 @@ func splitString(s, delims string, ignoreEmpty bool) (result []string) { return } -func findTildeHome(username string) string { - if username != "" { - if u, _ := user.Lookup(username); u != nil { - return u.HomeDir - } - } else if u, _ := user.Current(); u != nil { - return u.HomeDir - } else if v, ok := os.LookupEnv("HOME"); ok { - return v - } - return "~" + username -} - -// Tries to expand the tilde in paths, leaving it as-is on error. -func expandTilde(path string) string { - if path[0] != '~' { - return path - } - - var n int - for n = 0; n < len(path); n++ { - if path[n] == '/' { - break - } - } - return findTildeHome(path[1:n]) + path[n:] -} - -func getXDGHomeDir(name, def string) string { - env := os.Getenv(name) - if env != "" && env[0] == '/' { - return env - } - - home := "" - if v, ok := os.LookupEnv("HOME"); ok { - home = v - } else if u, _ := user.Current(); u != nil { - home = u.HomeDir - } - return filepath.Join(home, def) -} - -// Retrieve all XDG base directories for configuration files. -func getXDGConfigDirs() (result []string) { - home := getXDGHomeDir("XDG_CONFIG_HOME", ".config") - if home != "" { - result = append(result, home) - } - dirs := os.Getenv("XDG_CONFIG_DIRS") - if dirs == "" { - dirs = "/etc/xdg" - } - for _, path := range strings.Split(dirs, ":") { - if path != "" { - result = append(result, path) - } - } - return -} - // // Trivial SSL/TLS autodetection. The first block of data returned by Recvfrom // must be at least three octets long for this to work reliably, but that should @@ -157,47 +98,230 @@ func detectTLS(sysconn syscall.RawConn) (isTLS bool) { return isTLS } -// --- Configuration ----------------------------------------------------------- +// --- File system ------------------------------------------------------------- -// XXX: Do we really want to support default nil values? -var config = []struct { - key string // INI key - def []rune // default value, may be nil - description string // documentation -}{ - // XXX: I'm not sure if Go will cooperate here. - {"pid_file", nil, "Path or name of the PID file"}, - {"bind", []rune(":6667"), "Address of the IRC server"}, +// Look up the value of an XDG path from environment, or fall back to a default. +func getXDGHomeDir(name, def string) string { + env := os.Getenv(name) + if env != "" && env[0] == filepath.Separator { + return env + } + + home := "" + if v, ok := os.LookupEnv("HOME"); ok { + home = v + } else if u, _ := user.Current(); u != nil { + home = u.HomeDir + } + return filepath.Join(home, def) } -/* - -// Read a configuration file with the given basename w/o extension. -func readConfigFile(name string, output interface{}) error { - var suffix = filepath.Join(projectName, name+".json") - for _, path := range getXDGConfigDirs() { - full := filepath.Join(path, suffix) - file, err := os.Open(full) - if err != nil { - if !os.IsNotExist(err) { - return err - } +func resolveRelativeFilenameGeneric(paths []string, filename string) string { + for _, path := range paths { + // As per XDG spec, relative paths are ignored. + if path == "" || path[0] != filepath.Separator { continue } - defer file.Close() - // TODO: We don't want to use JSON. - decoder := json.NewDecoder(file) - err = decoder.Decode(output) - if err != nil { - return fmt.Errorf("%s: %s", full, err) + file := filepath.Join(path, filename) + if _, err := os.Stat(file); err == nil { + return file } - return nil } - return errors.New("configuration file not found") + return "" } -*/ +// Retrieve all XDG base directories for configuration files. +func getXDGConfigDirs() (result []string) { + home := getXDGHomeDir("XDG_CONFIG_HOME", ".config") + if home != "" { + result = append(result, home) + } + dirs := os.Getenv("XDG_CONFIG_DIRS") + if dirs == "" { + dirs = "/etc/xdg" + } + for _, path := range strings.Split(dirs, ":") { + if path != "" { + result = append(result, path) + } + } + return +} + +func resolveRelativeConfigFilename(filename string) string { + return resolveRelativeFilenameGeneric(getXDGConfigDirs(), + filepath.Join(projectName, filename)) +} + +func findTildeHome(username string) string { + if username != "" { + if u, _ := user.Lookup(username); u != nil { + return u.HomeDir + } + } else if u, _ := user.Current(); u != nil { + return u.HomeDir + } else if v, ok := os.LookupEnv("HOME"); ok { + return v + } + if debugMode { + log.Printf("failed to expand the home directory for %s", username) + } + return "~" + username +} + +func resolveFilename(filename string, relativeCB func(string) string) string { + // Absolute path is absolute. + if filename == "" || filename[0] == filepath.Separator { + return filename + } + if filename[0] != '~' { + return relativeCB(filename) + } + + // Paths to home directories ought to be absolute. + var n int + for n = 0; n < len(filename); n++ { + if filename[n] == filepath.Separator { + break + } + } + return findTildeHome(filename[1:n]) + filename[n:] +} + +// --- Simple file I/O --------------------------------------------------------- + +// Overwrites filename contents with data; creates directories as needed. +func writeFile(filename string, data []byte) error { + if dir := filepath.Dir(filename); dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + } + return ioutil.WriteFile(filename, data, 0644) +} + +// Wrapper for writeFile that makes sure that the new data has been written +// to disk in its entirety before overriding the old file. +func writeFileSafe(filename string, data []byte) error { + temp := filename + ".new" + if err := writeFile(temp, data); err != nil { + return err + } + return os.Rename(temp, filename) +} + +// --- Simple configuration ---------------------------------------------------- + +type simpleConfigItem struct { + key string // INI key + def string // default value + description string // documentation +} + +type simpleConfig map[string]string + +func (sc simpleConfig) loadDefaults(table []simpleConfigItem) { + for _, item := range table { + sc[item.key] = item.def + } +} + +func (sc simpleConfig) updateFromFile() error { + basename := projectName + ".conf" + filename := resolveFilename(basename, resolveRelativeConfigFilename) + if filename == "" { + return &os.PathError{ + Op: "cannot find", + Path: basename, + Err: os.ErrNotExist, + } + } + + f, err := os.Open(filename) + if err != nil { + return err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for lineNo := 1; scanner.Scan(); lineNo++ { + line := strings.TrimLeft(scanner.Text(), " \t") + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + equals := strings.IndexByte(line, '=') + if equals <= 0 { + return fmt.Errorf("%s:%d: malformed line", filename, lineNo) + } + + sc[strings.TrimRight(line[:equals], " \t")] = line[equals+1:] + } + return scanner.Err() +} + +func writeConfigurationFile(pathHint string, data []byte) (string, error) { + path := pathHint + if path == "" { + path = filepath.Join(getXDGHomeDir("XDG_CONFIG_HOME", ".config"), + projectName, projectName+".conf") + } + + if err := writeFileSafe(path, data); err != nil { + return "", err + } + return path, nil +} + +func simpleConfigWriteDefault(pathHint string, prolog string, + table []simpleConfigItem) (string, error) { + data := []byte(prolog) + for _, item := range table { + data = append(data, fmt.Sprintf("# %s\n%s=%s\n", + item.description, item.key, item.def)...) + } + return writeConfigurationFile(pathHint, data) +} + +/// Convenience wrapper suitable for most simple applications. +func callSimpleConfigWriteDefault(pathHint string, table []simpleConfigItem) { + prologLines := []string{ + `# ` + projectName + ` ` + projectVersion + ` configuration file`, + "#", + `# Relative paths are searched for in ${XDG_CONFIG_HOME:-~/.config}`, + `# /` + projectName + ` as well as in $XDG_CONFIG_DIRS/` + projectName, + ``, + ``, + } + + filename, err := simpleConfigWriteDefault( + pathHint, strings.Join(prologLines, "\n"), table) + if err != nil { + log.Fatalln(err) + } + + log.Printf("configuration written to `%s'\n", filename) +} + +// --- Configuration ----------------------------------------------------------- + +var configTable = []simpleConfigItem{ + // TODO: Default to the result from os.Hostname (if successful). + {"server_name", "", "Server name"}, + {"server_info", "My server", "Brief server description"}, + {"motd", "", "MOTD filename"}, + {"catalog", "", "Localisation catalog"}, + + {"bind", ":6667", "Bind addresses of the IRC server"}, + {"tls_cert", "", "Server TLS certificate (PEM)"}, + {"tls_key", "", "Server TLS private key (PEM)"}, + + {"operators", "", "IRCop TLS certificate SHA-256 fingerprints"}, + + {"max_connections", "0", "Global connection limit"}, + {"ping_interval", "180", "Interval between PINGs (sec)"}, +} // --- Rate limiter ------------------------------------------------------------ @@ -372,9 +496,16 @@ const ( ircMaxMessageLength = 510 ) -const reClassSpecial = "\\[\\]\\\\`_^{|}" +const ( + reClassSpecial = "\\[\\]\\\\`_^{|}" + // "shortname" from RFC 2812 doesn't work how its author thought it would. + reShortname = "[0-9A-Za-z](-*[0-9A-Za-z])*" +) var ( + reHostname = regexp.MustCompile( + `^` + reShortname + `(\.` + reShortname + `)*$`) + // Extending ASCII to the whole range of Unicode letters. reNickname = regexp.MustCompile( `^[\pL` + reClassSpecial + `][\pL` + reClassSpecial + `0-9-]*$`) @@ -389,6 +520,19 @@ var ( reFingerprint = regexp.MustCompile(`^[a-fA-F0-9]{64}$`) ) +func ircValidateHostname(hostname string) error { + if hostname == "" { + return errors.New("the value is empty") + } + if !reHostname.MatchString(hostname) { + return errors.New("invalid format") + } + if len(hostname) > ircMaxHostname { + return errors.New("the value is too long") + } + return nil +} + func ircIsValidNickname(nickname string) bool { return len(nickname) <= ircMaxNickname && reNickname.MatchString(nickname) } @@ -603,7 +747,8 @@ type writeEvent struct { err error // write error } -// TODO: Port server_context. Maybe we want to keep it in a struct? +// TODO: Port server_context. Maybe we want to keep it in a struct? A better +// question might be: can we run multiple instances of it? // XXX: Beware that maps with identifier keys need to be indexed correctly. // We might want to enforce accessor functions for users and channels. var ( @@ -614,6 +759,7 @@ var ( whowas map[string]*whowasInfo // WHOWAS registry + config simpleConfig // server configuration serverName string // our server name pingInterval uint // ping interval in seconds maxConnections int // max connections allowed or 0 @@ -631,7 +777,7 @@ var ( tlsConf *tls.Config clients = make(map[*client]bool) - listener net.Listener + listeners []net.Listener quitting bool quitTimer <-chan time.Time ) @@ -652,8 +798,10 @@ func forceQuit(reason string) { // Initiate a clean shutdown of the whole daemon. func initiateQuit() { log.Println("shutting down") - if err := listener.Close(); err != nil { - log.Println(err) + for _, ln := range listeners { + if err := ln.Close(); err != nil { + log.Println(err) + } } for c := range clients { c.closeLink("Shutting down") @@ -2751,12 +2899,15 @@ func (c *client) onWrite(written int, writeErr error) { func accept(ln net.Listener) { for { + // Error handling here may be tricky, see go #6163, #24808. if conn, err := ln.Accept(); err != nil { - // TODO: Consider specific cases in error handling, some errors - // are transitional while others are fatal. - log.Println(err) - break + if op, ok := err.(net.Error); !ok || !op.Temporary() { + log.Fatalln(err) + } else { + log.Println(err) + } } else { + // TCP_NODELAY is set by default on TCPConns. conns <- conn } } @@ -2798,7 +2949,7 @@ func prepare(client *client) { // This is just for the TLS detection and doesn't need to be fatal. log.Println(err) } else { - isTLS = detectTLS(sysconn) + isTLS = tlsConf != nil && detectTLS(sysconn) } // FIXME: When the client sends no data, we still initialize its conn. @@ -2827,7 +2978,7 @@ func write(client *client, data []byte) { writes <- writeEvent{client, n, err} } -// --- Main -------------------------------------------------------------------- +// --- Event loop -------------------------------------------------------------- func processOneEvent() { select { @@ -2842,6 +2993,12 @@ func processOneEvent() { forceQuit("timeout") case conn := <-conns: + if len(clients) >= maxConnections { + log.Println("connection limit reached, refusing connection") + conn.Close() + break + } + log.Println("accepted client connection") // In effect, we require TCP/UDP, as they have port numbers. @@ -2889,24 +3046,34 @@ func processOneEvent() { } } -func main() { - flag.BoolVar(&debugMode, "debug", false, "debug mode") - version := flag.Bool("version", false, "show version and exit") - flag.Parse() +// --- Application setup ------------------------------------------------------- - if *version { - fmt.Printf("%s %s\n", projectName, projectVersion) - return +func ircInitializeTLS() error { + configCert, configKey := config["tls_cert"], config["tls_key"] + + // Only try to enable SSL support if the user configures it; it is not + // a failure if no one has requested it. + if configCert == "" && configKey == "" { + return nil + } else if configCert == "" { + return errors.New("no TLS certificate set") + } else if configKey == "" { + return errors.New("no TLS private key set") } - // TODO: Configuration--create an INI parser, probably. - if len(flag.Args()) != 3 { - log.Fatalf("usage: %s KEY CERT ADDRESS\n", os.Args[0]) + pathCert := resolveFilename(configCert, resolveRelativeConfigFilename) + if pathCert == "" { + return fmt.Errorf("cannot find file: %s", configCert) } - cert, err := tls.LoadX509KeyPair(flag.Arg(1), flag.Arg(0)) + pathKey := resolveFilename(configKey, resolveRelativeConfigFilename) + if pathKey == "" { + return fmt.Errorf("cannot find file: %s", configKey) + } + + cert, err := tls.LoadX509KeyPair(pathCert, pathKey) if err != nil { - log.Fatalln(err) + return err } tlsConf = &tls.Config{ @@ -2914,15 +3081,169 @@ func main() { ClientAuth: tls.RequestClientCert, SessionTicketsDisabled: true, } - listener, err = net.Listen("tcp", flag.Arg(2)) + return nil +} + +func ircInitializeCatalog() error { + // TODO: Not going to use catgets but a simple text file with basic + // checking whether the index is used by this daemon at all should do. + return nil +} + +func ircInitializeMOTD() error { + configMOTD := config["motd"] + if configMOTD == "" { + return nil + } + + pathMOTD := resolveFilename(configMOTD, resolveRelativeConfigFilename) + if pathMOTD == "" { + return fmt.Errorf("cannot find file: %s", configMOTD) + } + + f, err := os.Open(pathMOTD) if err != nil { - log.Fatalln(err) + return fmt.Errorf("failed reading the MOTD file: %s", err) + } + defer f.Close() + + scanner := bufio.NewScanner(f) + motd = nil + for scanner.Scan() { + motd = append(motd, scanner.Text()) + } + return scanner.Err() +} + +type configError struct { + name string // configuration key + err error // description of the issue +} + +func (e *configError) Error() string { + return fmt.Sprintf("invalid configuration value for `%s': %s", + e.name, e.err) +} + +// This function handles values that require validation before their first use, +// or some kind of a transformation (such as conversion to an integer) needs +// to be done before they can be used directly. +func ircParseConfig() error { + // TODO: I think we could further shorten this with lambdas, doing away + // with the custom error type completely and at the same time getting rid of + // the key stuttering. + if u, err := strconv.ParseUint( + config["ping_interval"], 10, 32); err != nil { + return &configError{"ping_interval", err} + } else if u < 1 { + return &configError{"ping_interval", + errors.New("the value is out of range")} + } else { + pingInterval = uint(u) + } + + if i, err := strconv.ParseInt( + config["max_connections"], 10, 32); err != nil { + return &configError{"max_connections", err} + } else if i < 0 { + return &configError{"max_connections", + errors.New("the value is out of range")} + } else { + maxConnections = int(i) + } + + operators = make(map[string]bool) + for _, fp := range splitString(config["operators"], ",", true) { + if !ircIsValidFingerprint(fp) { + return &configError{"operators", + errors.New("invalid fingerprint valeu")} + } + operators[strings.ToLower(fp)] = true + } + return nil +} + +func ircInitializeServerName() error { + if value := config["server_name"]; value != "" { + if err := ircValidateHostname(value); err != nil { + return err + } + serverName = value + return nil + } + + if hostname, err := os.Hostname(); err != nil { + return err + } else if err := ircValidateHostname(hostname); err != nil { + return err + } else { + serverName = hostname + } + return nil +} + +func ircSetupListenFDs() error { + for _, address := range splitString(config["bind"], ",", true) { + ln, err := net.Listen("tcp", address) + if err != nil { + return err + } + listeners = append(listeners, ln) + } + if len(listeners) == 0 { + return errors.New("network setup failed: no ports to listen on") + } + for _, ln := range listeners { + go accept(ln) + } + return nil +} + +// --- Main -------------------------------------------------------------------- + +func main() { + flag.BoolVar(&debugMode, "debug", false, "run in debug mode") + version := flag.Bool("version", false, "show version and exit") + writeDefaultCfg := flag.Bool("writedefaultcfg", false, + "write a default configuration file and exit") + + flag.Parse() + + if *version { + fmt.Printf("%s %s\n", projectName, projectVersion) + return + } + if *writeDefaultCfg { + callSimpleConfigWriteDefault("", configTable) + return + } + if flag.NArg() > 0 { + flag.Usage() + os.Exit(1) + } + + config = make(simpleConfig) + if err := config.updateFromFile(); err != nil && !os.IsNotExist(err) { + log.Println("error loading configuration", err) + os.Exit(1) } started = time.Now() - go accept(listener) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + for _, fn := range []func() error{ + ircInitializeTLS, + ircInitializeServerName, + ircInitializeMOTD, + ircInitializeCatalog, + ircParseConfig, + ircSetupListenFDs, + } { + if err := fn(); err != nil { + log.Fatalln(err) + } + } + for !quitting || len(clients) > 0 { processOneEvent() }