Files
webdav/lib/config.go
T

475 lines
13 KiB
Go

package lib
import (
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"github.com/go-viper/mapstructure/v2"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
const (
DefaultTLS = false
DefaultCert = "cert.pem"
DefaultKey = "key.pem"
DefaultAddress = "0.0.0.0"
DefaultPort = 6065
DefaultPrefix = "/"
)
var errDirectoryConflict = errors.New("directory and directories cannot both be defined")
type Config struct {
UserPermissions `mapstructure:",squash"`
Debug bool
Address string
Port int
TLS bool
Cert string
Key string
Prefix string
NoSniff bool
NoPassword bool
BehindProxy bool
Log Log
CORS CORS
Listing Listing
Users []User
}
func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
v := viper.NewWithOptions(viper.ExperimentalBindStruct())
// Configure flags bindings
if flags != nil {
err := v.BindPFlags(flags)
if err != nil {
return nil, err
}
}
// Configuration file settings
v.AddConfigPath(".")
v.AddConfigPath("/etc/webdav/")
if path, err := os.Executable(); err == nil {
v.AddConfigPath(filepath.Dir(path))
}
v.SetConfigName("config")
if filename != "" {
v.SetConfigFile(filename)
}
// Environment settings
v.SetEnvPrefix("wd")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
// Defaults shared with flags
v.SetDefault("TLS", DefaultTLS)
v.SetDefault("Cert", DefaultCert)
v.SetDefault("Key", DefaultKey)
v.SetDefault("Address", DefaultAddress)
v.SetDefault("Port", DefaultPort)
v.SetDefault("Prefix", DefaultPrefix)
// Other defaults
v.SetDefault("RulesBehavior", RulesOverwrite)
v.SetDefault("Directory", ".")
v.SetDefault("Permissions", "R")
v.SetDefault("Debug", false)
v.SetDefault("NoSniff", false)
v.SetDefault("NoPassword", false)
v.SetDefault("Log.Format", "console")
v.SetDefault("Log.Outputs", []string{"stderr"})
v.SetDefault("Log.Colors", true)
v.SetDefault("CORS.Allowed_Hosts", []string{"*"})
v.SetDefault("CORS.Allowed_Headers", []string{"Authorization", "Content-Type", "Content-Range", "Depth", "Destination", "If", "Lock-Token", "Overwrite", "X-Update-Range"})
v.SetDefault("CORS.Allowed_Methods", []string{"COPY", "DELETE", "GET", "HEAD", "LOCK", "MKCOL", "MOVE", "OPTIONS", "PATCH", "POST", "PROPFIND", "PROPPATCH", "PUT", "UNLOCK"})
v.SetDefault("Listing.Enabled", false)
v.SetDefault("Listing.Hide_Parent_Dir", false)
v.SetDefault("Listing.Show_Path", true)
v.SetDefault("Listing.Header", "")
v.SetDefault("Listing.Header_File", "")
v.SetDefault("Listing.Footer", "")
v.SetDefault("Listing.Footer_File", "")
// Read and unmarshal configuration
err := v.ReadInConfig()
if err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return nil, err
}
}
cfg := &Config{}
err = v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
directoryMountsDecodeHook(),
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","),
mapstructure.TextUnmarshallerHookFunc(),
)))
if err != nil {
return nil, err
}
err = applyDirectoryConfig(v, flags, &cfg.UserPermissions, "directory", "directories", nil)
if err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
// Cascade user settings
for i := range cfg.Users {
userDirectoryKey := fmt.Sprintf("Users.%d.Directory", i)
userDirectoriesKey := fmt.Sprintf("Users.%d.Directories", i)
if !v.IsSet(userDirectoryKey) {
cfg.Users[i].Directory = cfg.Directory
}
err := applyDirectoryConfig(v, flags, &cfg.Users[i].UserPermissions, userDirectoryKey, userDirectoriesKey, &cfg.UserPermissions)
if err != nil {
if errors.Is(err, errDirectoryConflict) {
return nil, fmt.Errorf("invalid config: user %q cannot define both directory and directories", cfg.Users[i].Username)
}
return nil, fmt.Errorf("invalid config: user %q: %w", cfg.Users[i].Username, err)
}
// Viper does not apply environment variables to fields of slice elements
// when unmarshaling, so explicitly set values are read back with Get.
userPermissionsKey := fmt.Sprintf("Users.%d.Permissions", i)
if isExplicitlySet(v, flags, userPermissionsKey) {
permissions := Permissions{}
err := permissions.UnmarshalText([]byte(v.GetString(userPermissionsKey)))
if err != nil {
return nil, fmt.Errorf("invalid config: user %q: %w", cfg.Users[i].Username, err)
}
cfg.Users[i].Permissions = permissions
} else {
cfg.Users[i].Permissions = cfg.Permissions
}
userRulesBehaviorKey := fmt.Sprintf("Users.%d.RulesBehavior", i)
if isExplicitlySet(v, flags, userRulesBehaviorKey) {
cfg.Users[i].RulesBehavior = RulesBehavior(v.GetString(userRulesBehaviorKey))
} else {
cfg.Users[i].RulesBehavior = cfg.RulesBehavior
}
userRulesKey := fmt.Sprintf("Users.%d.Rules", i)
if os.Getenv(envKey(userRulesKey)) != "" {
return nil, fmt.Errorf("invalid config: user %q: rules cannot be set via environment variables", cfg.Users[i].Username)
}
if isExplicitlySet(v, flags, userRulesKey) {
switch cfg.Users[i].RulesBehavior {
case RulesOverwrite:
// Do nothing
case RulesAppend:
rules := append([]*Rule{}, cfg.Rules...)
rules = append(rules, cfg.Users[i].Rules...)
cfg.Users[i].Rules = rules
}
} else {
cfg.Users[i].Rules = cfg.Rules
}
}
err = cfg.Validate()
if err != nil {
return nil, err
}
return cfg, nil
}
func applyDirectoryConfig(v *viper.Viper, flags *pflag.FlagSet, permissions *UserPermissions, directoryKey, directoriesKey string, inherited *UserPermissions) error {
permissions.directoryExplicit = isExplicitlySet(v, flags, directoryKey)
permissions.directoriesExplicit = isExplicitlySet(v, flags, directoriesKey)
if permissions.directoryExplicit && permissions.directoriesExplicit {
return errDirectoryConflict
}
switch {
case permissions.directoryExplicit:
permissions.Directory = v.GetString(directoryKey)
permissions.useDirectories = false
case permissions.directoriesExplicit:
directories, err := getDirectoryMounts(v, directoriesKey, permissions.Directories)
if err != nil {
return err
}
permissions.Directories = directories
permissions.useDirectories = true
case inherited != nil:
permissions.Directories = append(DirectoryMounts{}, inherited.Directories...)
permissions.useDirectories = inherited.useDirectories
}
return nil
}
func isExplicitlySet(v *viper.Viper, flags *pflag.FlagSet, key string) bool {
if flags != nil && flags.Changed(key) {
return true
}
if v.InConfig(key) {
return true
}
value, ok := os.LookupEnv(envKey(key))
return ok && value != ""
}
func envKey(key string) string {
return "WD_" + strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
}
func (c *Config) Validate() error {
var err error
c.Directory, err = filepath.Abs(c.Directory)
if err != nil {
return fmt.Errorf("invalid config: %w", err)
}
if c.TLS {
if c.Cert == "" {
return errors.New("invalid config: Cert must be defined if TLS is activated")
}
if c.Key == "" {
return errors.New("invalid config: Key must be defined if TLS is activated")
}
c.Cert, err = filepath.Abs(c.Cert)
if err != nil {
return fmt.Errorf("invalid config: %w", err)
}
c.Key, err = filepath.Abs(c.Key)
if err != nil {
return fmt.Errorf("invalid config: %w", err)
}
}
err = c.UserPermissions.Validate()
if err != nil {
return fmt.Errorf("invalid config: %w", err)
}
err = c.Listing.Load()
if err != nil {
return fmt.Errorf("invalid config: %w", err)
}
usernames := map[string]struct{}{}
for i := range c.Users {
err := c.Users[i].Validate(c.NoPassword)
if err != nil {
return fmt.Errorf("invalid config: %w", err)
}
if _, ok := usernames[c.Users[i].Username]; ok {
return fmt.Errorf("invalid config: duplicate username %q", c.Users[i].Username)
}
usernames[c.Users[i].Username] = struct{}{}
}
return nil
}
func directoryMountsDecodeHook() mapstructure.DecodeHookFunc {
mountsType := reflect.TypeOf(DirectoryMounts{})
return func(from reflect.Type, to reflect.Type, data any) (any, error) {
if to != mountsType {
return data, nil
}
return decodeDirectoryMounts(data)
}
}
func getDirectoryMounts(v *viper.Viper, key string, fallback DirectoryMounts) (DirectoryMounts, error) {
value := v.Get(key)
if value == nil {
return fallback, nil
}
return decodeDirectoryMounts(value)
}
func decodeDirectoryMounts(data any) (DirectoryMounts, error) {
switch value := data.(type) {
case nil:
return DirectoryMounts{}, nil
case DirectoryMounts:
return value, nil
case []DirectoryMount:
return DirectoryMounts(value), nil
case string:
if value == "" {
return DirectoryMounts{}, nil
}
parts := strings.Split(value, ",")
mounts := make(DirectoryMounts, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
mounts = append(mounts, DirectoryMount{Path: part})
}
return mounts, nil
case []any:
mounts := make(DirectoryMounts, 0, len(value))
for _, item := range value {
mount, err := decodeDirectoryMount(item)
if err != nil {
return nil, err
}
mounts = append(mounts, mount)
}
return mounts, nil
case []string:
mounts := make(DirectoryMounts, 0, len(value))
for _, item := range value {
mounts = append(mounts, DirectoryMount{Path: item})
}
return mounts, nil
default:
return nil, fmt.Errorf("invalid directories: unsupported value %T", data)
}
}
func decodeDirectoryMount(data any) (DirectoryMount, error) {
switch value := data.(type) {
case string:
return DirectoryMount{Path: value}, nil
case map[string]any:
return decodeDirectoryMountMap(value)
case map[any]any:
m := map[string]any{}
for key, value := range value {
keyString, ok := key.(string)
if !ok {
return DirectoryMount{}, errors.New("invalid directories: mount keys must be strings")
}
m[keyString] = value
}
return decodeDirectoryMountMap(m)
default:
return DirectoryMount{}, fmt.Errorf("invalid directories: unsupported mount entry %T", data)
}
}
func decodeDirectoryMountMap(data map[string]any) (DirectoryMount, error) {
_, hasName := data["name"]
_, hasPath := data["path"]
if hasName || hasPath {
name, nameOK := data["name"].(string)
path, pathOK := data["path"].(string)
if !nameOK || !pathOK || len(data) != 2 {
return DirectoryMount{}, errors.New("invalid directories: explicit mount objects must define name and path")
}
return DirectoryMount{Name: name, Path: path}, nil
}
if len(data) != 1 {
return DirectoryMount{}, errors.New("invalid directories: mapped mount entries must have exactly one key")
}
for name, path := range data {
pathString, ok := path.(string)
if !ok {
return DirectoryMount{}, errors.New("invalid directories: mapped mount paths must be strings")
}
return DirectoryMount{Name: name, Path: pathString}, nil
}
return DirectoryMount{}, errors.New("invalid directories: empty mount entry")
}
func (cfg *Config) GetLogger() (*zap.Logger, error) {
loggerConfig := zap.NewProductionConfig()
loggerConfig.DisableCaller = true
if cfg.Debug {
loggerConfig.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
}
if cfg.Log.Colors && cfg.Log.Format != "json" {
loggerConfig.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
}
loggerConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
loggerConfig.Encoding = cfg.Log.Format
loggerConfig.OutputPaths = cfg.Log.Outputs
return loggerConfig.Build()
}
type Log struct {
Format string
Colors bool
Outputs []string
}
type CORS struct {
Enabled bool
Credentials bool
AllowPrivateNetwork bool `mapstructure:"allow_private_network"`
AllowedHeaders []string `mapstructure:"allowed_headers"`
AllowedHosts []string `mapstructure:"allowed_hosts"`
AllowedMethods []string `mapstructure:"allowed_methods"`
ExposedHeaders []string `mapstructure:"exposed_headers"`
}
// Listing.Header and Footer, when set, replace the entire outer HTML
// document shell (doctype, <html>, <head>, <body> open/close) instead of
// being inserted as fragments inside a default shell: Header must contain
// the full opening, up to and including <body>, and Footer must contain the
// full closing, i.e. </body></html>. They must be configured together.
type Listing struct {
Enabled bool
HideParentDir bool `mapstructure:"hide_parent_dir"`
ShowPath bool `mapstructure:"show_path"`
Header string
HeaderFile string `mapstructure:"header_file"`
Footer string
FooterFile string `mapstructure:"footer_file"`
}
var errHeaderFooterMismatch = errors.New("listing: header and footer must both be set, or both left empty")
func (bl *Listing) Load() error {
if bl.HeaderFile != "" {
content, err := os.ReadFile(bl.HeaderFile)
if err != nil {
return fmt.Errorf("failed to read header file: %w", err)
}
bl.Header = string(content)
}
if bl.FooterFile != "" {
content, err := os.ReadFile(bl.FooterFile)
if err != nil {
return fmt.Errorf("failed to read footer file: %w", err)
}
bl.Footer = string(content)
}
if (bl.Header == "") != (bl.Footer == "") {
return errHeaderFooterMismatch
}
return nil
}