289 lines
7.8 KiB
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
|
|
}
|