2023-05-20 20:15:57 +02:00
// Copyright 2023 wanderer <a_mirre at utb dot cz>
// SPDX-License-Identifier: AGPL-3.0-only
2023-03-19 22:03:12 +01:00
package config
import (
2023-05-04 23:49:25 +02:00
"context"
"fmt"
"os"
"os/exec"
"time"
2023-05-01 23:09:36 +02:00
"git.dotya.ml/mirre-mt/pcmt/slogging"
2023-03-19 22:03:12 +01:00
"github.com/philandstuff/dhall-golang/v6"
2023-05-09 17:35:00 +02:00
"golang.org/x/exp/slog"
2023-03-19 22:03:12 +01:00
)
2023-05-05 19:31:43 +02:00
type session struct {
2023-05-21 12:44:18 +02:00
CookieName string
// CookieAuthSecret is the key used for signing and authentication.
CookieAuthSecret string
// CookieEncrSecret is the key used for encrypting.
CookieEncrSecret string
MaxAge int
2023-05-05 19:31:43 +02:00
}
2023-05-21 12:44:18 +02:00
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.
2023-03-19 22:03:12 +01:00
type Config struct {
2023-05-21 12:44:18 +02:00
AppPath string
2023-05-05 19:31:43 +02:00
Host string
Port int
2023-05-21 12:44:18 +02:00
HTTP httpRec
2023-05-05 19:31:43 +02:00
LiveMode bool
DevelMode bool
Session session
Registration struct { Allowed bool }
Logger struct {
2023-05-21 12:44:18 +02:00
JSON bool
2023-05-05 19:31:43 +02:00
Fmt string
}
2023-05-21 12:44:18 +02:00
Init initialiser
// Mailer is currently a noop.
Mailer mailer
2023-03-19 22:03:12 +01:00
}
2023-05-21 12:44:18 +02:00
const (
schemaCompatibility = "0.0.1-rc.2"
2023-05-12 23:29:16 +02:00
2023-05-21 12:44:18 +02:00
// 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" )
2023-05-21 18:50:41 +02:00
errInitAdminPasswdEmpty = fmt . Errorf ( "requested initial admin creation and the initial admin password is empty" )
2023-05-21 12:44:18 +02:00
// 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
)
2023-05-09 17:35:00 +02:00
2023-05-13 19:44:32 +02:00
func Load ( conf string , isPath bool ) ( * Config , error ) {
2023-03-19 22:03:12 +01:00
var config Config
2023-05-04 23:49:25 +02:00
var err error
2023-05-11 17:06:20 +02:00
slogger := slogging . Logger ( )
2023-05-09 17:35:00 +02:00
// initialise if not already initialised.
if slogger == nil {
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" ) ) ,
)
2023-05-21 12:44:18 +02:00
log . Debugf ( "schema compatibility: '%s'" , schemaCompatibility )
2023-05-09 17:35:00 +02:00
switch {
case isPath :
log . Debug ( "config from file" )
2023-05-04 23:49:25 +02:00
err = dhall . UnmarshalFile ( conf , & config )
2023-05-09 17:35:00 +02:00
case ! isPath :
log . Debug ( "config raw from cmdline" )
2023-05-04 23:49:25 +02:00
err = dhall . Unmarshal ( [ ] byte ( conf ) , & config )
}
2023-05-21 12:44:18 +02:00
if ! config . Logger . JSON {
2023-05-09 17:35:00 +02:00
slogger = slogging . Init ( false )
log . Logger = slogger . Logger . With (
slog . Group ( "pcmt extra" , slog . String ( "module" , "config" ) ) ,
)
2023-03-19 22:03:12 +01:00
}
2023-05-12 23:22:48 +02:00
if config . DevelMode && os . Getenv ( "PCMT_DEVEL" ) != "False" {
2023-05-09 17:35:00 +02:00
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" ) ) ,
)
2023-05-04 23:49:25 +02:00
2023-05-09 17:35:00 +02:00
log . Debugf ( "parsed config: %+v" , config )
2023-05-04 23:49:25 +02:00
if dhallCmdExists ( ) {
_ = prettyPrintConfig ( conf , isPath )
}
}
2023-04-13 00:07:08 +02:00
2023-05-13 21:33:55 +02:00
// 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).
2023-05-09 17:35:00 +02:00
if err != nil {
return nil , err
}
2023-05-21 12:44:18 +02:00
// 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
}
2023-05-21 18:50:41 +02:00
if config . Init . CreateAdmin && config . Init . AdminPassword == "" {
return nil , errInitAdminPasswdEmpty
}
2023-03-19 22:03:12 +01:00
return & config , nil
}
2023-05-04 23:49:25 +02:00
2023-05-21 12:44:18 +02:00
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
}
2023-05-04 23:49:25 +02:00
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 {
2023-05-09 17:35:00 +02:00
log . Debug ( "could not pretty-print config" , "error" , err )
2023-05-04 23:49:25 +02:00
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 {
2023-05-09 17:35:00 +02:00
log . Debug ( "no command dhall" )
2023-05-04 23:49:25 +02:00
return false
}
return true
}