refactor: code cleanup, stricter config validation (#155)

This commit is contained in:
Henrique Dias
2024-07-21 20:52:50 +02:00
committed by GitHub
parent 46d54e4465
commit c125bedae1
19 changed files with 683 additions and 654 deletions
+2
View File
@@ -1,4 +1,5 @@
name: Build name: Build
on: on:
push: push:
tags: tags:
@@ -6,6 +7,7 @@ on:
branches: branches:
- main - main
pull_request: pull_request:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+2
View File
@@ -1,4 +1,5 @@
name: Lint name: Lint
on: on:
push: push:
tags: tags:
@@ -6,6 +7,7 @@ on:
branches: branches:
- main - main
pull_request: pull_request:
jobs: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+21
View File
@@ -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 ./...
+19 -15
View File
@@ -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 # 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) [![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) [![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 ## Install
@@ -13,7 +12,7 @@ Please refer to the [Releases page](https://github.com/hacdias/webdav/releases)
## Usage ## 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 ```yaml
# Server related settings # Server related settings
@@ -55,7 +54,7 @@ users:
password: "{env}ENV_PASSWORD" password: "{env}ENV_PASSWORD"
- username: basic - username: basic
password: basic password: basic
modify: false modify: false
rules: rules:
- regex: false - regex: false
allow: false allow: false
@@ -79,17 +78,22 @@ The `allowed_*` properties are optional, the default value for each of them will
### Reverse Proxy Service ### Reverse Proxy Service
When you use a reverse proxy implementation like `Nginx` or `Apache`, please note the following fields to avoid causing `502` errors When you use a reverse proxy implementation like `Nginx` or `Apache`, please note the following fields to avoid causing `502` errors
```text
```nginx
location / { location / {
proxy_pass http://127.0.0.1:8080; proxy_pass http://127.0.0.1:8080;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header REMOTE-HOST $remote_addr; proxy_set_header REMOTE-HOST $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
proxy_redirect off; proxy_redirect off;
} }
``` ```
## Contributing
Feel free to open an issue or a pull request.
## License ## License
MIT © [Henrique Dias](https://hacdias.com) [MIT License](LICENSE) © [Henrique Dias](https://hacdias.com)
-224
View File
@@ -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
}
+84 -82
View File
@@ -1,7 +1,8 @@
package cmd package cmd
import ( import (
"log" "errors"
"fmt"
"net" "net"
"net/http" "net/http"
"os" "os"
@@ -9,27 +10,21 @@ import (
"strings" "strings"
"syscall" "syscall"
"github.com/hacdias/webdav/v4/lib"
"github.com/spf13/cobra" "github.com/spf13/cobra"
v "github.com/spf13/viper"
"go.uber.org/zap" "go.uber.org/zap"
"go.uber.org/zap/zapcore" "go.uber.org/zap/zapcore"
) )
var (
cfgFile string
)
func init() { func init() {
cobra.OnInitialize(initConfig)
flags := rootCmd.Flags() flags := rootCmd.Flags()
flags.StringVarP(&cfgFile, "config", "c", "", "config file path") flags.StringP("config", "c", "", "config file path")
flags.BoolP("tls", "t", false, "enable tls") flags.BoolP("tls", "t", false, "enable TLS")
flags.Bool("auth", true, "enable auth") flags.Bool("auth", false, "enable authentication")
flags.String("cert", "cert.pem", "TLS certificate") flags.String("cert", "cert.pem", "path to TLS certificate")
flags.String("key", "key.pem", "TLS key") flags.String("key", "key.pem", "path to TLS key")
flags.StringP("address", "a", "0.0.0.0", "address to listen to") flags.StringP("address", "a", "0.0.0.0", "address to listen on")
flags.StringP("port", "p", "0", "port to listen to") flags.StringP("port", "p", "0", "port to listen on")
flags.StringP("prefix", "P", "/", "URL path prefix") flags.StringP("prefix", "P", "/", "URL path prefix")
flags.String("log_format", "console", "logging format") 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 The environment variables are prefixed by "WD_" followed by the option
name in caps. So to set "cert" via an env variable, you should name in caps. So to set "cert" via an env variable, you should
set WD_CERT.`, set WD_CERT.`,
Run: func(cmd *cobra.Command, args []string) { RunE: func(cmd *cobra.Command, args []string) error {
flags := cmd.Flags() flags := cmd.Flags()
cfg := readConfig(flags) cfgFilename, _ := flags.GetString("config")
// 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)
cfg, err := lib.ParseConfig(cfgFilename, flags)
if err != nil { if err != nil {
log.Fatal(err) return err
} }
loggerConfig := zap.NewProductionConfig()
loggerConfig.DisableCaller = true // Create HTTP handler from the config
if cfg.Debug { handler, err := lib.NewHandler(cfg)
loggerConfig.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
}
loggerConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
loggerConfig.Encoding = cfg.LogFormat
logger, err := loggerConfig.Build()
if err != nil { if err != nil {
// if we fail to configure proper logging, then the user has deliberately return err
// misconfigured the logger. Abort.
panic(err)
} }
zap.ReplaceGlobals(logger)
// Setup the logger based on the configuration
err = setupLogger(cfg)
if err != nil {
return err
}
defer func() { defer func() {
// Flush the logger at the end
_ = zap.L().Sync() _ = 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. // Build listener
if getOptB(flags, "tls") { listener, err := getListener(cfg)
if err := http.ServeTLS(listener, cfg, getOpt(flags, "cert"), getOpt(flags, "key")); err != nil { if err != nil {
zap.L().Fatal("shutting server", zap.Error(err)) return err
}
} else {
if err := http.Serve(listener, cfg); err != nil {
zap.L().Fatal("shutting server", zap.Error(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() { func getListener(cfg *lib.Config) (net.Listener, error) {
if cfgFile == "" { var (
v.AddConfigPath(".") address string
v.AddConfigPath("/etc/webdav/") network string
v.SetConfigName("config") )
if strings.HasPrefix(cfg.Address, "unix:") {
address = cfg.Address[5:]
network = "unix"
} else { } else {
v.SetConfigFile(cfgFile) address = fmt.Sprintf("%s:%d", cfg.Address, cfg.Port)
network = "tcp"
} }
v.SetEnvPrefix("WD") return net.Listen(network, address)
v.AutomaticEnv() }
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
if err := v.ReadInConfig(); err != nil { func setupLogger(cfg *lib.Config) error {
if _, ok := err.(v.ConfigParseError); ok { loggerConfig := zap.NewProductionConfig()
panic(err) loggerConfig.DisableCaller = true
} if cfg.Debug {
cfgFile = "No config file used" loggerConfig.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
} else {
cfgFile = "Using config file: " + v.ConfigFileUsed()
} }
loggerConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
loggerConfig.Encoding = cfg.LogFormat
logger, err := loggerConfig.Build()
if err != nil {
return err
}
zap.ReplaceGlobals(logger)
return nil
} }
+1 -1
View File
@@ -13,7 +13,7 @@ func init() {
Use: "version", Use: "version",
Short: "Print the version number", Short: "Print the version number",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("WebDAV version: %q", version) fmt.Printf("WebDAV version: %s\n", version)
}, },
}) })
} }
-55
View File
@@ -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)
}
}
+4
View File
@@ -3,21 +3,25 @@ module github.com/hacdias/webdav/v4
go 1.22 go 1.22
require ( require (
github.com/rs/cors v1.11.0
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5 github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.19.0 github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.9.0
go.uber.org/zap v1.27.0 go.uber.org/zap v1.27.0
golang.org/x/crypto v0.25.0 golang.org/x/crypto v0.25.0
golang.org/x/net v0.27.0 golang.org/x/net v0.27.0
) )
require ( require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // 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/locafero v0.6.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect
+2
View File
@@ -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/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 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 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/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 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk=
github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0=
+153
View File
@@ -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
}
+71
View File
@@ -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)
}
+40 -41
View File
@@ -9,12 +9,44 @@ import (
"golang.org/x/net/webdav" "golang.org/x/net/webdav"
) )
// NoSniffFileInfo wraps any generic FileInfo interface and bypasses mime type sniffing. type Dir struct {
type NoSniffFileInfo 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 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 != "" { if mimeType := mime.TypeByExtension(path.Ext(w.FileInfo.Name())); mimeType != "" {
// We can figure out the mime from the extension. // We can figure out the mime from the extension.
return mimeType, nil return mimeType, nil
@@ -24,60 +56,27 @@ func (w NoSniffFileInfo) ContentType(ctx context.Context) (contentType string, e
} }
} }
type WebDavDir struct { type noSniffFile 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 {
webdav.File webdav.File
} }
func (f WebDavFile) Stat() (os.FileInfo, error) { func (f noSniffFile) Stat() (os.FileInfo, error) {
info, err := f.File.Stat() info, err := f.File.Stat()
if err != nil { if err != nil {
return nil, err 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) fis, err = f.File.Readdir(count)
if err != nil { if err != nil {
return nil, err return nil, err
} }
for i := range fis { for i := range fis {
fis[i] = NoSniffFileInfo{fis[i]} fis[i] = noSniffFileInfo{fis[i]}
} }
return fis, nil return fis, nil
} }
+133
View File
@@ -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)
}
+85
View File
@@ -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
}
+27
View File
@@ -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)
}
Executable → Regular
+39 -37
View File
@@ -1,50 +1,52 @@
package lib package lib
import ( import (
"regexp" "errors"
"fmt"
"os"
"strings" "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 { type User struct {
Username string Permissions `mapstructure:",squash"`
Password string Username string
Scope string Password string
Modify bool
Rules []*Rule
Handler *webdav.Handler
} }
// Allowed checks if the user has permission to access a directory/file func (u User) checkPassword(input string) bool {
func (u User) Allowed(url string, noModification bool) bool { if strings.HasPrefix(u.Password, "{bcrypt}") {
var rule *Rule savedPassword := strings.TrimPrefix(u.Password, "{bcrypt}")
i := len(u.Rules) - 1 return bcrypt.CompareHashAndPassword([]byte(savedPassword), []byte(input)) == nil
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--
} }
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
} }
-25
View File
@@ -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
}
-174
View File
@@ -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)
}