// Copyright 2023 wanderer // SPDX-License-Identifier: AGPL-3.0-only package config import ( "context" "fmt" "os" "os/exec" "time" "git.dotya.ml/mirre-mt/pcmt/slogging" "github.com/philandstuff/dhall-golang/v6" "golang.org/x/exp/slog" ) type session struct { CookieName string // CookieAuthSecret is the key used for signing and authentication. CookieAuthSecret string // CookieEncrSecret is the key used for encrypting. CookieEncrSecret string MaxAge int } type httpRec struct { // Secure denotes whether the HTTP should use TLS (i.e. HTTPS). Secure bool Domain string AutoTLS bool TLSCertKeyPath string TLSKeyPath string ContentSecurityPolicy string // HSTSMaxAge sets the strict transport security duration, zero mean it's not set. HSTSMaxAge int // Gzip is the level of gzip compression, 0 means do not enable gzip. Gzip int // RateLimit controls the number of requests per second, 0 means no limit. RateLimit int // Timount controls connection timeout in seconds, 0 means no limit. Timeout int } type mailer struct { Enabled bool EnableHELO bool ForceTrustServerCert bool Protocol string SMTPPort int SendPlainText bool SubjectPrefix string } type initialiser struct { AdminPassword string CreateAdmin bool } // Config represents the Dhall configuration schema, // https://git.dotya.ml/mirre-mt/pcmt-config-schema/ for reference. type Config struct { AppPath string Host string Port int HTTP httpRec LiveMode bool DevelMode bool Session session Registration struct{ Allowed bool } Logger struct { JSON bool Fmt string } Init initialiser // Mailer is currently a noop. Mailer mailer } const ( schemaCompatibility = "0.0.1-rc.2" // authKeySize is the session cookie auth key length in bytes. authKeySize = 64 // encrKeySize is the session cookie encryption key length in bytes. encrKeySize = 32 ) var ( log slogging.Slogger errSessionAuthSecretWrongSize = fmt.Errorf("session cookie authentication secret should be *exactly* 64 bytes long, both raw and hex-encoded strings are accepted; make sure to generate the key with sufficient entropy e.g. using openssl") errSessionEncrSecretWrongSize = fmt.Errorf("session cookie encryption secret should be *exactly* 32 bytes (for 256 bit AES), both raw and hex-encoded strings are accepted; make sure to generate the key with sufficient entropy e.g. using openssl") errSessionSecretZeros = fmt.Errorf("session cookie secrets cannot be all zeros") errInitAdminPasswdEmpty = fmt.Errorf("requested initial admin creation and the initial admin password is empty") // authSecretIsHex is used to recall whether the authentication secret was // determined to be pure hex. authSecretIsHex bool // authSecretIsHex is used to recall whether the encryption secret was // determined to be pure hex. encrSecretIsHex bool quiet bool ) func Load(conf string, isPath bool) (*Config, error) { var config Config var err error slogger := slogging.Logger() // initialise if not already initialised. if slogger == nil { /// this should never happen, as the logger is initialised in run.go. slogger = slogging.Init(true) } // have a local copy. log = *slogger // add attr to all statements made with the local copy. log.Logger = log.Logger.With( slog.Group("pcmt extra", slog.String("module", "config")), ) log.Debugf("schema compatibility: '%s'", schemaCompatibility) switch { case isPath: log.Debug("config from file") err = dhall.UnmarshalFile(conf, &config) case !isPath: log.Debug("config raw from cmdline") err = dhall.Unmarshal([]byte(conf), &config) } if !config.Logger.JSON { slogger = slogging.Init(false) log.Logger = slogger.Logger.With( slog.Group("pcmt extra", slog.String("module", "config")), ) } if config.DevelMode && os.Getenv("PCMT_DEVEL") != "False" && !quiet { log.Debug("set DEBUG level based on config value") slogger = slogging.SetLevel(slogging.LevelDebug) log.Logger = slogger.Logger.With( slog.Group("pcmt extra", slog.String("module", "config")), ) log.Debugf("parsed config: %+v", config) if dhallCmdExists() { _ = prettyPrintConfig(conf, isPath) } } // only return now (if err), after we've had the chance to print the loaded // config, as would be the case if the config *did* adhere to the schema imported // but app's Config type definition above didn't - "don't know how to // decode into ..." is the most likely outcome in that case and it // could be the product of (at least) three things: // * completely wrong config not following the schema // * config is following a newer schema than the app supports // * config is following an older schema than the app supports // // NOTE: for the most part the only thing checked above would be adherence // to config schema (apart from *some* value validation). if err != nil { return nil, err } // make sure the secrets meet *basic* requirements for consumption. // NOTE: this func does not prevent the user from using dumb but correctly // sized keys (such as 32 bytes of 0x01...). secrets are checked if they // are not all zero bytes, though. if err = checkSessionSecretsLen(&config); err != nil { return nil, err } if config.Init.CreateAdmin && config.Init.AdminPassword == "" { return nil, errInitAdminPasswdEmpty } return &config, nil } func (c *Config) SessionSecretsAreHex() (bool, bool) { return authSecretIsHex, encrSecretIsHex } func checkSessionSecretsLen(conf *Config) error { auth := conf.Session.CookieAuthSecret encr := conf.Session.CookieEncrSecret log.Debugf("auth len bytes: %d", len([]byte(auth))) log.Debugf("encr len bytes: %d", len([]byte(encr))) switch { case (len([]byte(auth)) != authKeySize) && (isHex(auth) && len([]byte(auth)) != 2*authKeySize): return errSessionAuthSecretWrongSize case (len([]byte(encr)) != encrKeySize) && (isHex(encr) && len([]byte(encr)) != 2*encrKeySize): return errSessionEncrSecretWrongSize case isAllZeros([]byte(auth)) || isAllZeros([]byte(encr)): return errSessionSecretZeros case isHex(auth): authSecretIsHex = true fallthrough case isHex(encr): encrSecretIsHex = true } return nil } func isAllZeros(b []byte) bool { for _, v := range b { if v != 0 { return false } } return true } // isHex checks if the string is a hex string with even number of bytes. func isHex(s string) bool { bs := []byte(s) for _, b := range bs { if !(b >= '0' && b <= '9' || b >= 'a' && b <= 'f' || b >= 'A' && b <= 'F') { return false } } return true } func prettyPrintConfig(conf string, isPath bool) error { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() var cmd *exec.Cmd if isPath { cmd = exec.CommandContext(ctx, "/bin/sh", "-c", "dhall --file "+conf) //nolint:gosec } else { cmd = exec.CommandContext(ctx, "/bin/sh", "-c", "dhall <<< \""+conf+"\"") //nolint:gosec } output, err := cmd.CombinedOutput() if err != nil { log.Debug("could not pretty-print config", "error", err) return err } if isPath { fmt.Fprintln(os.Stderr, "\n"+conf+":\n"+string(output)) } else { fmt.Fprintln(os.Stderr, "\nconfig:\n"+string(output)) } return nil } func dhallCmdExists() bool { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if err := exec.CommandContext(ctx, "/bin/sh", "-c", "command -v dhall").Run(); err != nil { log.Debug("no command dhall") return false } return true } // BeQuiet instruct this package to be more frugal with logging/printing info. func BeQuiet() { quiet = true }