Compare commits

..

98 commits
v1.3.0 ... main

Author SHA1 Message Date
Henrique Dias
eaf42b03e9
feat: add bcrypt command 2024-12-22 10:28:49 +01:00
Henrique Dias
dce6010b61
chore: update dependencies 2024-12-21 08:04:50 +01:00
Henrique Dias
88863d7242
ci: update golangci-lint 2024-12-15 18:30:50 +01:00
Henrique Dias
2c96db92aa
chore: update dependencies 2024-12-15 18:30:28 +01:00
networkException
79bc17afab feat: add support for systemd listener activation sockets
To support starting a network service on demand and to
support a "least privilege-approach" with regards to the
permission a network service process needs to have, systemd
supports opening a network socket on behalf of the service
and passing it as an open file descriptor.

The service gets notified about open file descriptors for
this purpose as well as metadata such as named listeners
via environment variables.

This patch adds support for prefixing the listen address
passed with --address with "sd-listen-fd:" to access these
file descriptors, taking either a listener name passed using
the `LISTEN_FDNAMES` environment variable or `LISTEN_FD_$n`
for unnamed file descriptiors where `n` is the id of the
descriptor starting at 3 (LISTEN_FD_3).

See sd_listen_fds(3)
2024-12-11 17:29:03 +01:00
Henrique Dias
51b101d3d8 feat: rules behavior 2024-12-07 12:07:12 +01:00
Henrique Dias
ca7f3374d5 chore: update dependencies 2024-11-28 16:59:04 +01:00
Henrique Dias
64bbdc7b15 fix: error if rule has no regex or path 2024-11-28 16:57:10 +01:00
Steven Vandevelde
d418bd2661 fix: Pass ExposedHeaders to cors.New 2024-11-19 17:52:14 +01:00
Henrique Dias
d500716f29 fix: spoofing of X-Forwarded-For 2024-10-21 08:15:19 +02:00
Henrique Dias
8c49af0b68 fix: environment parsing for username 2024-10-20 09:09:05 +02:00
Henrique Dias
a5777e18ee
chore: update dependencies 2024-10-12 14:41:05 +02:00
Henrique Dias
49a6e935c3
docs(readme): make fail2ban config part of the examples 2024-10-12 14:40:29 +02:00
炯轩
a698e31cb4 chore: removed the the login attempt log commented line to makes the code cleaner and more focused 2024-10-12 14:38:42 +02:00
Jiongxuan Zhang
74b514c877 feat(authentication): enhance login failure logging and reduce log volume
- Added logging for invalid username attempts to provide more detailed failure reasons.
- Removed "login attempt" log entries to reduce log volume and focus on final verification results.
- Retained logging for invalid password and successful user authorization for clarity.
2024-10-12 14:38:42 +02:00
Jiongxuan Zhang
ca0bdb1cfa docs: add Fail2Ban configuration guide to README
- Added a section in README.md explaining how to configure Fail2Ban for WebDAV security.
- Included examples for filter and jail configuration.
- Provided instructions on setting up and testing Fail2Ban to block IPs after failed login attempts.
2024-10-12 14:38:42 +02:00
Jiongxuan Zhang
a056e1ba18 feat(authentication): improve IP logging by extracting real client IP from X-Forwarded-For header
- Added getRealRemoteIP function to retrieve the real client IP address when behind a reverse proxy.
- Updated authentication logging to use the extracted IP instead of r.RemoteAddr.
- Ensured compatibility for both proxy and non-proxy setups, falling back to r.RemoteAddr when X-Forwarded-For is not present.
2024-10-12 14:38:42 +02:00
Keith Gaughan
189af88bc8
chore: omit debug information release builds (#185) 2024-09-08 21:34:26 +02:00
Henrique Dias
4e87e6a613
chore: disable CGO (#184) 2024-08-25 15:06:46 +02:00
Henrique Dias
63449f1636
fix: check permissions at copy/move source and destination (#181) 2024-08-21 18:15:32 +02:00
Henrique Dias
4ad26dad35 ci: use go 1.23 2024-08-19 19:32:46 +02:00
Henrique Dias
623bbc9a70 chore: update dependencies 2024-08-19 19:32:46 +02:00
Henrique Dias
feeb33d249 docs: add note about noSniff 2024-08-01 21:53:58 +02:00
Henrique Dias
d3bee98000 feat: allow disabling password check for delegated authentication 2024-08-01 21:53:58 +02:00
Henrique Dias
373b2ec931
docs: fix nginx configuration
Closes #132
2024-08-01 10:39:21 +02:00
Henrique Dias
000f404f7a
docs: remove dev version note 2024-07-31 10:07:23 +01:00
Henrique Dias
e4a8622c1e
docs: remove outdated SECURITY.md 2024-07-31 10:07:01 +01:00
Henrique Dias
b5a3d07f5c feat!: fine-grained permissions 2024-07-31 11:06:34 +02:00
Henrique Dias
f4de82cfd1 feat: add test for server listing characters 2024-07-30 15:01:17 +02:00
Henrique Dias
ebcf500d5e
docs: cleanup readme 2024-07-29 09:13:46 +01:00
Henrique Dias
d7faa1f887 feat!: further log customizations 2024-07-29 10:11:02 +02:00
Henrique Dias
d5e5052f63 feat!: rename 'scope' to 'directory'
Directory makes it more clear of what it is. In addition, this will make it easier when
allowing for multiple directories in the future, since we can just name it 'directories', which is more clear than 'scopes'.
2024-07-29 10:11:02 +02:00
Henrique Dias
a255fb51e2 feat!: remove Auth option 2024-07-29 10:11:02 +02:00
Henrique Dias
ed23ca1820 feat!: change default port and scope
BREAKING CHANGE: the default port is no longer random, but 6065. The default scope is now the current directory instead of the root directory.
2024-07-29 10:11:02 +02:00
Henrique Dias
e7e9c3176d feat!: simplified rule with regex instead of boolean
BREAKING CHANGE: the "regex" field in the rule is now a regular expression instead of a boolean.
2024-07-29 10:11:02 +02:00
Henrique Dias
d3732322bc chore: bump version to v5 2024-07-29 10:10:19 +02:00
Henrique Dias
f708664906
feat: permissions, auth, rules basic tests 2024-07-26 17:18:46 +02:00
Henrique Dias
814462bed1
fix: environment variable parsing
This is more of a workaround than the correct solution. It only fixes top-level ENV variables parsing.
2024-07-25 22:46:34 +02:00
Henrique Dias
f6a0707fe6 refactor: shorten response writer code 2024-07-22 22:28:56 +02:00
Henrique Dias
947b163ea7
fix: rules parsing 2024-07-22 22:25:50 +02:00
Henrique Dias
732cf5eff5
docs: fix readme highlighting 2024-07-22 19:22:25 +02:00
Henrique Dias
1e87b21bb1 docs: improve configuration section 2024-07-22 18:55:04 +02:00
Henrique Dias
6166061f20 docs: install, docker, systemd instructions 2024-07-22 18:55:04 +02:00
Henrique Dias
4f8eab48ab
fix: config parsing keys 2024-07-22 18:36:58 +02:00
Henrique Dias
7542860a47
fix: panic when getting requests 2024-07-22 18:32:57 +02:00
Henrique Dias
3688420246 feat: centrally defined defaults 2024-07-22 17:52:56 +02:00
Henrique Dias
47e3f6de6f
fix: remove 'v' from version name 2024-07-21 21:43:53 +02:00
Henrique Dias
356edb8b93
feat: add tests for json and toml config 2024-07-21 21:41:16 +02:00
Henrique Dias
b16c041d0c
fix: add 'v' prefix to version 2024-07-21 21:31:52 +02:00
Henrique Dias
dc45f32af8
fix: dockerfile build version 2024-07-21 21:25:49 +02:00
Henrique Dias
d1691e1bd1
fix: docker workflow tags 2024-07-21 21:03:23 +02:00
Henrique Dias
5f685dbe98
ci: new goreleaser workflow (#157) 2024-07-21 21:01:03 +02:00
Henrique Dias
85219df921
ci: use cache for docker builds 2024-07-21 20:53:06 +02:00
Henrique Dias
c125bedae1
refactor: code cleanup, stricter config validation (#155) 2024-07-21 20:52:50 +02:00
Henrique Dias
46d54e4465
ci: fix docker image attestation 2024-07-21 20:45:14 +02:00
Henrique Dias
90c031846d
ci: new docker release workflows 2024-07-21 20:32:59 +02:00
Henrique Dias
968f2e147a chore: update dependencies 2024-07-21 19:47:04 +02:00
Henrique Dias
6f4be12e8c
ci: updated build and lint workflows (#152) 2024-07-21 19:43:30 +02:00
zmaplex
9433dbd452
fix: correctly close the listener 2024-07-18 21:24:08 +02:00
Henrique Dias
f0ca85e570
docs: add disclaimer 2022-09-26 11:58:28 +02:00
Ethan Davis
436a3b05a1
fix: 'modify:false' is respected 2022-01-18 15:17:20 +01:00
Rui Sun
1a610b17ba
feat: add armv7 support 2022-01-18 15:17:01 +01:00
Mohammed Al Sahaf
32613f76cc ci: update Go version to go1.17 2021-10-19 20:52:48 +03:00
Mohammed Al Sahaf
8a8650d9b2
feat: introduce structured, field logging (#87)
* feat: introduce structured, field logging

The logging is currently minimal and only for initial message of listening and user login attempts

* chore: major version upgarde

there was a breaking API change in 8cd6d0a585 for which the git tag was v4.0.0 but the Go SIV was missed. This commit updates the SIV and retracts the defective version of v4.1.0.
2021-10-19 17:45:54 +00:00
Ziding Zhang
5701cbb5b8
Create SECURITY.md (#81)
* Create SECURITY.md

simple instruction for security researchers

* Update SECURITY.md

Co-authored-by: Mohammed Al Sahaf <msaa1990@gmail.com>
2021-07-27 12:01:24 +03:00
SXueckShen
9a4b378b32
feat: add source request log (#71)
Co-authored-by: shenyuning <shenyn@hua-cloud.com.cn>
Co-authored-by: sxueck <shenyuning@ctirobot.com>
2021-07-01 15:15:33 +02:00
Justyn
099479a894
feat: add a config option to bypass mime content type sniffing (#60) 2021-01-14 14:47:33 +00:00
mik2k2
8cd6d0a585
feat: per path modify permissions (#57) 2021-01-14 14:43:52 +00:00
mik2k2
fca4e54839
feat: add support for unix sockets (#58) 2020-12-17 22:14:03 +00:00
Mohammed Al Sahaf
da6dd253d5 chore: use fetch-depth to fetch complete history
Fixes the issue of publishing all commit since inception (see: goreleaser/goreleaser#990)
2020-10-10 03:28:00 +03:00
Mohammed Al Sahaf
c1a919681b chore: publish releases immediately (not draft) 2020-10-10 03:09:23 +03:00
Mohammed Al Sahaf
5734366c54 Dockerfile: revert 05e995b
The commit 05e995b doesn't work well with goreleaser (see: goreleaser/goreleaser#694), which broke the Docker image build. Reverting the commit should restore sanity in the goreleaser flow.
2020-09-19 01:11:48 +03:00
Mohammed Al Sahaf
aab63c0ccc chore: housekeeping 2020-09-19 01:11:23 +03:00
Mohammed Al Sahaf
f212531d75
chore: Switch to Github Actions (#53) 2020-09-18 21:27:22 +01:00
Henrique Dias
2a39779cff
Update config.yml 2020-09-18 07:06:16 +01:00
Mohammed Al Sahaf
34f2336d91
chore: fix circleci release config (#52) 2020-09-18 07:02:51 +01:00
Henrique Dias
6c5420aa5a chore: install go 2020-08-26 08:44:14 +02:00
Henrique Dias
30c210d097 chore: use specific relese image 2020-08-26 08:09:44 +02:00
Henrique Dias
f408f7aa6f chore: remove setup_remote_docker 2020-08-26 08:02:41 +02:00
Henrique Dias
e9cd29578e use machine build on release 2020-08-26 07:58:11 +02:00
Henrique Dias
e0020e8110
chore: update go version and golangci
License: MIT
Signed-off-by: Henrique Dias <hacdias@gmail.com>
2020-08-25 08:24:31 +02:00
pataquets
2078312ec8 Readme.md: Add Docker pulls badge. 2020-08-03 07:53:31 +01:00
pataquets
05e995b11b Dockerfile: make image build from local source code instead of copying outside build. 2020-08-02 07:55:52 +01:00
Shun Zi
c4d4734095
fix: GET directory fails when non-root prefix configured (#40) 2020-06-27 08:45:33 +01:00
Mohammed Al Sahaf
cf267c1006
chore: build the binary statically to fix the docker image (#44) 2020-06-27 08:44:29 +01:00
Henrique Dias
72d8e39927
fix(BREAKING): rename webdav/ --> lib/ (#36)
License: MIT
Signed-off-by: Henrique Dias <hacdias@gmail.com>
2019-11-10 11:30:45 +00:00
Henrique Dias
3ef86e8a7f
chore: update dependencies (#35)
License: MIT
Signed-off-by: Henrique Dias <hacdias@gmail.com>
2019-11-10 11:24:55 +00:00
Shun Zi
2971f7ed3d feat: add URL prefix(subpath) support (#31)
To adapt to situations such as working behind a reverse proxy dispatching query to different services by URL path prefix
2019-11-08 13:01:42 +00:00
Henrique Dias
ab0334f036 chore: add mips(64)(le)
License: MIT
Signed-off-by: Henrique Dias <hacdias@gmail.com>
2019-07-14 09:57:31 +01:00
Henrique Dias
931f125224 chore: go mod tidy
License: MIT
Signed-off-by: Henrique Dias <hacdias@gmail.com>
2019-06-11 13:12:22 +01:00
Henrique Dias
cd472b26be chore: bump to v2.0.0
License: MIT
Signed-off-by: Henrique Dias <hacdias@gmail.com>
2019-06-11 13:09:51 +01:00
Steven Vandevelde
7358553e69 feat(BREAKING): extend cors functionality (#25) 2019-06-11 13:02:10 +01:00
Henrique Dias
764a69cd33
fix: numeric passwords (#24)
License: MIT
Signed-off-by: Henrique Dias <hacdias@gmail.com>
2019-06-09 13:51:53 +01:00
Henrique Dias
d266f1150e fix: auth enabled by default
License: MIT
Signed-off-by: Henrique Dias <hacdias@gmail.com>
2019-06-09 13:47:28 +01:00
Henrique Dias
76ebaffaef docs: add cors config
License: MIT
Signed-off-by: Henrique Dias <hacdias@gmail.com>
2019-05-24 14:49:01 +01:00
Henrique Dias
60f2697615 fix: pass through linters
License: MIT
Signed-off-by: Henrique Dias <hacdias@gmail.com>
2019-05-24 14:47:05 +01:00
oskar
e5b3946388 feat: add support for custom CORS headers 2019-05-24 14:43:22 +01:00
Henrique Dias
8c66f0c585 feat: check basic auth user anyways
License: MIT
Signed-off-by: Henrique Dias <hacdias@gmail.com>
2019-05-12 20:25:36 +01:00
30 changed files with 2169 additions and 618 deletions

View file

@ -1,45 +0,0 @@
version: 2
jobs:
lint:
docker:
- image: golangci/golangci-lint:v1.16
steps:
- checkout
- run: golangci-lint run -v
build:
docker:
- image: circleci/golang:1.12
steps:
- checkout
- run: go build main.go
release:
docker:
- image: circleci/golang:1.12
steps:
- checkout
- setup_remote_docker
- run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
- run: curl -sL https://git.io/goreleaser | bash
- run: docker logout
workflows:
version: 2
build-workflow:
jobs:
- lint:
filters:
tags:
only: /.*/
- build:
filters:
tags:
only: /.*/
- release:
context: deploy
requires:
- build
- lint
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/

21
.github/workflows/build.yml vendored Normal file
View file

@ -0,0 +1,21 @@
name: Build
on:
push:
tags:
- v*
branches:
- main
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.23.x"
- run: go build .
env:
CGO_ENABLED: '0'

70
.github/workflows/docker.yml vendored Normal file
View file

@ -0,0 +1,70 @@
name: Docker
on:
workflow_dispatch:
push:
branches:
- 'main'
tags:
- 'v*'
jobs:
docker:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
attestations: write
id-token: write
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: |
hacdias/webdav
ghcr.io/hacdias/webdav
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}
type=semver,pattern=v{{major}}.{{minor}}
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
sbom: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ steps.meta.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max

21
.github/workflows/lint.yml vendored Normal file
View file

@ -0,0 +1,21 @@
name: Lint
on:
push:
tags:
- v*
branches:
- main
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.23.x"
- uses: golangci/golangci-lint-action@v6
with:
version: "v1.62"

25
.github/workflows/releaser.yml vendored Normal file
View file

@ -0,0 +1,25 @@
name: Releaser
on:
push:
tags:
- '*'
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
- uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

21
.github/workflows/test.yml vendored Normal file
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.23.x"
- name: Run test with coverage
run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
dist/
webdav

View file

@ -1,54 +1,57 @@
env:
- GO111MODULE=on
version: 2
before:
hooks:
- go mod tidy
- go mod download
build:
main: main.go
binary: webdav
ldflags:
- -s -w -X github.com/hacdias/webdav/cmd.version={{.Version}}
goos:
- darwin
- linux
- windows
- freebsd
- netbsd
- openbsd
goarch:
- amd64
- 386
- arm
- arm64
goarm:
- 5
- 6
- 7
ignore:
- goos: openbsd
goarch: arm
goarm: 6
- goos: freebsd
goarch: arm
goarm: 6
builds:
- main: main.go
binary: webdav
env:
- CGO_ENABLED=0
flags:
- '-trimpath'
ldflags:
- '-s -w -X github.com/hacdias/webdav/v5/cmd.version={{.Version}}'
goos:
- darwin
- linux
- windows
- freebsd
- netbsd
- openbsd
goarch:
- amd64
- '386'
- arm
- arm64
- mips
- mipsle
- mips64
- mips64le
goarm:
- '5'
- '6'
- '7'
ignore:
- goos: openbsd
goarch: arm
goarm: 6
- goos: freebsd
goarch: arm
goarm: 6
archives:
-
name_template: "{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{ .ProjectName }}"
- name_template: "{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{ .ProjectName }}"
format: tar.gz
format_overrides:
- goos: windows
format: zip
dockers:
-
goos: linux
goarch: amd64
goarm: ''
image_templates:
- "hacdias/webdav:latest"
- "hacdias/webdav:{{ .Tag }}"
- "hacdias/webdav:v{{ .Major }}.{{ .Minor }}"
- "hacdias/webdav:v{{ .Major }}"
release:
github:
owner: hacdias
name: webdav
draft: false
prerelease: auto

View file

@ -1,11 +1,23 @@
FROM alpine:latest as certs
FROM golang:1.23-alpine3.20 AS build
ARG VERSION="untracked"
RUN apk --update add ca-certificates
WORKDIR /webdav/
COPY ./go.mod ./
COPY ./go.sum ./
RUN go mod download
COPY . /webdav/
RUN go build -o main -trimpath -ldflags="-s -w -X 'github.com/hacdias/webdav/v5/cmd.version=$VERSION'" .
FROM scratch
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
EXPOSE 80
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /webdav/main /bin/webdav
COPY webdav /webdav
EXPOSE 6065
ENTRYPOINT [ "/webdav" ]
ENTRYPOINT [ "webdav" ]

258
README.md
View file

@ -1,54 +1,276 @@
# webdav
[![Build](https://img.shields.io/circleci/project/github/hacdias/webdav/master.svg?style=flat-square)](https://circleci.com/gh/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)
[![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
Please refer to the [Releases page](https://github.com/hacdias/webdav/releases) for more information. There, you can either download the binaries or find the Docker commands to install WebDAV.
For a manual install, please refer to the [releases](https://github.com/hacdias/webdav/releases) page and download the correct binary for your system. Alternatively, you can build or install it from source using the Go toolchain. You can either clone the repository and execute `go build`, or directly install it, using:
```
go install github.com/hacdias/webdav/v5@latest
```
### Docker
Docker images are provided on both [GitHub's registry](https://github.com/hacdias/webdav/pkgs/container/webdav) and [Docker Hub](https://hub.docker.com/r/hacdias/webdav). You can pull the images using one of the following two commands. Note that this commands pull the latest released version. You can use specific tags to pin specific versions, or use `main` for the development branch.
```bash
# GitHub Registry
docker pull ghcr.io/hacdias/webdav:latest
# Docker Hub
docker pull hacdias/webdav:latest
```
## 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:
For usage information regarding the CLI, run `webdav --help`.
### Docker
To use with Docker, you need to provide a configuration file and mount the data directories. For example, let's take the following configuration file that simply sets the port to `6060` and the directory to `/data`.
```yaml
port: 6060
directory: /data
```
You can now run with the following Docker command, where you mount the configuration file inside the container, and the data directory too, as well as forwarding the port 6060. You will need to change this to match your own configuration.
```bash
docker run \
-p 6060:6060 \
-v $(pwd)/config.yml:/config.yml:ro \
-v $(pwd)/data:/data \
ghcr.io/hacdias/webdav -c /config.yml
```
## 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.
```yaml
# Server related settings
address: 0.0.0.0
port: 0
auth: true
port: 6065
# TLS-related settings if you want to enable TLS directly.
tls: false
cert: cert.pem
key: key.pem
# Default user settings (will be merged)
scope: .
modify: true
# Prefix to apply to the WebDAV path-ing. Default is '/'.
prefix: /
# Enable or disable debug logging. Default is 'false'.
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).
directory: .
# The default permissions for users. This is a case insensitive option. Possible
# permissions: C (Create), R (Read), U (Update), D (Delete). You can combine multiple
# 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. 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.
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'.
format: console
# Enable or disable colors. Default is 'true'. Only applied if format is 'console'.
colors: true
# Logging outputs. You can have more than one output. Default is only 'stderr'.
outputs:
- stderr
# CORS configuration
cors:
# Whether or not CORS configuration should be applied. Default is 'false'.
enabled: true
credentials: true
allowed_headers:
- Depth
allowed_hosts:
- http://localhost:8080
allowed_methods:
- GET
exposed_headers:
- Content-Length
- Content-Range
# The list of users. If the list is empty, then there will be no authentication.
# Otherwise, basic authentication will automatically be configured.
#
# If you're delegating the authentication to a different service, you can proxy
# the username using basic authentication, and then disable webdav's password
# check using the option:
#
# noPassword: true
users:
# Example 'admin' user with plaintext password.
- username: admin
password: admin
scope: /a/different/path
- username: encrypted
# 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
# Example user whose details will be picked up from the environment.
- username: "{env}ENV_USERNAME"
password: "{env}ENV_PASSWORD"
- username: basic
password: basic
modify: false
# Override default permissions.
permissions: CRUD
rules:
- regex: false
allow: false
path: /some/file
# With this rule, the user CANNOT access /some/files.
- path: /some/file
permissions: none
# With this rule, the user CAN create, read, update and delete within /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.
- regex: "^.+.js$"
permissions: RU
```
There are more ways to customize how you run WebDAV through flags and environment variables. Please run `webdav --help` for more information on that.
### CORS
The `allowed_*` properties are optional, the default value for each of them will be `*`. `exposed_headers` is optional as well, but is not set if not defined. Setting `credentials` to `true` will allow you to:
1. Use `withCredentials = true` in javascript.
2. Use the `username:password@host` syntax.
## Caveats
### Reverse Proxy Service
When using a reverse proxy implementation, like Caddy, Nginx, or Apache, note that you need to forward the correct headers in order to avoid 502 errors. Here's a Nginx configuration example:
```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 $host;
proxy_redirect off;
}
```
## Examples
### Systemd
An example of how to use this with `systemd` is on [webdav.service.example](/webdav.service.example).
Example configuration of a [`systemd`](https://en.wikipedia.org/wiki/Systemd) service:
```conf
[Unit]
Description=WebDAV
After=network.target
[Service]
Type=simple
User=root
ExecStart=/usr/bin/webdav --config /opt/webdav.yml
Restart=on-failure
[Install]
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>"\s*\}
# Failregex to match "invalid username" and extract remote_address only (if applicable)
failregex += ^.*invalid username\s*\{.*"remote_address":\s*"<HOST>"\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.
#### 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.
## License
MIT © [Henrique Dias](https://hacdias.com)
[MIT License](LICENSE) © [Henrique Dias](https://hacdias.com)

49
cmd/bcrypt.go Normal file
View file

@ -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
},
}

View file

@ -9,4 +9,4 @@ func Execute() {
if err := rootCmd.Execute(); err != nil {
log.Fatal(err)
}
}
}

View file

@ -1,151 +0,0 @@
package cmd
import (
"errors"
"log"
"os"
"regexp"
"strings"
"github.com/hacdias/webdav/webdav"
"github.com/spf13/pflag"
v "github.com/spf13/viper"
wd "golang.org/x/net/webdav"
)
func parseRules(raw []interface{}) []*webdav.Rule {
rules := []*webdav.Rule{}
for _, v := range raw {
if r, ok := v.(map[interface{}]interface{}); ok {
rule := &webdav.Rule{
Regex: false,
Allow: false,
Path: "",
}
if regex, ok := r["regex"].(bool); ok {
rule.Regex = regex
}
if allow, ok := r["allow"].(bool); ok {
rule.Allow = allow
}
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 *webdav.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 strings.HasPrefix(password, "{env}") {
password, err = loadFromEnv(password)
checkErr(err)
}
user := &webdav.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 = parseRules(rules)
}
user.Handler = &wd.Handler{
FileSystem: wd.Dir(user.Scope),
LockSystem: wd.NewMemLS(),
}
c.Users[username] = user
}
}
}
func readConfig(flags *pflag.FlagSet) *webdav.Config {
cfg := &webdav.Config{
User: &webdav.User{
Scope: getOpt(flags, "scope"),
Modify: getOptB(flags, "modify"),
Rules: []*webdav.Rule{},
Handler: &wd.Handler{
FileSystem: wd.Dir(getOpt(flags, "scope")),
LockSystem: wd.NewMemLS(),
},
},
Auth: getOptB(flags, "auth"),
Users: map[string]*webdav.User{},
}
rawRules := v.Get("rules")
if rules, ok := rawRules.([]interface{}); ok {
cfg.User.Rules = parseRules(rules)
}
rawUsers := v.Get("users")
if users, ok := rawUsers.([]interface{}); ok {
parseUsers(users, cfg)
}
if len(cfg.Users) != 0 && !cfg.Auth {
log.Print("Users will be ignored due to auth=false")
}
return cfg
}

View file

@ -1,30 +1,30 @@
package cmd
import (
"errors"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"github.com/coreos/go-systemd/v22/activation"
"github.com/hacdias/webdav/v5/lib"
"github.com/spf13/cobra"
v "github.com/spf13/viper"
)
var (
cfgFile string
"go.uber.org/zap"
)
func init() {
cobra.OnInitialize(initConfig)
flags := rootCmd.Flags()
flags.StringVarP(&cfgFile, "config", "c", "", "config file path")
flags.BoolP("tls", "t", false, "enable tls")
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.StringP("address", "a", lib.DefaultAddress, "address to listen on")
flags.IntP("port", "p", lib.DefaultPort, "port to listen on")
flags.BoolP("tls", "t", lib.DefaultTLS, "enable TLS")
flags.String("cert", lib.DefaultCert, "path to TLS certificate")
flags.String("key", lib.DefaultKey, "path to TLS key")
flags.StringP("prefix", "P", lib.DefaultPrefix, "URL path prefix")
}
var rootCmd = &cobra.Command{
@ -46,53 +46,97 @@ 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)
cfgFilename, _ := flags.GetString("config")
// Builds the address and a listener.
laddr := getOpt(flags, "address") + ":" + getOpt(flags, "port")
listener, err := net.Listen("tcp", laddr)
cfg, err := lib.ParseConfig(cfgFilename, flags)
if err != nil {
log.Fatal(err)
return err
}
// Tell the user the port in which is listening.
fmt.Println("Listening on", listener.Addr().String())
// Starts the server.
if getOptB(flags, "tls") {
if err := http.ServeTLS(listener, cfg, getOpt(flags, "cert"), getOpt(flags, "key")); err != nil {
log.Fatal(err)
}
} else {
if err := http.Serve(listener, cfg); err != nil {
log.Fatal(err)
}
// Setup the logger based on the configuration
logger, err := cfg.GetLogger()
if err != nil {
return err
}
zap.ReplaceGlobals(logger)
// Create HTTP handler from the config
handler, err := lib.NewHandler(cfg)
if err != nil {
return err
}
defer func() {
// Flush the logger at the end
_ = zap.L().Sync()
}()
// 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")
} else {
v.SetConfigFile(cfgFile)
}
func getListener(cfg *lib.Config) (net.Listener, error) {
var (
address string
network string
)
v.SetEnvPrefix("WD")
v.AutomaticEnv()
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(v.ConfigParseError); ok {
panic(err)
if strings.HasPrefix(cfg.Address, "sd-listen-fd:") {
listeners, err := activation.ListenersWithNames()
if err != nil {
return nil, err
}
cfgFile = "No config file used"
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 {
cfgFile = "Using config file: " + v.ConfigFileUsed()
address = fmt.Sprintf("%s:%d", cfg.Address, cfg.Port)
network = "tcp"
}
return net.Listen(network, address)
}

View file

@ -6,14 +6,14 @@ import (
"github.com/spf13/cobra"
)
var version = "(untracked)"
var version = "untracked"
func init() {
rootCmd.AddCommand(&cobra.Command{
Use: "version",
Short: "Print the version number",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("WebDAV version " + version)
fmt.Printf("WebDAV version: %s\n", version)
},
})
}

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)
}
}

45
go.mod
View file

@ -1,13 +1,40 @@
module github.com/hacdias/webdav
module github.com/hacdias/webdav/v5
go 1.12
go 1.23
require (
github.com/BurntSushi/toml v0.3.1 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/spf13/cobra v0.0.3
github.com/spf13/pflag v1.0.3
github.com/spf13/viper v1.3.2
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529
golang.org/x/net v0.0.0-20190509222800-a4d6f7feada5
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.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/studio-b12/gowebdav v0.9.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.31.0
golang.org/x/net v0.33.0
)
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/magiconair/properties v1.8.9 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // 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
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

128
go.sum
View file

@ -1,55 +1,81 @@
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
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.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
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.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.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.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8=
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/viper v1.3.2 h1:VUFqw5KcqRf7i70GOzW7N+Q7+gxVBkSSqiXB12+JQ4M=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529 h1:iMGN4xG0cnqj3t+zOM8wUB0BiPKHEwSxEZCvzcbZuvk=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190509222800-a4d6f7feada5 h1:6M3SDHlHHDCx2PcQw3S4KsR170vGqDhJDOmpVd4Hjak=
golang.org/x/net v0.0.0-20190509222800-a4d6f7feada5/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
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.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM=
github.com/magiconair/properties v1.8.9/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.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
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.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/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.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/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/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/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=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
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.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 h1:1UoZQm6f0P/ZO0w1Ri+f+ifG/gXhegadRdwBIXEFWDo=
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

217
lib/config.go Normal file
View file

@ -0,0 +1,217 @@
package lib
import (
"errors"
"fmt"
"path/filepath"
"strings"
"github.com/go-viper/mapstructure/v2"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
const (
DefaultTLS = false
DefaultCert = "cert.pem"
DefaultKey = "key.pem"
DefaultAddress = "0.0.0.0"
DefaultPort = 6065
DefaultPrefix = "/"
)
type Config struct {
UserPermissions `mapstructure:",squash"`
Debug bool
Address string
Port int
TLS bool
Cert string
Key string
Prefix string
NoSniff bool
NoPassword bool
BehindProxy bool
Log Log
CORS CORS
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
}
}
// 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()
// 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)
v.SetDefault("Cert", DefaultCert)
v.SetDefault("Key", DefaultKey)
v.SetDefault("Address", DefaultAddress)
v.SetDefault("Port", DefaultPort)
v.SetDefault("Prefix", DefaultPrefix)
// Other defaults
v.SetDefault("RulesBehavior", RulesOverwrite)
v.SetDefault("Directory", ".")
v.SetDefault("Permissions", "R")
v.SetDefault("Debug", false)
v.SetDefault("NoSniff", false)
v.SetDefault("NoPassword", false)
v.SetDefault("Log.Format", "console")
v.SetDefault("Log.Outputs", []string{"stderr"})
v.SetDefault("Log.Colors", true)
v.SetDefault("CORS.Allowed_Headers", []string{"*"})
v.SetDefault("CORS.Allowed_Hosts", []string{"*"})
v.SetDefault("CORS.Allowed_Methods", []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, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","),
mapstructure.TextUnmarshallerHookFunc(),
)))
if err != nil {
return nil, err
}
// Cascade user settings
for i := range cfg.Users {
if !v.IsSet(fmt.Sprintf("Users.%d.Directory", i)) {
cfg.Users[i].Directory = cfg.Directory
}
if !v.IsSet(fmt.Sprintf("Users.%d.Permissions", i)) {
cfg.Users[i].Permissions = cfg.Permissions
}
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
}
}
err = cfg.Validate()
if err != nil {
return nil, err
}
return cfg, nil
}
func (c *Config) Validate() error {
var err error
c.Directory, err = filepath.Abs(c.Directory)
if err != nil {
return fmt.Errorf("invalid config: %w", err)
}
if c.TLS {
if c.Cert == "" {
return errors.New("invalid config: Cert must be defined if TLS is activated")
}
if c.Key == "" {
return errors.New("invalid config: Key must be defined if TLS is activated")
}
c.Cert, err = filepath.Abs(c.Cert)
if err != nil {
return fmt.Errorf("invalid config: %w", err)
}
c.Key, err = filepath.Abs(c.Key)
if err != nil {
return fmt.Errorf("invalid config: %w", err)
}
}
err = c.UserPermissions.Validate()
if err != nil {
return fmt.Errorf("invalid config: %w", err)
}
for i := range c.Users {
err := c.Users[i].Validate(c.NoPassword)
if err != nil {
return fmt.Errorf("invalid config: %w", err)
}
}
return nil
}
func (cfg *Config) GetLogger() (*zap.Logger, error) {
loggerConfig := zap.NewProductionConfig()
loggerConfig.DisableCaller = true
if cfg.Debug {
loggerConfig.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
}
if cfg.Log.Colors && cfg.Log.Format != "json" {
loggerConfig.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
}
loggerConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
loggerConfig.Encoding = cfg.Log.Format
loggerConfig.OutputPaths = cfg.Log.Outputs
return loggerConfig.Build()
}
type Log struct {
Format string
Colors bool
Outputs []string
}
type CORS struct {
Enabled bool
Credentials bool
AllowedHeaders []string `mapstructure:"allowed_headers"`
AllowedHosts []string `mapstructure:"allowed_hosts"`
AllowedMethods []string `mapstructure:"allowed_methods"`
ExposedHeaders []string `mapstructure:"exposed_headers"`
}

354
lib/config_test.go Normal file
View file

@ -0,0 +1,354 @@
package lib
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func writeAndParseConfig(t *testing.T, content, extension string) *Config {
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "config"+extension)
err := os.WriteFile(tmpFile, []byte(content), 0666)
require.NoError(t, err)
cfg, err := ParseConfig(tmpFile, nil)
require.NoError(t, err)
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()
cfg := writeAndParseConfig(t, "", ".yml")
require.NoError(t, cfg.Validate())
require.EqualValues(t, DefaultTLS, cfg.TLS)
require.EqualValues(t, DefaultAddress, cfg.Address)
require.EqualValues(t, DefaultPort, cfg.Port)
require.EqualValues(t, DefaultPrefix, cfg.Prefix)
require.EqualValues(t, "console", cfg.Log.Format)
require.EqualValues(t, true, cfg.Log.Colors)
require.EqualValues(t, []string{"stderr"}, cfg.Log.Outputs)
dir, err := os.Getwd()
require.NoError(t, err)
require.Equal(t, dir, cfg.Directory)
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()
check := func(t *testing.T, cfg *Config) {
require.True(t, cfg.Permissions.Read)
require.True(t, cfg.Permissions.Create)
require.False(t, cfg.Permissions.Delete)
require.False(t, cfg.Permissions.Update)
require.Equal(t, "/", cfg.Directory)
require.Len(t, cfg.Rules, 1)
require.Len(t, cfg.Users, 2)
require.True(t, cfg.Users[0].Permissions.Read)
require.True(t, cfg.Users[0].Permissions.Create)
require.False(t, cfg.Users[0].Permissions.Delete)
require.False(t, cfg.Users[0].Permissions.Update)
require.Equal(t, "/", cfg.Users[0].Directory)
require.Len(t, cfg.Users[0].Rules, 1)
require.True(t, cfg.Users[1].Permissions.Read)
require.False(t, cfg.Users[1].Permissions.Create)
require.False(t, cfg.Users[1].Permissions.Delete)
require.False(t, cfg.Users[1].Permissions.Update)
require.Equal(t, "/basic", cfg.Users[1].Directory)
require.Len(t, cfg.Users[1].Rules, 0)
}
t.Run("YAML", func(t *testing.T) {
content := `
directory: /
permissions: CR
rules:
- path: /public/access/
permissions: R
users:
- username: admin
password: admin
- username: basic
password: basic
directory: /basic
permissions: R
rules: []`
cfg := writeAndParseConfig(t, content, ".yml")
require.NoError(t, cfg.Validate())
check(t, cfg)
})
t.Run("JSON", func(t *testing.T) {
content := `{
"directory": "/",
"permissions": "CR",
"rules": [
{
"path": "/public/access/",
"permissions": "R"
}
],
"users": [
{
"username": "admin",
"password": "admin"
},
{
"username": "basic",
"password": "basic",
"directory": "/basic",
"permissions": "R",
"rules": []
}
]
}`
cfg := writeAndParseConfig(t, content, ".json")
require.NoError(t, cfg.Validate())
check(t, cfg)
})
t.Run("`TOML", func(t *testing.T) {
content := `
directory = "/"
permissions = "CR"
[[rules]]
path = "/public/access/"
permissions = "R"
[[users]]
username = "admin"
password = "admin"
[[users]]
username = "basic"
password = "basic"
directory = "/basic"
permissions = "R"
rules = []
`
cfg := writeAndParseConfig(t, content, ".toml")
require.NoError(t, cfg.Validate())
check(t, cfg)
})
}
func TestConfigKeys(t *testing.T) {
t.Parallel()
cfg := writeAndParseConfig(t, `
cors:
enabled: true
credentials: true
allowed_headers:
- Depth
allowed_hosts:
- http://localhost:8080
allowed_methods:
- GET
exposed_headers:
- Content-Length
- Content-Range`, ".yml")
require.NoError(t, cfg.Validate())
require.True(t, cfg.CORS.Enabled)
require.True(t, cfg.CORS.Credentials)
require.EqualValues(t, []string{"Content-Length", "Content-Range"}, cfg.CORS.ExposedHeaders)
require.EqualValues(t, []string{"Depth"}, cfg.CORS.AllowedHeaders)
require.EqualValues(t, []string{"http://localhost:8080"}, cfg.CORS.AllowedHosts)
require.EqualValues(t, []string{"GET"}, cfg.CORS.AllowedMethods)
}
func TestConfigRules(t *testing.T) {
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())
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.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) {
require.NoError(t, os.Setenv("WD_PORT", "1234"))
require.NoError(t, os.Setenv("WD_DEBUG", "true"))
require.NoError(t, os.Setenv("WD_PERMISSIONS", "CRUD"))
require.NoError(t, os.Setenv("WD_DIRECTORY", "/test"))
cfg, err := ParseConfig("", nil)
require.NoError(t, err)
assert.Equal(t, 1234, cfg.Port)
assert.Equal(t, "/test", cfg.Directory)
assert.Equal(t, true, cfg.Debug)
require.True(t, cfg.Permissions.Read)
require.True(t, cfg.Permissions.Create)
require.True(t, cfg.Permissions.Delete)
require.True(t, cfg.Permissions.Update)
// Reset
require.NoError(t, os.Setenv("WD_PORT", ""))
require.NoError(t, os.Setenv("WD_DEBUG", ""))
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"))
}

82
lib/files.go Normal file
View file

@ -0,0 +1,82 @@
package lib
import (
"context"
"mime"
"os"
"path"
"golang.org/x/net/webdav"
)
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) {
if mimeType := mime.TypeByExtension(path.Ext(w.FileInfo.Name())); mimeType != "" {
// We can figure out the mime from the extension.
return mimeType, nil
} else {
// We can't figure out the mime type without sniffing, call it an octet stream.
return "application/octet-stream", nil
}
}
type noSniffFile struct {
webdav.File
}
func (f noSniffFile) Stat() (os.FileInfo, error) {
info, err := f.File.Stat()
if err != nil {
return nil, err
}
return noSniffFileInfo{info}, nil
}
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]}
}
return fis, nil
}

187
lib/handler.go Normal file
View file

@ -0,0 +1,187 @@
package lib
import (
"net/http"
"net/url"
"os"
"strings"
"github.com/rs/cors"
"go.uber.org/zap"
"golang.org/x/net/webdav"
)
type handlerUser struct {
User
webdav.Handler
}
type Handler struct {
noPassword bool
behindProxy bool
user *handlerUser
users map[string]*handlerUser
}
func NewHandler(c *Config) (http.Handler, error) {
h := &Handler{
noPassword: c.NoPassword,
behindProxy: c.BehindProxy,
user: &handlerUser{
User: User{
UserPermissions: c.UserPermissions,
},
Handler: webdav.Handler{
Prefix: c.Prefix,
FileSystem: Dir{
Dir: webdav.Dir(c.Directory),
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.Directory),
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,
ExposedHeaders: c.CORS.ExposedHeaders,
OptionsPassthrough: false,
}).Handler(h), nil
}
if len(c.Users) == 0 {
zap.L().Warn("unprotected config: no users have been set, so no authentication will be used")
}
if c.NoPassword {
zap.L().Warn("unprotected config: password check is disabled, only intended when delegating authentication to another service")
}
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 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()
if !ok {
http.Error(w, "Not authorized", http.StatusUnauthorized)
return
}
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) {
// 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
}
// 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)
}
}
// Checks for user permissions relatively to this PATH.
allowed := user.Allowed(r, func(filename string) bool {
_, err := user.FileSystem.Stat(r.Context(), filename)
return !os.IsNotExist(err)
})
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 = responseWriterNoBody{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)
}
// 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
}

361
lib/handler_test.go Normal file
View file

@ -0,0 +1,361 @@
package lib
import (
"fmt"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"testing"
"github.com/stretchr/testify/require"
"github.com/studio-b12/gowebdav"
)
func makeTestDirectory(t *testing.T, m map[string][]byte) string {
dir := t.TempDir()
for path, data := range m {
filename := filepath.Join(dir, path)
if data == nil {
err := os.MkdirAll(filename, 0775)
require.NoError(t, err)
} else {
err := os.MkdirAll(filepath.Dir(filename), 0775)
require.NoError(t, err)
err = os.WriteFile(filename, data, 0664)
require.NoError(t, err)
}
}
return dir
}
func makeTestServer(t *testing.T, yamlConfig string) *httptest.Server {
cfg := writeAndParseConfig(t, yamlConfig, ".yml")
require.NoError(t, cfg.Validate())
handler, err := NewHandler(cfg)
require.NoError(t, err)
return httptest.NewServer(handler)
}
func TestServerDefaults(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"foo.txt": []byte("foo"),
"sub/bar.txt": []byte("bar"),
})
srv := makeTestServer(t, "directory: "+dir)
client := gowebdav.NewClient(srv.URL, "", "")
// By default, reading permissions.
files, err := client.ReadDir("/")
require.NoError(t, err)
require.Len(t, files, 2)
data, err := client.Read("/foo.txt")
require.NoError(t, err)
require.EqualValues(t, []byte("foo"), data)
files, err = client.ReadDir("/sub")
require.NoError(t, err)
require.Len(t, files, 1)
require.Equal(t, "bar.txt", files[0].Name())
data, err = client.Read("/sub/bar.txt")
require.NoError(t, err)
require.EqualValues(t, []byte("bar"), data)
// By default, no modification permissions.
require.ErrorContains(t, client.Mkdir("/dir", 0666), "403")
require.ErrorContains(t, client.MkdirAll("/dir/path", 0666), "403")
require.ErrorContains(t, client.Remove("/foo.txt"), "403")
require.ErrorContains(t, client.RemoveAll("/foo.txt"), "403")
require.ErrorContains(t, client.Rename("/foo.txt", "/file2.txt", false), "403")
require.ErrorContains(t, client.Copy("/foo.txt", "/file2.txt", false), "403")
require.ErrorContains(t, client.Write("/foo.txt", []byte("hello world 2"), 0666), "403")
}
func TestServerListingCharacters(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"富/foo.txt": []byte("foo"),
"你好.txt": []byte("bar"),
"z*.txt": []byte("zbar"),
"foo.txt": []byte("foo"),
"🌹.txt": []byte("foo"),
})
srv := makeTestServer(t, "directory: "+dir)
client := gowebdav.NewClient(srv.URL, "", "")
// By default, reading permissions.
files, err := client.ReadDir("/")
require.NoError(t, err)
require.Len(t, files, 5)
names := []string{
files[0].Name(),
files[1].Name(),
files[2].Name(),
files[3].Name(),
files[4].Name(),
}
sort.Strings(names)
require.Equal(t, []string{
"foo.txt",
"z*.txt",
"你好.txt",
"富",
"🌹.txt",
}, names)
data, err := client.Read("/z*.txt")
require.NoError(t, err)
require.EqualValues(t, []byte("zbar"), data)
}
func TestServerAuthentication(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"foo.txt": []byte("foo"),
"sub/bar.txt": []byte("bar"),
})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: CRUD
users:
- username: basic
password: basic
- username: bcrypt
password: "{bcrypt}$2a$12$222dfz8Nweoyvy8OwI8.me9nfaRfuz8lqGkiiYSMH1lLMHO26qWom"
`, dir))
t.Run("Basic Auth (Plaintext)", func(t *testing.T) {
t.Parallel()
client := gowebdav.NewClient(srv.URL, "basic", "basic")
files, err := client.ReadDir("/")
require.NoError(t, err)
require.Len(t, files, 2)
})
t.Run("Basic Auth (BCrypt)", func(t *testing.T) {
t.Parallel()
client := gowebdav.NewClient(srv.URL, "bcrypt", "bcrypt")
files, err := client.ReadDir("/")
require.NoError(t, err)
require.Len(t, files, 2)
})
t.Run("Unauthorized (No Credentials)", func(t *testing.T) {
t.Parallel()
client := gowebdav.NewClient(srv.URL, "", "")
_, err := client.ReadDir("/")
require.ErrorContains(t, err, "401")
})
t.Run("Unauthorized (Wrong User)", func(t *testing.T) {
t.Parallel()
client := gowebdav.NewClient(srv.URL, "wrong", "basic")
_, err := client.ReadDir("/")
require.ErrorContains(t, err, "401")
})
t.Run("Unauthorized (Wrong Password)", func(t *testing.T) {
t.Parallel()
client := gowebdav.NewClient(srv.URL, "basic", "wrong")
_, err := client.ReadDir("/")
require.ErrorContains(t, err, "401")
})
}
func TestServerAuthenticationNoPassword(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"foo.txt": []byte("foo"),
"sub/bar.txt": []byte("bar"),
})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
noPassword: true
permissions: CRUD
users:
- username: basic
`, dir))
t.Run("Basic Auth", func(t *testing.T) {
t.Parallel()
client := gowebdav.NewClient(srv.URL, "basic", "")
files, err := client.ReadDir("/")
require.NoError(t, err)
require.Len(t, files, 2)
})
t.Run("Unauthorized Wrong User", func(t *testing.T) {
t.Parallel()
client := gowebdav.NewClient(srv.URL, "wrong", "")
_, err := client.ReadDir("/")
require.ErrorContains(t, err, "401")
})
}
func TestServerRules(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
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("/")
require.NoError(t, err)
require.Len(t, files, 5)
err = client.Write("/foo.txt", []byte("new"), 0666)
require.NoError(t, err)
err = client.Write("/new.txt", []byte("new"), 0666)
require.NoError(t, err)
err = client.Copy("/bar.js", "/b/bar.js", false)
require.ErrorContains(t, err, "403")
err = client.Copy("/bar.js", "/bar.jsx", false)
require.NoError(t, err)
err = client.Copy("/b/foo.txt", "/foo1.txt", false)
require.NoError(t, err)
err = client.Rename("/b/foo.txt", "/foo2.txt", false)
require.ErrorContains(t, err, "403")
_, err = client.Read("/a/foo.txt")
require.ErrorContains(t, err, "403")
err = client.Write("/a/foo.js", []byte("new"), 0666)
require.ErrorContains(t, err, "403")
err = client.Write("/b/foo.txt", []byte("new"), 0666)
require.ErrorContains(t, err, "403")
_, err = client.ReadDir("/c")
require.ErrorContains(t, err, "403")
_, err = client.Read("/c/a.txt")
require.ErrorContains(t, err, "403")
err = client.Write("/c/b.txt", []byte("new"), 0666)
require.ErrorContains(t, err, "403")
}
func TestServerPermissions(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"foo.txt": []byte("foo"),
"a/foo.txt": []byte("foo a"),
"b/foo.txt": []byte("foo b"),
})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: CR
users:
- username: a
password: a
directory: %s/a
- username: b
password: b
directory: %s/b
permissions: R
`, dir, dir, dir))
t.Run("User A", func(t *testing.T) {
t.Parallel()
client := gowebdav.NewClient(srv.URL, "a", "a")
files, err := client.ReadDir("/")
require.NoError(t, err)
require.Len(t, files, 1)
data, err := client.Read("/foo.txt")
require.NoError(t, err)
require.EqualValues(t, []byte("foo a"), data)
err = client.Copy("/foo.txt", "/copy.txt", false)
require.NoError(t, err)
err = client.Copy("/foo.txt", "/copy.txt", true)
require.ErrorContains(t, err, "403")
err = client.Rename("/foo.txt", "/copy.txt", true)
require.ErrorContains(t, err, "403")
data, err = client.Read("/copy.txt")
require.NoError(t, err)
require.EqualValues(t, []byte("foo a"), data)
})
t.Run("User B", func(t *testing.T) {
t.Parallel()
client := gowebdav.NewClient(srv.URL, "b", "b")
files, err := client.ReadDir("/")
require.NoError(t, err)
require.Len(t, files, 1)
data, err := client.Read("/foo.txt")
require.NoError(t, err)
require.EqualValues(t, []byte("foo b"), data)
err = client.Copy("/foo.txt", "/copy.txt", false)
require.ErrorContains(t, err, "403")
})
}

186
lib/permissions.go Normal file
View file

@ -0,0 +1,186 @@
package lib
import (
"errors"
"fmt"
"net/http"
"path/filepath"
"regexp"
"strings"
)
type Rule struct {
Permissions Permissions
Path string
Regex *regexp.Regexp
}
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")
}
return nil
}
// Matches checks if [Rule] matches the given path.
func (r *Rule) Matches(path string) bool {
if r.Regex != nil {
return r.Regex.MatchString(path)
}
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
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 {
// 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")
for i := len(p.Rules) - 1; i >= 0; i-- {
if p.Rules[i].Matches(dst) {
if !p.Rules[i].Permissions.AllowedDestination(r, fileExists) {
return false
}
// Only check the first rule that matches, similarly to the source rules.
break
}
}
if !p.Permissions.AllowedDestination(r, fileExists) {
return false
}
}
// 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) {
return p.Rules[i].Permissions.Allowed(r, fileExists)
}
}
return p.Permissions.Allowed(r, fileExists)
}
func (p *UserPermissions) Validate() error {
var err error
p.Directory, err = filepath.Abs(p.Directory)
if err != nil {
return fmt.Errorf("invalid permissions: %w", err)
}
for _, r := range p.Rules {
if err := r.Validate(); err != nil {
return fmt.Errorf("invalid permissions: %w", err)
}
}
switch p.RulesBehavior {
case RulesAppend, RulesOverwrite:
// Good to go
default:
return fmt.Errorf("invalid rule behavior: %s", p.RulesBehavior)
}
return nil
}
type Permissions struct {
Create bool
Read bool
Update bool
Delete bool
}
func (p *Permissions) UnmarshalText(data []byte) error {
text := strings.ToLower(string(data))
if text == "none" {
return nil
}
for _, c := range text {
switch c {
case 'c':
p.Create = true
case 'r':
p.Read = true
case 'u':
p.Update = true
case 'd':
p.Delete = true
default:
return fmt.Errorf("invalid permission: %q", c)
}
}
return nil
}
// 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 {
case "GET", "HEAD", "OPTIONS", "POST", "PROPFIND":
// Note: POST backend implementation just returns the same thing as GET.
return p.Read
case "MKCOL":
return p.Create
case "PROPPATCH":
return p.Update
case "PUT":
if fileExists(r.URL.Path) {
return p.Update
} else {
return p.Create
}
case "COPY":
return p.Read
case "MOVE":
return p.Read && p.Delete
case "DELETE":
return p.Delete
case "LOCK", "UNLOCK":
return p.Create || p.Read || p.Update || p.Delete
default:
return false
}
}
// 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 {
case "COPY", "MOVE":
if fileExists(r.Header.Get("Destination")) {
return p.Update
} else {
return p.Create
}
default:
return false
}
}

61
lib/user.go Normal file
View file

@ -0,0 +1,61 @@
package lib
import (
"errors"
"fmt"
"os"
"strings"
"golang.org/x/crypto/bcrypt"
)
type User struct {
UserPermissions `mapstructure:",squash"`
Username string
Password string
}
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 u.Password == input
}
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)
}
u.Password = os.Getenv(env)
if u.Password == "" {
return fmt.Errorf("invalid user %q: password environment variable is empty", u.Username)
}
}
if err := u.UserPermissions.Validate(); err != nil {
return fmt.Errorf("invalid user %q: %w", u.Username, err)
}
return nil
}

View file

@ -1,12 +1,9 @@
package main
import (
"runtime"
"github.com/hacdias/webdav/cmd"
"github.com/hacdias/webdav/v5/cmd"
)
func main() {
runtime.GOMAXPROCS(runtime.NumCPU())
cmd.Execute()
}

View file

@ -1,12 +0,0 @@
[Unit]
Description=WebDAV server
After=network.target
[Service]
Type=simple
User=root
ExecStart=/usr/bin/webdav --config /opt/webdav.config
Restart=on-failure
[Install]
WantedBy=multi-user.target

View file

@ -1,48 +0,0 @@
package webdav
import (
"regexp"
"strings"
"golang.org/x/net/webdav"
)
// Rule is a dissalow/allow rule.
type Rule struct {
Regex bool
Allow 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
}
// Allowed checks if the user has permission to access a directory/file
func (u User) Allowed(url string) bool {
var rule *Rule
i := len(u.Rules) - 1
for i >= 0 {
rule = u.Rules[i]
if rule.Regex {
if rule.Regexp.MatchString(url) {
return rule.Allow
}
} else if strings.HasPrefix(url, rule.Path) {
return rule.Allow
}
i--
}
return true
}

View file

@ -1,16 +0,0 @@
package webdav
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
}

View file

@ -1,110 +0,0 @@
package webdav
import (
"context"
"log"
"net/http"
)
// Config is the configuration of a WebDAV instance.
type Config struct {
*User
Auth bool
Users map[string]*User
}
// 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
if c.Auth {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
// Gets the correct user for this request.
username, password, ok := r.BasicAuth()
if !ok {
http.Error(w, "Not authorized", 401)
return
}
user, ok := c.Users[username]
if !ok {
http.Error(w, "Not authorized", 401)
return
}
if !checkPassword(user.Password, password) {
log.Println("Wrong Password for user", username)
http.Error(w, "Not authorized", 401)
return
}
u = user
}
// Checks for user permissions relatively to this PATH.
if !u.Allowed(r.URL.Path) {
w.WriteHeader(http.StatusForbidden)
return
}
if r.Method == "HEAD" {
w = newResponseWriterNoBody(w)
}
// If this request modified the files and the user doesn't have permission
// to do so, return forbidden.
if (r.Method == "PUT" || r.Method == "POST" || r.Method == "MKCOL" ||
r.Method == "DELETE" || r.Method == "COPY" || r.Method == "MOVE") &&
!u.Modify {
w.WriteHeader(http.StatusForbidden)
return
}
// 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" {
info, err := u.Handler.FileSystem.Stat(context.TODO(), r.URL.Path)
if err == nil && info.IsDir() {
r.Method = "PROPFIND"
if r.Header.Get("Depth") == "" {
r.Header.Add("Depth", "1")
}
}
}
// Runs the WebDAV.
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)
}