mirror of
https://github.com/hacdias/webdav.git
synced 2026-09-22 03:20:41 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aad8e563a7 | ||
|
|
8446f9bdae | ||
|
|
5226853fca | ||
|
|
e350dc989a | ||
|
|
65de8e8578 | ||
|
|
f0b5a39d80 | ||
|
|
dbd708a76e | ||
|
|
f61b7cab3b | ||
|
|
3cfaa0da3e | ||
|
|
f7b78cd834 | ||
|
|
4b4e555ed5 | ||
|
|
6aeb3f8a30 | ||
|
|
59c5f1343f | ||
|
|
617496c019 | ||
|
|
3790d7de08 | ||
|
|
eaf42b03e9 | ||
|
|
dce6010b61 | ||
|
|
88863d7242 | ||
|
|
2c96db92aa | ||
|
|
79bc17afab | ||
|
|
51b101d3d8 | ||
|
|
ca7f3374d5 | ||
|
|
64bbdc7b15 | ||
|
|
d418bd2661 | ||
|
|
d500716f29 | ||
|
|
8c49af0b68 | ||
|
|
a5777e18ee | ||
|
|
49a6e935c3 | ||
|
|
a698e31cb4 | ||
|
|
74b514c877 | ||
|
|
ca0bdb1cfa | ||
|
|
a056e1ba18 | ||
|
|
189af88bc8 |
@@ -15,7 +15,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23.x"
|
||||
go-version: "1.24.x"
|
||||
- run: go build .
|
||||
env:
|
||||
CGO_ENABLED: '0'
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23.x"
|
||||
go-version: "1.24.x"
|
||||
- uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: "v1.60"
|
||||
version: "v1.64"
|
||||
|
||||
@@ -16,6 +16,6 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.23.x"
|
||||
go-version: "1.24.x"
|
||||
- name: Run test with coverage
|
||||
run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ builds:
|
||||
flags:
|
||||
- '-trimpath'
|
||||
ldflags:
|
||||
- '-X github.com/hacdias/webdav/v5/cmd.version={{.Version}}'
|
||||
- '-s -w -X github.com/hacdias/webdav/v5/cmd.version={{.Version}}'
|
||||
goos:
|
||||
- darwin
|
||||
- linux
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.23-alpine3.20 AS build
|
||||
FROM golang:1.24-alpine3.20 AS build
|
||||
|
||||
ARG VERSION="untracked"
|
||||
|
||||
@@ -11,7 +11,7 @@ COPY ./go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . /webdav/
|
||||
RUN go build -o main -ldflags="-X 'github.com/hacdias/webdav/v5/cmd.version=$VERSION'" .
|
||||
RUN go build -o main -trimpath -ldflags="-s -w -X 'github.com/hacdias/webdav/v5/cmd.version=$VERSION'" .
|
||||
|
||||
FROM scratch
|
||||
|
||||
|
||||
@@ -49,6 +49,12 @@ docker run \
|
||||
ghcr.io/hacdias/webdav -c /config.yml
|
||||
```
|
||||
|
||||
If you are using [fail2ban](#fail2ban-setup), it would be helpful to add the parameters listed below. They will assist in analyzing the log.
|
||||
```bash
|
||||
--log-driver journald \
|
||||
--name webdav \
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The configuration can be provided as a YAML, JSON or TOML file. Below is an example of a YAML configuration file with all the options available, as well as what they mean.
|
||||
@@ -71,6 +77,11 @@ debug: false
|
||||
# Disable sniffing the files to detect their content type. Default is 'false'.
|
||||
noSniff: false
|
||||
|
||||
# Whether the server runs behind a trusted proxy or not. When this is true,
|
||||
# the header X-Forwarded-For will be used for logging the remote addresses
|
||||
# of logging attempts (if available).
|
||||
behindProxy: false
|
||||
|
||||
# The directory that will be able to be accessed by the users when connecting.
|
||||
# This directory will be used by users unless they have their own 'directory' defined.
|
||||
# Default is '.' (current directory).
|
||||
@@ -81,9 +92,22 @@ directory: .
|
||||
# permissions. For example, to allow to read and create, set "RC". Default is "R".
|
||||
permissions: R
|
||||
|
||||
# The default permissions rules for users. Default is none.
|
||||
# The default permissions rules for users. Default is none. Rules are applied
|
||||
# from last to first, that is, the first rule that matches the request, starting
|
||||
# from the end, will be applied to the request. Rule paths are always relative to
|
||||
# the user's directory.
|
||||
rules: []
|
||||
|
||||
# The behavior of redefining the rules for users. It can be:
|
||||
# - overwrite: when a user has rules defined, these will overwrite any global
|
||||
# rules already defined. That is, the global rules are not applicable to the
|
||||
# user.
|
||||
# - append: when a user has rules defined, these will be appended to the global
|
||||
# rules already defined. That is, for this user, their own specific rules will
|
||||
# be checked first, and then the global rules.
|
||||
# Default is 'overwrite'.
|
||||
rulesBehavior: overwrite
|
||||
|
||||
# Logging configuration
|
||||
log:
|
||||
# Logging format ('console', 'json'). Default is 'console'.
|
||||
@@ -122,6 +146,8 @@ users:
|
||||
- username: admin
|
||||
password: admin
|
||||
# Example 'john' user with bcrypt encrypted password, with custom directory.
|
||||
# You can generate a bcrypt-encrypted password by using the 'webdav bcrypt'
|
||||
# command lint utility.
|
||||
- username: john
|
||||
password: "{bcrypt}$2y$10$zEP6oofmXFeHaeMfBNLnP.DO8m.H.Mwhd24/TOX2MWLxAExXi4qgi"
|
||||
directory: /another/path
|
||||
@@ -133,14 +159,15 @@ users:
|
||||
# Override default permissions.
|
||||
permissions: CRUD
|
||||
rules:
|
||||
# With this rule, the user CANNOT access /some/files.
|
||||
# With this rule, the user CANNOT access {user directory}/some/files.
|
||||
- path: /some/file
|
||||
permissions: none
|
||||
# With this rule, the user CAN create, read, update and delete within /public/access.
|
||||
# With this rule, the user CAN create, read, update and delete within
|
||||
# {user directory}/public/access.
|
||||
- path: /public/access/
|
||||
permissions: CRUD
|
||||
# With this rule, the user CAN read and update all files ending with .js. It uses
|
||||
# a regular expression.
|
||||
# With this rule, the user CAN read and update all files ending with .js.
|
||||
# It uses a regular expression.
|
||||
- regex: "^.+.js$"
|
||||
permissions: RU
|
||||
```
|
||||
@@ -166,6 +193,14 @@ location / {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $host;
|
||||
proxy_redirect off;
|
||||
|
||||
# Ensure COPY and MOVE commands work. Change https://example.com to the
|
||||
# correct address where the WebDAV server will be deployed at.
|
||||
set $dest $http_destination;
|
||||
if ($http_destination ~ "^https://example.com(?<path>(.+))") {
|
||||
set $dest /$path;
|
||||
}
|
||||
proxy_set_header Destination $dest;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -190,6 +225,64 @@ Restart=on-failure
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### Fail2Ban Setup
|
||||
|
||||
To add security against brute-force attacks in your WebDAV server, you can configure Fail2Ban to ban IP addresses after a set number of failed login attempts.
|
||||
|
||||
#### Filter Configuration
|
||||
|
||||
Create a new filter rule under `filter.d/webdav.conf`:
|
||||
|
||||
```ini
|
||||
[INCLUDES]
|
||||
before = common.conf
|
||||
|
||||
[Definition]
|
||||
# Failregex to match "invalid password" and extract remote_address only
|
||||
failregex = ^.*invalid password\s*\{.*"remote_address":\s*"<HOST>:\d+"\s*\}
|
||||
^.*invalid username\s*\{.*"remote_address":\s*"<HOST>:\d+"\s*\}
|
||||
|
||||
ignoreregex =
|
||||
```
|
||||
|
||||
This configuration will capture invalid login attempts and extract the IP address to ban.
|
||||
|
||||
#### Jail Configuration
|
||||
|
||||
In `jail.d/webdav.conf`, define the jail that monitors your WebDAV log for failed login attempts:
|
||||
|
||||
```ini
|
||||
[webdav]
|
||||
|
||||
enabled = true
|
||||
port = [your_port]
|
||||
filter = webdav
|
||||
logpath = [your_log_path]
|
||||
banaction = iptables-allports
|
||||
ignoreself = false
|
||||
```
|
||||
|
||||
- Replace `[your_port]` with the port your WebDAV server is running on.
|
||||
- Replace `[your_log_path]` with the path to your WebDAV log file.
|
||||
|
||||
If you use it with Docker and `--log-driver journald`, replace `logpath` with `journalmatch = CONTAINER_NAME=[your_container_name]`
|
||||
|
||||
#### Final Steps
|
||||
|
||||
1. Restart Fail2Ban to apply these configurations:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart fail2ban
|
||||
```
|
||||
|
||||
2. Verify that Fail2Ban is running and monitoring your WebDAV logs:
|
||||
|
||||
```bash
|
||||
sudo fail2ban-client status webdav
|
||||
```
|
||||
|
||||
With this setup, Fail2Ban will automatically block IP addresses that exceed the allowed number of failed login attempts.
|
||||
|
||||
## Contributing
|
||||
|
||||
Feel free to open an issue or a pull request.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
flags := bcryptCmd.Flags()
|
||||
flags.IntP("cost", "c", bcrypt.DefaultCost, "cost used to generate password, higher cost leads to slower verification times")
|
||||
|
||||
rootCmd.AddCommand(bcryptCmd)
|
||||
}
|
||||
|
||||
var bcryptCmd = &cobra.Command{
|
||||
Use: "bcrypt",
|
||||
Short: "Generate a bcrypt encrypted password",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cost, err := cmd.Flags().GetInt("cost")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cost < bcrypt.MinCost {
|
||||
return fmt.Errorf("given cost cannot be under minimum cost of %d", bcrypt.MinCost)
|
||||
}
|
||||
|
||||
if cost > bcrypt.MaxCost {
|
||||
return fmt.Errorf("given cost cannot be over maximum cost of %d", bcrypt.MaxCost)
|
||||
}
|
||||
|
||||
pwd := args[0]
|
||||
if pwd == "" {
|
||||
return errors.New("password argument must not be empty")
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(pwd), cost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println(string(hash))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
+16
-1
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/coreos/go-systemd/v22/activation"
|
||||
"github.com/hacdias/webdav/v5/lib"
|
||||
"github.com/spf13/cobra"
|
||||
"go.uber.org/zap"
|
||||
@@ -115,7 +116,21 @@ func getListener(cfg *lib.Config) (net.Listener, error) {
|
||||
network string
|
||||
)
|
||||
|
||||
if strings.HasPrefix(cfg.Address, "unix:") {
|
||||
if strings.HasPrefix(cfg.Address, "sd-listen-fd:") {
|
||||
listeners, err := activation.ListenersWithNames()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
address := cfg.Address[13:]
|
||||
listener, ok := listeners[address]
|
||||
|
||||
if !ok || len(listener) < 1 {
|
||||
return nil, errors.New("unknown sd-listen-fd address '" + address + "'")
|
||||
}
|
||||
|
||||
return listener[0], nil
|
||||
} else if strings.HasPrefix(cfg.Address, "unix:") {
|
||||
address = cfg.Address[5:]
|
||||
network = "unix"
|
||||
} else {
|
||||
|
||||
@@ -1,39 +1,34 @@
|
||||
module github.com/hacdias/webdav/v5
|
||||
|
||||
go 1.23
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/go-viper/mapstructure/v2 v2.1.0
|
||||
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
|
||||
github.com/coreos/go-systemd/v22 v22.5.0
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1
|
||||
github.com/rs/cors v1.11.1
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/spf13/pflag v1.0.6
|
||||
github.com/spf13/viper v1.20.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/studio-b12/gowebdav v0.9.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.26.0
|
||||
golang.org/x/net v0.28.0
|
||||
golang.org/x/crypto v0.38.0
|
||||
golang.org/x/net v0.40.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/fsnotify/fsnotify v1.9.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/pelletier/go-toml/v2 v2.2.4 // 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/sagikazarmark/locafero v0.9.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.7.0 // indirect
|
||||
github.com/spf13/afero v1.14.0 // indirect
|
||||
github.com/spf13/cast v1.8.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect
|
||||
golang.org/x/sys v0.24.0 // indirect
|
||||
golang.org/x/text v0.17.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,63 +1,48 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/go-viper/mapstructure/v2 v2.1.0 h1:gHnMa2Y/pIxElCH2GlZZ1lZSsn6XMtufpGyP1XxdC/w=
|
||||
github.com/go-viper/mapstructure/v2 v2.1.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
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/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
|
||||
github.com/rs/cors v1.11.1/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=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
|
||||
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
|
||||
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
|
||||
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||
github.com/spf13/cast v1.8.0 h1:gEN9K4b8Xws4EX0+a0reLmhq8moKn7ntRlQYgjPeCDk=
|
||||
github.com/spf13/cast v1.8.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/studio-b12/gowebdav v0.9.0 h1:1j1sc9gQnNxbXXM4M/CebPOX4aXYtr7MojAVcN4dHjU=
|
||||
github.com/studio-b12/gowebdav v0.9.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
@@ -68,21 +53,16 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI=
|
||||
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
|
||||
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
|
||||
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
|
||||
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
|
||||
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
||||
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+25
-7
@@ -3,6 +3,7 @@ package lib
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -33,13 +34,14 @@ type Config struct {
|
||||
Prefix string
|
||||
NoSniff bool
|
||||
NoPassword bool
|
||||
BehindProxy bool
|
||||
Log Log
|
||||
CORS CORS
|
||||
Users []User
|
||||
}
|
||||
|
||||
func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
||||
v := viper.New()
|
||||
v := viper.NewWithOptions(viper.ExperimentalBindStruct())
|
||||
|
||||
// Configure flags bindings
|
||||
if flags != nil {
|
||||
@@ -52,6 +54,10 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
||||
// 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)
|
||||
@@ -61,9 +67,6 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
||||
v.SetEnvPrefix("wd")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
// TODO: use new env struct bind feature when it's released in viper.
|
||||
// This should make it redundant to set defaults for things that are
|
||||
// empty or false.
|
||||
|
||||
// Defaults shared with flags
|
||||
v.SetDefault("TLS", DefaultTLS)
|
||||
@@ -74,6 +77,7 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
||||
v.SetDefault("Prefix", DefaultPrefix)
|
||||
|
||||
// Other defaults
|
||||
v.SetDefault("RulesBehavior", RulesOverwrite)
|
||||
v.SetDefault("Directory", ".")
|
||||
v.SetDefault("Permissions", "R")
|
||||
v.SetDefault("Debug", false)
|
||||
@@ -114,7 +118,21 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
||||
cfg.Users[i].Permissions = cfg.Permissions
|
||||
}
|
||||
|
||||
if !v.IsSet(fmt.Sprintf("Users.%d.Rules", i)) {
|
||||
if !v.IsSet(fmt.Sprintf("Users.%d.RulesBehavior", i)) {
|
||||
cfg.Users[i].RulesBehavior = cfg.RulesBehavior
|
||||
}
|
||||
|
||||
if v.IsSet(fmt.Sprintf("Users.%d.Rules", i)) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -160,8 +178,8 @@ func (c *Config) Validate() error {
|
||||
return fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
for _, u := range c.Users {
|
||||
err := u.Validate(c.NoPassword)
|
||||
for i := range c.Users {
|
||||
err := c.Users[i].Validate(c.NoPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
+138
-10
@@ -22,6 +22,17 @@ func writeAndParseConfig(t *testing.T, content, extension string) *Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
func writeAndParseConfigWithError(t *testing.T, content, extension, error string) {
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "config"+extension)
|
||||
|
||||
err := os.WriteFile(tmpFile, []byte(content), 0666)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ParseConfig(tmpFile, nil)
|
||||
require.ErrorContains(t, err, error)
|
||||
}
|
||||
|
||||
func TestConfigDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -181,24 +192,111 @@ cors:
|
||||
}
|
||||
|
||||
func TestConfigRules(t *testing.T) {
|
||||
content := `
|
||||
t.Run("Only Regex or Path", func(t *testing.T) {
|
||||
content := `
|
||||
directory: /
|
||||
rules:
|
||||
- regex: '^.+\.js$'
|
||||
path: /public/access/`
|
||||
|
||||
writeAndParseConfigWithError(t, content, ".yaml", "cannot define both regex and path")
|
||||
})
|
||||
|
||||
t.Run("Regex or Path Required", func(t *testing.T) {
|
||||
content := `
|
||||
directory: /
|
||||
rules:
|
||||
- permissions: CRUD`
|
||||
|
||||
writeAndParseConfigWithError(t, content, ".yaml", "must either define a path of a regex")
|
||||
})
|
||||
|
||||
t.Run("Parse", func(t *testing.T) {
|
||||
content := `
|
||||
directory: /
|
||||
rules:
|
||||
- regex: '^.+\.js$'
|
||||
- path: /public/access/`
|
||||
|
||||
cfg := writeAndParseConfig(t, content, ".yaml")
|
||||
require.NoError(t, cfg.Validate())
|
||||
cfg := writeAndParseConfig(t, content, ".yaml")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
require.Len(t, cfg.Rules, 2)
|
||||
require.Len(t, cfg.Rules, 2)
|
||||
|
||||
require.Empty(t, cfg.Rules[0].Path)
|
||||
require.NotNil(t, cfg.Rules[0].Regex)
|
||||
require.True(t, cfg.Rules[0].Regex.MatchString("/my/path/to/file.js"))
|
||||
require.False(t, cfg.Rules[0].Regex.MatchString("/my/path/to/file.ts"))
|
||||
require.Empty(t, cfg.Rules[0].Path)
|
||||
require.NotNil(t, cfg.Rules[0].Regex)
|
||||
require.True(t, cfg.Rules[0].Regex.MatchString("/my/path/to/file.js"))
|
||||
require.False(t, cfg.Rules[0].Regex.MatchString("/my/path/to/file.ts"))
|
||||
|
||||
require.NotEmpty(t, cfg.Rules[1].Path)
|
||||
require.Nil(t, cfg.Rules[1].Regex)
|
||||
require.NotEmpty(t, cfg.Rules[1].Path)
|
||||
require.Nil(t, cfg.Rules[1].Regex)
|
||||
})
|
||||
|
||||
t.Run("Rules Behavior (Default: Overwrite)", func(t *testing.T) {
|
||||
content := `
|
||||
directory: /
|
||||
rules:
|
||||
- regex: '^.+\.js$'
|
||||
- path: /public/access/
|
||||
|
||||
users:
|
||||
- username: foo
|
||||
password: bar
|
||||
rules:
|
||||
- path: /private/access/`
|
||||
|
||||
cfg := writeAndParseConfig(t, content, ".yaml")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
require.Len(t, cfg.Rules, 2)
|
||||
|
||||
require.Empty(t, cfg.Rules[0].Path)
|
||||
require.NotNil(t, cfg.Rules[0].Regex)
|
||||
require.True(t, cfg.Rules[0].Regex.MatchString("/my/path/to/file.js"))
|
||||
require.False(t, cfg.Rules[0].Regex.MatchString("/my/path/to/file.ts"))
|
||||
|
||||
require.EqualValues(t, "/public/access/", cfg.Rules[1].Path)
|
||||
require.Nil(t, cfg.Rules[1].Regex)
|
||||
|
||||
require.Len(t, cfg.Users, 1)
|
||||
require.Len(t, cfg.Users[0].Rules, 1)
|
||||
require.EqualValues(t, "/private/access/", cfg.Users[0].Rules[0].Path)
|
||||
})
|
||||
|
||||
t.Run("Rules Behavior (Append)", func(t *testing.T) {
|
||||
content := `
|
||||
directory: /
|
||||
rules:
|
||||
- regex: '^.+\.js$'
|
||||
- path: /public/access/
|
||||
rulesBehavior: append
|
||||
|
||||
users:
|
||||
- username: foo
|
||||
password: bar
|
||||
rules:
|
||||
- path: /private/access/`
|
||||
|
||||
cfg := writeAndParseConfig(t, content, ".yaml")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
require.Len(t, cfg.Rules, 2)
|
||||
|
||||
require.Empty(t, cfg.Rules[0].Path)
|
||||
require.NotNil(t, cfg.Rules[0].Regex)
|
||||
require.True(t, cfg.Rules[0].Regex.MatchString("/my/path/to/file.js"))
|
||||
require.False(t, cfg.Rules[0].Regex.MatchString("/my/path/to/file.ts"))
|
||||
|
||||
require.EqualValues(t, "/public/access/", cfg.Rules[1].Path)
|
||||
require.Nil(t, cfg.Rules[1].Regex)
|
||||
|
||||
require.Len(t, cfg.Users, 1)
|
||||
require.Len(t, cfg.Users[0].Rules, 3)
|
||||
|
||||
require.EqualValues(t, cfg.Rules[0], cfg.Users[0].Rules[0])
|
||||
require.EqualValues(t, cfg.Rules[1], cfg.Users[0].Rules[1])
|
||||
require.EqualValues(t, "/private/access/", cfg.Users[0].Rules[2].Path)
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigEnv(t *testing.T) {
|
||||
@@ -224,3 +322,33 @@ func TestConfigEnv(t *testing.T) {
|
||||
require.NoError(t, os.Setenv("WD_PERMISSIONS", ""))
|
||||
require.NoError(t, os.Setenv("WD_DIRECTORY", ""))
|
||||
}
|
||||
|
||||
func TestConfigParseUserPasswordEnvironment(t *testing.T) {
|
||||
content := `
|
||||
directory: /
|
||||
users:
|
||||
- username: '{env}USER1_USERNAME'
|
||||
password: '{env}USER1_PASSWORD'
|
||||
- username: basic
|
||||
password: basic
|
||||
`
|
||||
|
||||
writeAndParseConfigWithError(t, content, ".yml", "username environment variable is empty")
|
||||
|
||||
err := os.Setenv("USER1_USERNAME", "admin")
|
||||
require.NoError(t, err)
|
||||
|
||||
writeAndParseConfigWithError(t, content, ".yml", "password environment variable is empty")
|
||||
|
||||
err = os.Setenv("USER1_PASSWORD", "admin")
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := writeAndParseConfig(t, content, ".yaml")
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
require.Equal(t, "admin", cfg.Users[0].Username)
|
||||
require.Equal(t, "basic", cfg.Users[1].Username)
|
||||
|
||||
require.True(t, cfg.Users[0].checkPassword("admin"))
|
||||
require.True(t, cfg.Users[1].checkPassword("basic"))
|
||||
}
|
||||
|
||||
+50
-25
@@ -2,7 +2,6 @@ package lib
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
@@ -17,14 +16,18 @@ type handlerUser struct {
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
noPassword bool
|
||||
user *handlerUser
|
||||
users map[string]*handlerUser
|
||||
noPassword bool
|
||||
behindProxy bool
|
||||
user *handlerUser
|
||||
users map[string]*handlerUser
|
||||
}
|
||||
|
||||
func NewHandler(c *Config) (http.Handler, error) {
|
||||
ls := webdav.NewMemLS()
|
||||
|
||||
h := &Handler{
|
||||
noPassword: c.NoPassword,
|
||||
noPassword: c.NoPassword,
|
||||
behindProxy: c.BehindProxy,
|
||||
user: &handlerUser{
|
||||
User: User{
|
||||
UserPermissions: c.UserPermissions,
|
||||
@@ -35,7 +38,10 @@ func NewHandler(c *Config) (http.Handler, error) {
|
||||
Dir: webdav.Dir(c.Directory),
|
||||
noSniff: c.NoSniff,
|
||||
},
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
LockSystem: &lockSystem{
|
||||
LockSystem: ls,
|
||||
directory: c.Directory,
|
||||
},
|
||||
},
|
||||
},
|
||||
users: map[string]*handlerUser{},
|
||||
@@ -50,7 +56,10 @@ func NewHandler(c *Config) (http.Handler, error) {
|
||||
Dir: webdav.Dir(u.Directory),
|
||||
noSniff: c.NoSniff,
|
||||
},
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
LockSystem: &lockSystem{
|
||||
LockSystem: ls,
|
||||
directory: u.Directory,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -61,6 +70,7 @@ func NewHandler(c *Config) (http.Handler, error) {
|
||||
AllowedOrigins: c.CORS.AllowedHosts,
|
||||
AllowedMethods: c.CORS.AllowedMethods,
|
||||
AllowedHeaders: c.CORS.AllowedHeaders,
|
||||
ExposedHeaders: c.CORS.ExposedHeaders,
|
||||
OptionsPassthrough: false,
|
||||
}).Handler(h), nil
|
||||
}
|
||||
@@ -84,9 +94,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if len(h.users) > 0 {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
|
||||
|
||||
// Retrieve the real client IP address using the updated helper function
|
||||
remoteAddr := getRealRemoteIP(r, h.behindProxy)
|
||||
|
||||
// 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
|
||||
@@ -94,34 +106,33 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
user, ok = h.users[username]
|
||||
if !ok {
|
||||
// Log invalid username
|
||||
zap.L().Info("invalid username", zap.String("username", username), zap.String("remote_address", remoteAddr))
|
||||
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.noPassword && !user.checkPassword(password) {
|
||||
zap.L().Info("invalid password", zap.String("username", username), zap.String("remote_address", r.RemoteAddr))
|
||||
// Log invalid password
|
||||
zap.L().Info("invalid password", zap.String("username", username), zap.String("remote_address", remoteAddr))
|
||||
http.Error(w, "Not authorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
zap.L().Info("user authorized", zap.String("username", username))
|
||||
// Log successful authorization
|
||||
zap.L().Info("user authorized", zap.String("username", username), zap.String("remote_address", remoteAddr))
|
||||
}
|
||||
|
||||
// Cleanup destination header if it's present by stripping out the prefix
|
||||
// and only keeping the path.
|
||||
if destination := r.Header.Get("Destination"); destination != "" {
|
||||
u, err := url.Parse(destination)
|
||||
if err == nil {
|
||||
destination = strings.TrimPrefix(u.Path, user.Prefix)
|
||||
if !strings.HasPrefix(destination, "/") {
|
||||
destination = "/" + destination
|
||||
}
|
||||
r.Header.Set("Destination", destination)
|
||||
}
|
||||
// Convert the HTTP request into an internal request type
|
||||
req, err := newRequest(r, h.user.Prefix)
|
||||
if err != nil {
|
||||
zap.L().Info("invalid request path or destination", zap.Error(err))
|
||||
http.Error(w, "Invalid request path or destination", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Checks for user permissions relatively to this PATH.
|
||||
allowed := user.Allowed(r, func(filename string) bool {
|
||||
allowed := user.Allowed(req, func(filename string) bool {
|
||||
_, err := user.FileSystem.Stat(r.Context(), filename)
|
||||
return !os.IsNotExist(err)
|
||||
})
|
||||
@@ -143,8 +154,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// "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) {
|
||||
// Similarly, since the definition of HEAD is a GET without a response
|
||||
// message body, the semantics of HEAD are unmodified when applied to
|
||||
// collection resources.
|
||||
//
|
||||
// GET (or HEAD), when applied to collection, will return the same as PROPFIND method.
|
||||
if (r.Method == "GET" || r.Method == "HEAD") && 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"
|
||||
@@ -159,10 +174,20 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
user.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// getRealRemoteIP retrieves the client's actual IP address, considering reverse proxies.
|
||||
func getRealRemoteIP(r *http.Request, behindProxy bool) string {
|
||||
if behindProxy {
|
||||
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
type responseWriterNoBody struct {
|
||||
http.ResponseWriter
|
||||
}
|
||||
|
||||
func (w responseWriterNoBody) Write(data []byte) (int, error) {
|
||||
return 0, nil
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
@@ -292,6 +292,82 @@ users:
|
||||
require.ErrorContains(t, err, "403")
|
||||
}
|
||||
|
||||
func TestServerRulesPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"foo.txt": []byte("foo"),
|
||||
"bar.js": []byte("foo js"),
|
||||
"a/foo.js": []byte("foo js"),
|
||||
"a/foo.txt": []byte("foo txt"),
|
||||
"b/foo.txt": []byte("foo b"),
|
||||
"c/a.txt": []byte("b"),
|
||||
"c/b.txt": []byte("b"),
|
||||
"c/c.txt": []byte("b"),
|
||||
})
|
||||
|
||||
srv := makeTestServer(t, fmt.Sprintf(`
|
||||
directory: %s
|
||||
permissions: CRUD
|
||||
prefix: /prefix
|
||||
|
||||
users:
|
||||
- username: basic
|
||||
password: basic
|
||||
rules:
|
||||
- regex: "^.+.js$"
|
||||
permissions: R
|
||||
- path: "/b/"
|
||||
permissions: R
|
||||
- path: "/a/foo.txt"
|
||||
permissions: none
|
||||
- path: "/c/"
|
||||
permissions: none
|
||||
`, dir))
|
||||
|
||||
client := gowebdav.NewClient(srv.URL, "basic", "basic")
|
||||
|
||||
files, err := client.ReadDir("/prefix")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 5)
|
||||
|
||||
err = client.Write("/prefix/foo.txt", []byte("new"), 0666)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.Write("/prefix/new.txt", []byte("new"), 0666)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.Copy("/prefix/bar.js", "/prefix/b/bar.js", false)
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
err = client.Copy("/prefix/bar.js", "/prefix/bar.jsx", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.Copy("/prefix/b/foo.txt", "/prefix/foo1.txt", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.Rename("/prefix/b/foo.txt", "/prefix/foo2.txt", false)
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
_, err = client.Read("/prefix/a/foo.txt")
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
err = client.Write("/prefix/a/foo.js", []byte("new"), 0666)
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
err = client.Write("/prefix/b/foo.txt", []byte("new"), 0666)
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
_, err = client.ReadDir("/prefix/c")
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
_, err = client.Read("/prefix/c/a.txt")
|
||||
require.ErrorContains(t, err, "403")
|
||||
|
||||
err = client.Write("/prefix/c/b.txt", []byte("new"), 0666)
|
||||
require.ErrorContains(t, err, "403")
|
||||
}
|
||||
|
||||
func TestServerPermissions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/webdav"
|
||||
)
|
||||
|
||||
var _ webdav.LockSystem = &lockSystem{}
|
||||
|
||||
// LockSystem wraps a [webdav.LockSystem] with a root directory, allowing
|
||||
// to reuse the same [webdav.LockSystem] for multiple users with different base
|
||||
// directories, meaning we can correctly lock the files across different users.
|
||||
type lockSystem struct {
|
||||
webdav.LockSystem
|
||||
directory string
|
||||
}
|
||||
|
||||
func (l *lockSystem) Confirm(now time.Time, name0, name1 string, conditions ...webdav.Condition) (release func(), err error) {
|
||||
if name0 != "" {
|
||||
name0 = filepath.Join(l.directory, name0)
|
||||
}
|
||||
|
||||
if name1 != "" {
|
||||
name1 = filepath.Join(l.directory, name1)
|
||||
}
|
||||
|
||||
return l.LockSystem.Confirm(now, name0, name1, conditions...)
|
||||
}
|
||||
|
||||
func (l *lockSystem) Create(now time.Time, details webdav.LockDetails) (token string, err error) {
|
||||
details.Root = filepath.Join(l.directory, details.Root)
|
||||
return l.LockSystem.Create(now, details)
|
||||
}
|
||||
+32
-14
@@ -3,7 +3,6 @@ package lib
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -16,6 +15,10 @@ type Rule struct {
|
||||
}
|
||||
|
||||
func (r *Rule) Validate() error {
|
||||
if r.Regex == nil && r.Path == "" {
|
||||
return errors.New("invalid rule: must either define a path of a regex")
|
||||
}
|
||||
|
||||
if r.Regex != nil && r.Path != "" {
|
||||
return errors.New("invalid rule: cannot define both regex and path")
|
||||
}
|
||||
@@ -32,19 +35,27 @@ func (r *Rule) Matches(path string) bool {
|
||||
return strings.HasPrefix(path, r.Path)
|
||||
}
|
||||
|
||||
type RulesBehavior string
|
||||
|
||||
const (
|
||||
RulesOverwrite RulesBehavior = "overwrite"
|
||||
RulesAppend RulesBehavior = "append"
|
||||
)
|
||||
|
||||
type UserPermissions struct {
|
||||
Directory string
|
||||
Permissions Permissions
|
||||
Rules []*Rule
|
||||
Directory string
|
||||
Permissions Permissions
|
||||
Rules []*Rule
|
||||
RulesBehavior RulesBehavior
|
||||
}
|
||||
|
||||
// Allowed checks if the user has permission to access a directory/file
|
||||
func (p UserPermissions) Allowed(r *http.Request, fileExists func(string) bool) bool {
|
||||
func (p UserPermissions) Allowed(r *request, fileExists func(string) bool) bool {
|
||||
// For COPY and MOVE requests, we first check the permissions for the destination
|
||||
// path. As soon as a rule matches and does not allow the operation at the destination,
|
||||
// we fail immediately. If no rule matches, we check the global permissions.
|
||||
if r.Method == "COPY" || r.Method == "MOVE" {
|
||||
dst := r.Header.Get("Destination")
|
||||
if r.method == "COPY" || r.method == "MOVE" {
|
||||
dst := r.destination
|
||||
|
||||
for i := len(p.Rules) - 1; i >= 0; i-- {
|
||||
if p.Rules[i].Matches(dst) {
|
||||
@@ -65,7 +76,7 @@ func (p UserPermissions) Allowed(r *http.Request, fileExists func(string) bool)
|
||||
// Go through rules beginning from the last one, and check the permissions at
|
||||
// the source. The first matched rule returns.
|
||||
for i := len(p.Rules) - 1; i >= 0; i-- {
|
||||
if p.Rules[i].Matches(r.URL.Path) {
|
||||
if p.Rules[i].Matches(r.path) {
|
||||
return p.Rules[i].Permissions.Allowed(r, fileExists)
|
||||
}
|
||||
}
|
||||
@@ -87,6 +98,13 @@ func (p *UserPermissions) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
switch p.RulesBehavior {
|
||||
case RulesAppend, RulesOverwrite:
|
||||
// Good to go
|
||||
default:
|
||||
return fmt.Errorf("invalid rule behavior: %s", p.RulesBehavior)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -123,8 +141,8 @@ func (p *Permissions) UnmarshalText(data []byte) error {
|
||||
|
||||
// Allowed returns whether this permission set has permissions to execute this
|
||||
// request in the source directory. This applies to all requests with all methods.
|
||||
func (p Permissions) Allowed(r *http.Request, fileExists func(string) bool) bool {
|
||||
switch r.Method {
|
||||
func (p Permissions) Allowed(r *request, fileExists func(string) bool) bool {
|
||||
switch r.method {
|
||||
case "GET", "HEAD", "OPTIONS", "POST", "PROPFIND":
|
||||
// Note: POST backend implementation just returns the same thing as GET.
|
||||
return p.Read
|
||||
@@ -133,7 +151,7 @@ func (p Permissions) Allowed(r *http.Request, fileExists func(string) bool) bool
|
||||
case "PROPPATCH":
|
||||
return p.Update
|
||||
case "PUT":
|
||||
if fileExists(r.URL.Path) {
|
||||
if fileExists(r.path) {
|
||||
return p.Update
|
||||
} else {
|
||||
return p.Create
|
||||
@@ -153,10 +171,10 @@ func (p Permissions) Allowed(r *http.Request, fileExists func(string) bool) bool
|
||||
|
||||
// AllowedDestination returns whether this permissions set has permissions to execute this
|
||||
// request in the destination directory. This only applies for COPY and MOVE requests.
|
||||
func (p Permissions) AllowedDestination(r *http.Request, fileExists func(string) bool) bool {
|
||||
switch r.Method {
|
||||
func (p Permissions) AllowedDestination(r *request, fileExists func(string) bool) bool {
|
||||
switch r.method {
|
||||
case "COPY", "MOVE":
|
||||
if fileExists(r.Header.Get("Destination")) {
|
||||
if fileExists(r.destination) {
|
||||
return p.Update
|
||||
} else {
|
||||
return p.Create
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type request struct {
|
||||
method string
|
||||
path string
|
||||
destination string
|
||||
}
|
||||
|
||||
func newRequest(r *http.Request, prefix string) (*request, error) {
|
||||
ctx := &request{
|
||||
method: r.Method,
|
||||
}
|
||||
|
||||
if destination := r.Header.Get("Destination"); destination != "" {
|
||||
u, err := url.Parse(destination)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid destination header")
|
||||
}
|
||||
|
||||
if prefix != "" {
|
||||
destination = strings.TrimPrefix(u.Path, prefix)
|
||||
if len(destination) >= len(u.Path) {
|
||||
return nil, errors.New("invalid url prefix")
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(destination, "/") {
|
||||
destination = "/" + destination
|
||||
}
|
||||
|
||||
ctx.destination = destination
|
||||
}
|
||||
|
||||
path := r.URL.Path
|
||||
|
||||
if prefix != "" {
|
||||
path = strings.TrimPrefix(r.URL.Path, prefix)
|
||||
if len(path) >= len(r.URL.Path) {
|
||||
return nil, errors.New("invalid url prefix")
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
|
||||
ctx.path = path
|
||||
|
||||
return ctx, nil
|
||||
}
|
||||
+10
-1
@@ -27,12 +27,21 @@ func (u User) checkPassword(input string) bool {
|
||||
func (u *User) Validate(noPassword bool) error {
|
||||
if u.Username == "" {
|
||||
return errors.New("invalid user: username must be set")
|
||||
} else if strings.HasPrefix(u.Username, "{env}") {
|
||||
env := strings.TrimPrefix(u.Username, "{env}")
|
||||
if env == "" {
|
||||
return fmt.Errorf("invalid user %q: username environment variable not set", u.Username)
|
||||
}
|
||||
|
||||
u.Username = os.Getenv(env)
|
||||
if u.Username == "" {
|
||||
return fmt.Errorf("invalid user %q: username environment variable is empty", u.Username)
|
||||
}
|
||||
}
|
||||
|
||||
if u.Password == "" && !noPassword {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user