pcmt/config/config.go

289 lines
7.8 KiB
Go

// Copyright 2023 wanderer <a_mirre at utb dot cz>
// 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
)
// Load attempts to parse the Dhall configuration, returns (nil, err) on error.
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 <nil> 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
}
// SessionSecretsAreHex returns whether session secrets are hex-encoded.
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
}