From c125bedae1607a2a533b18a9451b9be34a5ee396 Mon Sep 17 00:00:00 2001 From: Henrique Dias Date: Sun, 21 Jul 2024 20:52:50 +0200 Subject: [PATCH] refactor: code cleanup, stricter config validation (#155) --- .github/workflows/build.yml | 2 + .github/workflows/lint.yml | 2 + .github/workflows/test.yml | 21 ++++ README.md | 34 +++--- cmd/config.go | 224 ------------------------------------ cmd/root.go | 166 +++++++++++++------------- cmd/version.go | 2 +- cmd/viper.go | 55 --------- go.mod | 4 + go.sum | 2 + lib/config.go | 153 ++++++++++++++++++++++++ lib/config_test.go | 71 ++++++++++++ lib/{dir.go => files.go} | 81 +++++++------ lib/handler.go | 133 +++++++++++++++++++++ lib/permissions.go | 85 ++++++++++++++ lib/response_writer.go | 27 +++++ lib/user.go | 76 ++++++------ lib/utils.go | 25 ---- lib/webdav.go | 174 ---------------------------- 19 files changed, 683 insertions(+), 654 deletions(-) create mode 100644 .github/workflows/test.yml delete mode 100644 cmd/config.go delete mode 100644 cmd/viper.go create mode 100644 lib/config.go create mode 100644 lib/config_test.go rename lib/{dir.go => files.go} (56%) create mode 100644 lib/handler.go create mode 100644 lib/permissions.go create mode 100644 lib/response_writer.go mode change 100755 => 100644 lib/user.go delete mode 100755 lib/utils.go delete mode 100644 lib/webdav.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 617e4f8..9941e96 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,5 @@ name: Build + on: push: tags: @@ -6,6 +7,7 @@ on: branches: - main pull_request: + jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 26ad40e..7c51cf8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,4 +1,5 @@ name: Lint + on: push: tags: @@ -6,6 +7,7 @@ on: branches: - main pull_request: + jobs: lint: runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..8760b4a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,21 @@ +name: Test + +on: + push: + tags: + - v* + branches: + - main + pull_request: + +jobs: + test: + name: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.22.x" + - name: Run test with coverage + run: go test -race -coverprofile=coverage.txt -covermode=atomic ./... diff --git a/README.md b/README.md index d584856..64e5dd1 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@ -> ⚠️ Disclaimer: this repository is not actively maintained. If you are interested in maintaining it, please [contact me](https://github.com/hacdias/webdav/issues/144). - # webdav -![Build](https://github.com/hacdias/webdav/workflows/Tests/badge.svg) [![Go Report Card](https://goreportcard.com/badge/github.com/hacdias/webdav?style=flat-square)](https://goreportcard.com/report/hacdias/webdav) [![Version](https://img.shields.io/github/release/hacdias/webdav.svg?style=flat-square)](https://github.com/hacdias/webdav/releases/latest) -[![Docker Pulls](https://img.shields.io/docker/pulls/hacdias/webdav)](https://hub.docker.com/r/hacdias/webdav) +[![Docker Pulls](https://img.shields.io/docker/pulls/hacdias/webdav?style=flat-square)](https://hub.docker.com/r/hacdias/webdav) + +A simple and standalone [WebDAV](https://en.wikipedia.org/wiki/WebDAV) server. ## Install @@ -13,7 +12,7 @@ Please refer to the [Releases page](https://github.com/hacdias/webdav/releases) ## Usage -```webdav``` command line interface is really easy to use so you can easily create a WebDAV server for your own user. By default, it runs on a random free port and supports JSON, YAML and TOML configuration. An example of a YAML configuration with the default configurations: +`webdav` command line interface is really easy to use so you can easily create a WebDAV server for your own user. By default, it runs on a random free port and supports JSON, YAML and TOML configuration. An example of a YAML configuration with the default configurations: ```yaml # Server related settings @@ -55,7 +54,7 @@ users: password: "{env}ENV_PASSWORD" - username: basic password: basic - modify: false + modify: false rules: - regex: false allow: false @@ -79,17 +78,22 @@ The `allowed_*` properties are optional, the default value for each of them will ### Reverse Proxy Service When you use a reverse proxy implementation like `Nginx` or `Apache`, please note the following fields to avoid causing `502` errors -```text + +```nginx location / { - proxy_pass http://127.0.0.1:8080; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header REMOTE-HOST $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $http_host; - proxy_redirect off; - } + proxy_pass http://127.0.0.1:8080; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header REMOTE-HOST $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + proxy_redirect off; +} ``` +## Contributing + +Feel free to open an issue or a pull request. + ## License -MIT © [Henrique Dias](https://hacdias.com) +[MIT License](LICENSE) © [Henrique Dias](https://hacdias.com) \ No newline at end of file diff --git a/cmd/config.go b/cmd/config.go deleted file mode 100644 index 4632b94..0000000 --- a/cmd/config.go +++ /dev/null @@ -1,224 +0,0 @@ -package cmd - -import ( - "errors" - "log" - "os" - "regexp" - "strconv" - "strings" - - "github.com/hacdias/webdav/v4/lib" - "github.com/spf13/pflag" - v "github.com/spf13/viper" - "golang.org/x/net/webdav" -) - -func parseRules(raw []interface{}, defaultModify bool) []*lib.Rule { - rules := []*lib.Rule{} - - for _, v := range raw { - if r, ok := v.(map[interface{}]interface{}); ok { - rule := &lib.Rule{ - Regex: false, - Allow: false, - Modify: defaultModify, - Path: "", - } - - if regex, ok := r["regex"].(bool); ok { - rule.Regex = regex - } - - if allow, ok := r["allow"].(bool); ok { - rule.Allow = allow - } - - if modify, ok := r["modify"].(bool); ok { - rule.Modify = modify - if modify { - rule.Allow = true - } - } - - path, ok := r["path"].(string) - if !ok { - continue - } - - if rule.Regex { - rule.Regexp = regexp.MustCompile(path) - } else { - rule.Path = path - } - - rules = append(rules, rule) - } - } - - return rules -} - -func loadFromEnv(v string) (string, error) { - v = strings.TrimPrefix(v, "{env}") - if v == "" { - return "", errors.New("no environment variable specified") - } - - v = os.Getenv(v) - if v == "" { - return "", errors.New("the environment variable is empty") - } - - return v, nil -} - -func parseUsers(raw []interface{}, c *lib.Config) { - var err error - for _, v := range raw { - if u, ok := v.(map[interface{}]interface{}); ok { - username, ok := u["username"].(string) - if !ok { - log.Fatal("user needs an username") - } - - if strings.HasPrefix(username, "{env}") { - username, err = loadFromEnv(username) - checkErr(err) - } - - password, ok := u["password"].(string) - if !ok { - password = "" - - if numPwd, ok := u["password"].(int); ok { - password = strconv.Itoa(numPwd) - } - } - - if strings.HasPrefix(password, "{env}") { - password, err = loadFromEnv(password) - checkErr(err) - } - - user := &lib.User{ - Username: username, - Password: password, - Scope: c.User.Scope, - Modify: c.User.Modify, - Rules: c.User.Rules, - } - - if scope, ok := u["scope"].(string); ok { - user.Scope = scope - } - - if modify, ok := u["modify"].(bool); ok { - user.Modify = modify - } - - if rules, ok := u["rules"].([]interface{}); ok { - user.Rules = append(c.User.Rules, parseRules(rules, user.Modify)...) - } - - user.Handler = &webdav.Handler{ - Prefix: c.User.Handler.Prefix, - FileSystem: lib.WebDavDir{ - Dir: webdav.Dir(user.Scope), - NoSniff: c.NoSniff, - }, - LockSystem: webdav.NewMemLS(), - } - - c.Users[username] = user - } - } -} - -func parseCors(cfg map[string]interface{}, c *lib.Config) { - cors := lib.CorsCfg{ - Enabled: cfg["enabled"].(bool), - Credentials: cfg["credentials"].(bool), - } - - cors.AllowedHeaders = corsProperty("allowed_headers", cfg) - cors.AllowedHosts = corsProperty("allowed_hosts", cfg) - cors.AllowedMethods = corsProperty("allowed_methods", cfg) - cors.ExposedHeaders = corsProperty("exposed_headers", cfg) - - c.Cors = cors -} - -func corsProperty(property string, cfg map[string]interface{}) []string { - var def []string - - if property == "exposed_headers" { - def = []string{} - } else { - def = []string{"*"} - } - - if allowed, ok := cfg[property].([]interface{}); ok { - items := make([]string, len(allowed)) - - for idx, a := range allowed { - items[idx] = a.(string) - } - - if len(items) == 0 { - return def - } - - return items - } - - return def -} - -func readConfig(flags *pflag.FlagSet) *lib.Config { - cfg := &lib.Config{ - User: &lib.User{ - Scope: getOpt(flags, "scope"), - Modify: getOptB(flags, "modify"), - Rules: []*lib.Rule{}, - Handler: &webdav.Handler{ - Prefix: getOpt(flags, "prefix"), - FileSystem: lib.WebDavDir{ - Dir: webdav.Dir(getOpt(flags, "scope")), - NoSniff: getOptB(flags, "nosniff"), - }, - LockSystem: webdav.NewMemLS(), - }, - }, - Debug: getOptB(flags, "debug"), - Auth: getOptB(flags, "auth"), - NoSniff: getOptB(flags, "nosniff"), - Cors: lib.CorsCfg{ - Enabled: false, - Credentials: false, - }, - Users: map[string]*lib.User{}, - LogFormat: getOpt(flags, "log_format"), - } - - rawRules := v.Get("rules") - if rules, ok := rawRules.([]interface{}); ok { - cfg.User.Rules = parseRules(rules, cfg.User.Modify) - } - - rawUsers := v.Get("users") - if users, ok := rawUsers.([]interface{}); ok { - parseUsers(users, cfg) - } - - rawCors := v.Get("cors") - if cors, ok := rawCors.(map[string]interface{}); ok { - parseCors(cors, cfg) - } - - if len(cfg.Users) != 0 && !cfg.Auth { - log.Print("Users will be ignored due to auth=false") - } - - return cfg -} diff --git a/cmd/root.go b/cmd/root.go index 4c29c82..26937b3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,7 +1,8 @@ package cmd import ( - "log" + "errors" + "fmt" "net" "net/http" "os" @@ -9,27 +10,21 @@ import ( "strings" "syscall" + "github.com/hacdias/webdav/v4/lib" "github.com/spf13/cobra" - v "github.com/spf13/viper" "go.uber.org/zap" "go.uber.org/zap/zapcore" ) -var ( - cfgFile string -) - func init() { - cobra.OnInitialize(initConfig) - flags := rootCmd.Flags() - flags.StringVarP(&cfgFile, "config", "c", "", "config file path") - flags.BoolP("tls", "t", false, "enable tls") - flags.Bool("auth", true, "enable auth") - flags.String("cert", "cert.pem", "TLS certificate") - flags.String("key", "key.pem", "TLS key") - flags.StringP("address", "a", "0.0.0.0", "address to listen to") - flags.StringP("port", "p", "0", "port to listen to") + flags.StringP("config", "c", "", "config file path") + flags.BoolP("tls", "t", false, "enable TLS") + flags.Bool("auth", false, "enable authentication") + flags.String("cert", "cert.pem", "path to TLS certificate") + flags.String("key", "key.pem", "path to TLS key") + flags.StringP("address", "a", "0.0.0.0", "address to listen on") + flags.StringP("port", "p", "0", "port to listen on") flags.StringP("prefix", "P", "/", "URL path prefix") flags.String("log_format", "console", "logging format") } @@ -53,91 +48,98 @@ The precedence of the configuration values are as follows: The environment variables are prefixed by "WD_" followed by the option name in caps. So to set "cert" via an env variable, you should set WD_CERT.`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { flags := cmd.Flags() - cfg := readConfig(flags) - - // Build address and listener - laddr := getOpt(flags, "address") - var lnet string - if strings.HasPrefix(laddr, "unix:") { - laddr = laddr[5:] - lnet = "unix" - } else { - laddr = laddr + ":" + getOpt(flags, "port") - lnet = "tcp" - } - listener, err := net.Listen(lnet, laddr) - - sigc := make(chan os.Signal, 1) - signal.Notify(sigc, os.Interrupt, syscall.SIGTERM) - - go func(c chan os.Signal) { - // Wait for a SIGINT or SIGKILL: - sig := <-c - log.Printf("Caught signal %s: shutting down.", sig) - // Stop listening (and unlink the socket if unix type): - listener.Close() - // And we're done: - os.Exit(0) - }(sigc) + cfgFilename, _ := flags.GetString("config") + cfg, err := lib.ParseConfig(cfgFilename, flags) if err != nil { - log.Fatal(err) + return err } - loggerConfig := zap.NewProductionConfig() - loggerConfig.DisableCaller = true - if cfg.Debug { - loggerConfig.Level = zap.NewAtomicLevelAt(zap.DebugLevel) - } - loggerConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder - loggerConfig.Encoding = cfg.LogFormat - logger, err := loggerConfig.Build() + + // Create HTTP handler from the config + handler, err := lib.NewHandler(cfg) if err != nil { - // if we fail to configure proper logging, then the user has deliberately - // misconfigured the logger. Abort. - panic(err) + return err } - zap.ReplaceGlobals(logger) + + // Setup the logger based on the configuration + err = setupLogger(cfg) + if err != nil { + return err + } + defer func() { + // Flush the logger at the end _ = zap.L().Sync() }() - // Tell the user the port in which is listening. - zap.L().Info("Listening", zap.String("address", listener.Addr().String())) - // Starts the server. - if getOptB(flags, "tls") { - if err := http.ServeTLS(listener, cfg, getOpt(flags, "cert"), getOpt(flags, "key")); err != nil { - zap.L().Fatal("shutting server", zap.Error(err)) - } - } else { - if err := http.Serve(listener, cfg); err != nil { - zap.L().Fatal("shutting server", zap.Error(err)) - } + // Build listener + listener, err := getListener(cfg) + if err != nil { + return err } + + // Trap exiting signals + quit := make(chan os.Signal, 1) + + go func() { + zap.L().Info("listening", zap.String("address", listener.Addr().String())) + + var err error + if cfg.TLS { + err = http.ServeTLS(listener, handler, cfg.Cert, cfg.Key) + } else { + err = http.Serve(listener, handler) + } + + if err != nil && !errors.Is(err, http.ErrServerClosed) { + zap.L().Error("failed to start server", zap.Error(err)) + } + + quit <- os.Interrupt + }() + + signal.Notify(quit, os.Interrupt, syscall.SIGTERM) + signal := <-quit + + zap.L().Info("caught signal, shutting down", zap.Stringer("signal", signal)) + _ = listener.Close() + + return nil }, } -func initConfig() { - if cfgFile == "" { - v.AddConfigPath(".") - v.AddConfigPath("/etc/webdav/") - v.SetConfigName("config") +func getListener(cfg *lib.Config) (net.Listener, error) { + var ( + address string + network string + ) + + if strings.HasPrefix(cfg.Address, "unix:") { + address = cfg.Address[5:] + network = "unix" } else { - v.SetConfigFile(cfgFile) + address = fmt.Sprintf("%s:%d", cfg.Address, cfg.Port) + network = "tcp" } - v.SetEnvPrefix("WD") - v.AutomaticEnv() - v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + return net.Listen(network, address) +} - if err := v.ReadInConfig(); err != nil { - if _, ok := err.(v.ConfigParseError); ok { - panic(err) - } - cfgFile = "No config file used" - } else { - cfgFile = "Using config file: " + v.ConfigFileUsed() +func setupLogger(cfg *lib.Config) error { + loggerConfig := zap.NewProductionConfig() + loggerConfig.DisableCaller = true + if cfg.Debug { + loggerConfig.Level = zap.NewAtomicLevelAt(zap.DebugLevel) } + loggerConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder + loggerConfig.Encoding = cfg.LogFormat + logger, err := loggerConfig.Build() + if err != nil { + return err + } + zap.ReplaceGlobals(logger) + return nil } diff --git a/cmd/version.go b/cmd/version.go index c277dbb..acad500 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -13,7 +13,7 @@ func init() { Use: "version", Short: "Print the version number", Run: func(cmd *cobra.Command, args []string) { - fmt.Printf("WebDAV version: %q", version) + fmt.Printf("WebDAV version: %s\n", version) }, }) } diff --git a/cmd/viper.go b/cmd/viper.go deleted file mode 100644 index 18d5330..0000000 --- a/cmd/viper.go +++ /dev/null @@ -1,55 +0,0 @@ -package cmd - -import ( - "log" - - "github.com/spf13/pflag" - v "github.com/spf13/viper" -) - -// getOption returns a parameter as a string. -// -// NOTE: we could simply bind the flags to viper and use IsSet. -// Although there is a bug on Viper that always returns true on IsSet -// if a flag is binded. Our alternative way is to manually check -// the flag and then the value from env/config/gotten by viper. -// https://github.com/spf13/viper/pull/331 -func getOpt(flags *pflag.FlagSet, key string) string { - value, _ := flags.GetString(key) - - // If set on Flags, use it. - if flags.Changed(key) { - return value - } - - // If set through viper (env, config), return it. - if v.IsSet(key) { - return v.GetString(key) - } - - // Otherwise use default value on flags. - return value -} - -func getOptB(flags *pflag.FlagSet, key string) bool { - value, _ := flags.GetBool(key) - - // If set on Flags, use it. - if flags.Changed(key) { - return value - } - - // If set through viper (env, config), return it. - if v.IsSet(key) { - return v.GetBool(key) - } - - // Otherwise use default value on flags. - return value -} - -func checkErr(err error) { - if err != nil { - log.Fatal(err) - } -} diff --git a/go.mod b/go.mod index 3518dcf..1048ae2 100644 --- a/go.mod +++ b/go.mod @@ -3,21 +3,25 @@ module github.com/hacdias/webdav/v4 go 1.22 require ( + github.com/rs/cors v1.11.0 github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.19.0 + github.com/stretchr/testify v1.9.0 go.uber.org/zap v1.27.0 golang.org/x/crypto v0.25.0 golang.org/x/net v0.27.0 ) require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/sagikazarmark/locafero v0.6.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect diff --git a/go.sum b/go.sum index e6cca7e..3b7682a 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po= +github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk= github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= diff --git a/lib/config.go b/lib/config.go new file mode 100644 index 0000000..a1e6b15 --- /dev/null +++ b/lib/config.go @@ -0,0 +1,153 @@ +package lib + +import ( + "errors" + "fmt" + "path/filepath" + "strings" + + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +type Config struct { + Permissions `mapstructure:",squash"` + Debug bool + Address string + Port int + TLS bool + Cert string + Key string + Prefix string + NoSniff bool + LogFormat string + Auth bool + CORS CORS + Users []User +} + +func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) { + v := viper.New() + + // Configure flags bindings + if flags != nil { + err := v.BindPFlags(flags) + if err != nil { + return nil, err + } + + err = v.BindPFlag("LogFormat", flags.Lookup("log_format")) + if err != nil { + return nil, err + } + } + + // Configuration file settings + v.AddConfigPath(".") + v.AddConfigPath("/etc/webdav/") + v.SetConfigName("config") + if filename != "" { + v.SetConfigFile(filename) + } + + // Environment settings + v.SetEnvPrefix("wd") + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AutomaticEnv() + + // Defaults + v.SetDefault("CORS.AllowedHeaders", []string{"*"}) + v.SetDefault("CORS.AllowedHosts", []string{"*"}) + v.SetDefault("CORS.AllowedMethods", []string{"*"}) + + // 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) + if err != nil { + return nil, err + } + + // Cascade user settings + for i := range cfg.Users { + if !v.IsSet(fmt.Sprintf("Users.%d.Scope", i)) { + cfg.Users[i].Scope = cfg.Scope + } + + if !v.IsSet(fmt.Sprintf("Users.%d.Modify", i)) { + cfg.Users[i].Modify = cfg.Modify + } + + if !v.IsSet(fmt.Sprintf("Users.%d.Rules", i)) { + cfg.Users[i].Rules = cfg.Rules + } + } + + err = cfg.Validate() + if err != nil { + return nil, err + } + + return cfg, nil +} + +func (c *Config) Validate() error { + var err error + + if c.Auth && len(c.Users) == 0 { + return errors.New("invalid config: auth cannot be enabled without users") + } + + if !c.Auth && len(c.Users) != 0 { + return errors.New("invalid config: auth cannot be disabled with users defined") + } + + 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.Permissions.Validate() + if err != nil { + return fmt.Errorf("invalid config: %w", err) + } + + for _, u := range c.Users { + err := u.Validate() + if err != nil { + return fmt.Errorf("invalid config: %w", err) + } + } + + return nil +} + +type CORS struct { + Enabled bool + Credentials bool + AllowedHeaders []string + AllowedHosts []string + AllowedMethods []string + ExposedHeaders []string +} diff --git a/lib/config_test.go b/lib/config_test.go new file mode 100644 index 0000000..6e7ec63 --- /dev/null +++ b/lib/config_test.go @@ -0,0 +1,71 @@ +package lib + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func writeAndParseConfig(t *testing.T, content string) *Config { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "config.yml") + + err := os.WriteFile(tmpFile, []byte(content), 0666) + require.NoError(t, err) + + cfg, err := ParseConfig(tmpFile, nil) + require.NoError(t, err) + + return cfg +} + +func TestConfigDefaults(t *testing.T) { + t.Parallel() + + cfg := writeAndParseConfig(t, "") + require.NoError(t, cfg.Validate()) + + require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHeaders) + require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHosts) + require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedMethods) +} + +func TestConfigCascade(t *testing.T) { + t.Parallel() + + content := ` +auth: true +scope: / +modify: true +rules: + - path: /public/access/ + modify: true + +users: + - username: admin + password: admin + - username: basic + password: basic + scope: /basic + modify: false + rules: []` + + cfg := writeAndParseConfig(t, content) + require.NoError(t, cfg.Validate()) + + require.True(t, cfg.Modify) + require.Equal(t, "/", cfg.Scope) + require.Len(t, cfg.Rules, 1) + + require.Len(t, cfg.Users, 2) + + require.True(t, cfg.Users[0].Modify) + require.Equal(t, "/", cfg.Users[0].Scope) + require.Len(t, cfg.Users[0].Rules, 1) + + require.False(t, cfg.Users[1].Modify) + require.Equal(t, "/basic", cfg.Users[1].Scope) + require.Len(t, cfg.Users[1].Rules, 0) +} diff --git a/lib/dir.go b/lib/files.go similarity index 56% rename from lib/dir.go rename to lib/files.go index 8c16db8..fab7cd6 100644 --- a/lib/dir.go +++ b/lib/files.go @@ -9,12 +9,44 @@ import ( "golang.org/x/net/webdav" ) -// NoSniffFileInfo wraps any generic FileInfo interface and bypasses mime type sniffing. -type NoSniffFileInfo struct { +type Dir struct { + webdav.Dir + noSniff bool +} + +func (d Dir) Stat(ctx context.Context, name string) (os.FileInfo, error) { + // Skip wrapping if NoSniff is off + if !d.noSniff { + return d.Dir.Stat(ctx, name) + } + + info, err := d.Dir.Stat(ctx, name) + if err != nil { + return nil, err + } + + return noSniffFileInfo{info}, nil +} + +func (d Dir) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) { + // Skip wrapping if NoSniff is off + if !d.noSniff { + return d.Dir.OpenFile(ctx, name, flag, perm) + } + + file, err := d.Dir.OpenFile(ctx, name, flag, perm) + if err != nil { + return nil, err + } + + return noSniffFile{File: file}, nil +} + +type noSniffFileInfo struct { os.FileInfo } -func (w NoSniffFileInfo) ContentType(ctx context.Context) (contentType string, err error) { +func (w noSniffFileInfo) ContentType(ctx context.Context) (contentType string, err error) { if mimeType := mime.TypeByExtension(path.Ext(w.FileInfo.Name())); mimeType != "" { // We can figure out the mime from the extension. return mimeType, nil @@ -24,60 +56,27 @@ func (w NoSniffFileInfo) ContentType(ctx context.Context) (contentType string, e } } -type WebDavDir struct { - webdav.Dir - NoSniff bool -} - -func (d WebDavDir) Stat(ctx context.Context, name string) (os.FileInfo, error) { - // Skip wrapping if NoSniff is off - if !d.NoSniff { - return d.Dir.Stat(ctx, name) - } - - info, err := d.Dir.Stat(ctx, name) - if err != nil { - return nil, err - } - - return NoSniffFileInfo{info}, nil -} - -func (d WebDavDir) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) { - // Skip wrapping if NoSniff is off - if !d.NoSniff { - return d.Dir.OpenFile(ctx, name, flag, perm) - } - - file, err := d.Dir.OpenFile(ctx, name, flag, perm) - if err != nil { - return nil, err - } - - return WebDavFile{File: file}, nil -} - -type WebDavFile struct { +type noSniffFile struct { webdav.File } -func (f WebDavFile) Stat() (os.FileInfo, error) { +func (f noSniffFile) Stat() (os.FileInfo, error) { info, err := f.File.Stat() if err != nil { return nil, err } - return NoSniffFileInfo{info}, nil + return noSniffFileInfo{info}, nil } -func (f WebDavFile) Readdir(count int) (fis []os.FileInfo, err error) { +func (f noSniffFile) Readdir(count int) (fis []os.FileInfo, err error) { fis, err = f.File.Readdir(count) if err != nil { return nil, err } for i := range fis { - fis[i] = NoSniffFileInfo{fis[i]} + fis[i] = noSniffFileInfo{fis[i]} } return fis, nil } diff --git a/lib/handler.go b/lib/handler.go new file mode 100644 index 0000000..68d1171 --- /dev/null +++ b/lib/handler.go @@ -0,0 +1,133 @@ +package lib + +import ( + "net/http" + "strings" + + "github.com/rs/cors" + "go.uber.org/zap" + "golang.org/x/net/webdav" +) + +type handlerUser struct { + User + webdav.Handler +} + +type Handler struct { + *Config + user *handlerUser + users map[string]*handlerUser +} + +func NewHandler(c *Config) (http.Handler, error) { + h := &Handler{ + user: &handlerUser{ + User: User{ + Permissions: c.Permissions, + }, + Handler: webdav.Handler{ + Prefix: c.Prefix, + FileSystem: Dir{ + Dir: webdav.Dir(c.Scope), + noSniff: c.NoSniff, + }, + LockSystem: webdav.NewMemLS(), + }, + }, + users: map[string]*handlerUser{}, + } + + for _, u := range c.Users { + h.users[u.Username] = &handlerUser{ + User: u, + Handler: webdav.Handler{ + Prefix: c.Prefix, + FileSystem: Dir{ + Dir: webdav.Dir(u.Scope), + noSniff: c.NoSniff, + }, + LockSystem: webdav.NewMemLS(), + }, + } + } + + if c.CORS.Enabled { + return cors.New(cors.Options{ + AllowCredentials: c.CORS.Credentials, + AllowedOrigins: c.CORS.AllowedHosts, + AllowedMethods: c.CORS.AllowedMethods, + AllowedHeaders: c.CORS.AllowedHeaders, + OptionsPassthrough: false, + }).Handler(h), nil + } + + return h, nil +} + +// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + user := h.user + + // Authentication + if h.Auth { + w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) + + // Gets the correct user for this request. + username, password, ok := r.BasicAuth() + zap.L().Info("login attempt", zap.String("username", username), zap.String("remote_address", r.RemoteAddr)) + if !ok { + http.Error(w, "Not authorized", http.StatusUnauthorized) + return + } + + user, ok = h.users[username] + if !ok { + http.Error(w, "Not authorized", http.StatusUnauthorized) + return + } + + if !user.checkPassword(password) { + zap.L().Info("invalid password", zap.String("username", username), zap.String("remote_address", r.RemoteAddr)) + http.Error(w, "Not authorized", http.StatusUnauthorized) + return + } + + zap.L().Info("user authorized", zap.String("username", username)) + } + + // Checks for user permissions relatively to this PATH. + allowed := user.Allowed(r) + + zap.L().Debug("allowed & method & path", zap.Bool("allowed", allowed), zap.String("method", r.Method), zap.String("path", r.URL.Path)) + + if !allowed { + w.WriteHeader(http.StatusForbidden) + return + } + + if r.Method == "HEAD" { + w = newResponseWriterNoBody(w) + } + + // Excerpt from RFC4918, section 9.4: + // + // GET, when applied to a collection, may return the contents of an + // "index.html" resource, a human-readable view of the contents of + // the collection, or something else altogether. + // + // Get, when applied to collection, will return the same as PROPFIND method. + if r.Method == "GET" && strings.HasPrefix(r.URL.Path, user.Prefix) { + info, err := user.FileSystem.Stat(r.Context(), strings.TrimPrefix(r.URL.Path, user.Prefix)) + if err == nil && info.IsDir() { + r.Method = "PROPFIND" + + if r.Header.Get("Depth") == "" { + r.Header.Add("Depth", "1") + } + } + } + + // Runs the WebDAV. + user.ServeHTTP(w, r) +} diff --git a/lib/permissions.go b/lib/permissions.go new file mode 100644 index 0000000..bd27c60 --- /dev/null +++ b/lib/permissions.go @@ -0,0 +1,85 @@ +package lib + +import ( + "fmt" + "net/http" + "regexp" + "strings" +) + +var readMethods = []string{ + http.MethodGet, + http.MethodHead, + http.MethodOptions, + "PROPFIND", +} + +type Rule struct { + Regex bool + Allow bool + Modify bool + Path string + // TODO: remove Regex and replace by this. It encodes + Regexp *regexp.Regexp `mapstructure:"-"` +} + +func (r *Rule) Validate() error { + if r.Regex { + rp, err := regexp.Compile(r.Path) + if err != nil { + return fmt.Errorf("invalid rule: %w", err) + } + r.Regexp = rp + r.Path = "" + } + + return nil +} + +// Matches checks if [Rule] matches the given path. +func (r *Rule) Matches(path string) bool { + if r.Regex { + return r.Regexp.MatchString(path) + } + + return strings.HasPrefix(path, r.Path) +} + +type Permissions struct { + Scope string + Modify bool + Rules []*Rule +} + +// Allowed checks if the user has permission to access a directory/file +func (p Permissions) Allowed(r *http.Request) bool { + // Determine whether or not it is a read or write request. + readRequest := false + for _, method := range readMethods { + if r.Method == method { + readRequest = true + break + } + } + + // Go through rules beginning from the last one. + for i := len(p.Rules) - 1; i >= 0; i-- { + rule := p.Rules[i] + + if rule.Matches(r.URL.Path) { + return rule.Allow && (readRequest || rule.Modify) + } + } + + return readRequest || p.Modify +} + +func (p *Permissions) Validate() error { + for _, r := range p.Rules { + if err := r.Validate(); err != nil { + return fmt.Errorf("invalid permissions: %w", err) + } + } + + return nil +} diff --git a/lib/response_writer.go b/lib/response_writer.go new file mode 100644 index 0000000..2090871 --- /dev/null +++ b/lib/response_writer.go @@ -0,0 +1,27 @@ +package lib + +import "net/http" + +var _ http.ResponseWriter = responseWriterNoBody{} + +// responseWriterNoBody is a wrapper used to suppress the body of the response +// to a request. Mainly used for HEAD requests. +type responseWriterNoBody struct { + http.ResponseWriter +} + +// newResponseWriterNoBody creates a new responseWriterNoBody. +func newResponseWriterNoBody(w http.ResponseWriter) *responseWriterNoBody { + return &responseWriterNoBody{w} +} + +// Write suppress the body. +func (w responseWriterNoBody) Write(data []byte) (int, error) { + return 0, nil +} + +// WriteHeader writes the header to the http.ResponseWriter. +func (w responseWriterNoBody) WriteHeader(statusCode int) { + w.Header().Del("Content-Length") + w.ResponseWriter.WriteHeader(statusCode) +} diff --git a/lib/user.go b/lib/user.go old mode 100755 new mode 100644 index fc7000a..538c6e1 --- a/lib/user.go +++ b/lib/user.go @@ -1,50 +1,52 @@ package lib import ( - "regexp" + "errors" + "fmt" + "os" "strings" - "golang.org/x/net/webdav" + "golang.org/x/crypto/bcrypt" ) -// Rule is a disallow/allow rule. -type Rule struct { - Regex bool - Allow bool - Modify bool - Path string - Regexp *regexp.Regexp -} - -// User contains the settings of each user. type User struct { - Username string - Password string - Scope string - Modify bool - Rules []*Rule - Handler *webdav.Handler + Permissions `mapstructure:",squash"` + Username string + Password string } -// Allowed checks if the user has permission to access a directory/file -func (u User) Allowed(url string, noModification bool) bool { - var rule *Rule - i := len(u.Rules) - 1 - - for i >= 0 { - rule = u.Rules[i] - - isAllowed := rule.Allow && (noModification || rule.Modify) - if rule.Regex { - if rule.Regexp.MatchString(url) { - return isAllowed - } - } else if strings.HasPrefix(url, rule.Path) { - return isAllowed - } - - i-- +func (u User) checkPassword(input string) bool { + if strings.HasPrefix(u.Password, "{bcrypt}") { + savedPassword := strings.TrimPrefix(u.Password, "{bcrypt}") + return bcrypt.CompareHashAndPassword([]byte(savedPassword), []byte(input)) == nil } - return noModification || u.Modify + return u.Password == input +} + +func (u *User) Validate() error { + if u.Username == "" { + return errors.New("invalid user: username must be set") + } + + if u.Password == "" { + return fmt.Errorf("invalid user %q: password must be set", u.Username) + } else if strings.HasPrefix(u.Password, "{env}") { + + env := strings.TrimPrefix(u.Password, "{env}") + if env == "" { + return fmt.Errorf("invalid user %q: password environment variable not set", u.Username) + } + + u.Password = os.Getenv(env) + if u.Password == "" { + return fmt.Errorf("invalid user %q: password environment variable is empty", u.Username) + } + } + + if err := u.Permissions.Validate(); err != nil { + return fmt.Errorf("invalid user %q: %w", u.Username, err) + } + + return nil } diff --git a/lib/utils.go b/lib/utils.go deleted file mode 100755 index 8d26e31..0000000 --- a/lib/utils.go +++ /dev/null @@ -1,25 +0,0 @@ -package lib - -import ( - "strings" - - "golang.org/x/crypto/bcrypt" -) - -func checkPassword(saved, input string) bool { - if strings.HasPrefix(saved, "{bcrypt}") { - savedPassword := strings.TrimPrefix(saved, "{bcrypt}") - return bcrypt.CompareHashAndPassword([]byte(savedPassword), []byte(input)) == nil - } - - return saved == input -} - -func isAllowedHost(allowedHosts []string, origin string) bool { - for _, host := range allowedHosts { - if host == origin { - return true - } - } - return false -} diff --git a/lib/webdav.go b/lib/webdav.go deleted file mode 100644 index 8545c15..0000000 --- a/lib/webdav.go +++ /dev/null @@ -1,174 +0,0 @@ -package lib - -import ( - "context" - "net/http" - "strings" - - "go.uber.org/zap" -) - -// CorsCfg is the CORS config. -type CorsCfg struct { - Enabled bool - Credentials bool - AllowedHeaders []string - AllowedHosts []string - AllowedMethods []string - ExposedHeaders []string -} - -// Config is the configuration of a WebDAV instance. -type Config struct { - *User - Auth bool - Debug bool - NoSniff bool - Cors CorsCfg - Users map[string]*User - LogFormat string -} - -// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met. -func (c *Config) ServeHTTP(w http.ResponseWriter, r *http.Request) { - u := c.User - requestOrigin := r.Header.Get("Origin") - - // Add CORS headers before any operation so even on a 401 unauthorized status, CORS will work. - if c.Cors.Enabled && requestOrigin != "" { - headers := w.Header() - - allowedHeaders := strings.Join(c.Cors.AllowedHeaders, ", ") - allowedMethods := strings.Join(c.Cors.AllowedMethods, ", ") - exposedHeaders := strings.Join(c.Cors.ExposedHeaders, ", ") - - allowAllHosts := len(c.Cors.AllowedHosts) == 1 && c.Cors.AllowedHosts[0] == "*" - allowedHost := isAllowedHost(c.Cors.AllowedHosts, requestOrigin) - - if allowAllHosts { - headers.Set("Access-Control-Allow-Origin", "*") - } else if allowedHost { - headers.Set("Access-Control-Allow-Origin", requestOrigin) - } - - if allowAllHosts || allowedHost { - headers.Set("Access-Control-Allow-Headers", allowedHeaders) - headers.Set("Access-Control-Allow-Methods", allowedMethods) - - if c.Cors.Credentials { - headers.Set("Access-Control-Allow-Credentials", "true") - } - - if len(c.Cors.ExposedHeaders) > 0 { - headers.Set("Access-Control-Expose-Headers", exposedHeaders) - } - } - } - - if r.Method == "OPTIONS" && c.Cors.Enabled && requestOrigin != "" { - return - } - - // Authentication - if c.Auth { - w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) - - // Gets the correct user for this request. - username, password, ok := r.BasicAuth() - zap.L().Info("login attempt", zap.String("username", username), zap.String("remote_address", r.RemoteAddr)) - if !ok { - http.Error(w, "Not authorized", http.StatusUnauthorized) - return - } - - user, ok := c.Users[username] - if !ok { - http.Error(w, "Not authorized", http.StatusUnauthorized) - return - } - - if !checkPassword(user.Password, password) { - zap.L().Info("invalid password", zap.String("username", username), zap.String("remote_address", r.RemoteAddr)) - http.Error(w, "Not authorized", http.StatusUnauthorized) - return - } - - u = user - zap.L().Info("user authorized", zap.String("username", username)) - } else { - // Even if Auth is disabled, we might want to get - // the user from the Basic Auth header. Useful for Caddy - // plugin implementation. - username, _, ok := r.BasicAuth() - if ok { - if user, ok := c.Users[username]; ok { - u = user - } - } - } - - // Checks for user permissions relatively to this PATH. - noModification := r.Method == "GET" || r.Method == "HEAD" || - r.Method == "OPTIONS" || r.Method == "PROPFIND" - - allowed := u.Allowed(r.URL.Path, noModification) - - zap.L().Debug("allowed & method & path", zap.Bool("allowed", allowed), zap.String("method", r.Method), zap.String("path", r.URL.Path)) - - if !allowed { - w.WriteHeader(http.StatusForbidden) - return - } - - if r.Method == "HEAD" { - w = newResponseWriterNoBody(w) - } - - // Excerpt from RFC4918, section 9.4: - // - // GET, when applied to a collection, may return the contents of an - // "index.html" resource, a human-readable view of the contents of - // the collection, or something else altogether. - // - // Get, when applied to collection, will return the same as PROPFIND method. - if r.Method == "GET" && strings.HasPrefix(r.URL.Path, u.Handler.Prefix) { - info, err := u.Handler.FileSystem.Stat(context.TODO(), strings.TrimPrefix(r.URL.Path, u.Handler.Prefix)) - if err == nil && info.IsDir() { - r.Method = "PROPFIND" - - if r.Header.Get("Depth") == "" { - r.Header.Add("Depth", "1") - } - } - } - - // Runs the WebDAV. - //u.Handler.LockSystem = webdav.NewMemLS() - u.Handler.ServeHTTP(w, r) -} - -// responseWriterNoBody is a wrapper used to suprress the body of the response -// to a request. Mainly used for HEAD requests. -type responseWriterNoBody struct { - http.ResponseWriter -} - -// newResponseWriterNoBody creates a new responseWriterNoBody. -func newResponseWriterNoBody(w http.ResponseWriter) *responseWriterNoBody { - return &responseWriterNoBody{w} -} - -// Header executes the Header method from the http.ResponseWriter. -func (w responseWriterNoBody) Header() http.Header { - return w.ResponseWriter.Header() -} - -// Write suprresses the body. -func (w responseWriterNoBody) Write(data []byte) (int, error) { - return 0, nil -} - -// WriteHeader writes the header to the http.ResponseWriter. -func (w responseWriterNoBody) WriteHeader(statusCode int) { - w.ResponseWriter.WriteHeader(statusCode) -}