Compare commits

..
13 Commits
Author SHA1 Message Date
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
10 changed files with 347 additions and 110 deletions
+2 -1
View File
@@ -64,6 +64,7 @@ jobs:
sbom: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: ${{ steps.meta.outputs.args }}
build-args: |
VERSION=${{ steps.meta.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
+2 -2
View File
@@ -1,6 +1,6 @@
FROM golang:1.22-alpine3.20 AS build
ARG DOCKER_META_VERSION="untracked"
ARG VERSION="untracked"
RUN apk --update add ca-certificates
@@ -11,7 +11,7 @@ COPY ./go.sum ./
RUN go mod download
COPY . /webdav/
RUN go build -o main -ldflags="-X 'github.com/hacdias/webdav/v4/cmd.version=$DOCKER_META_VERSION'" .
RUN go build -o main -ldflags="-X 'github.com/hacdias/webdav/v4/cmd.version=$VERSION'" .
FROM scratch
+114 -32
View File
@@ -8,28 +8,110 @@ 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/v4@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 scope to `/data`.
```yaml
port: 6060
scope: /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
# TLS-related settings if you want to enable TLS directly.
tls: false
cert: cert.pem
key: key.pem
# Prefix to apply to the WebDAV path-ing. Default is "/".
prefix: /
# Enable or disable debug logging. Default is false.
debug: false
# Default user settings (will be merged)
scope: .
# Whether or not to have authentication. With authentication on, you need to
# define one or more users. Default is false.
auth: true
# 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 'scope' defined.
# Default is "/".
scope: /
# Whether the users can, by default, modify the contents. Default is false.
modify: true
# Default permissions rules to apply at the paths.
rules: []
# The list of users. Must be defined if auth is set to true.
users:
# Example 'admin' user with plaintext password.
- username: admin
password: admin
# Example 'john' user with bcrypt encrypted password, with custom scope.
- username: john
password: "{bcrypt}$2y$10$zEP6oofmXFeHaeMfBNLnP.DO8m.H.Mwhd24/TOX2MWLxAExXi4qgi"
scope: /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
# Override default modify.
modify: false
rules:
# With this rule, the user CANNOT access /some/files.
- path: /some/file
allow: false
# With this rule, the user CAN modify /public/access.
- path: /public/access/
modify: true
# With this rule, the user CAN modify all files ending with .js. It uses
# a regular expression.
- path: "^*.js$"
regex: true
modify: true
# CORS configuration
cors:
enabled: true
@@ -43,32 +125,8 @@ cors:
exposed_headers:
- Content-Length
- Content-Range
users:
- username: admin
password: admin
scope: /a/different/path
- username: encrypted
password: "{bcrypt}$2y$10$zEP6oofmXFeHaeMfBNLnP.DO8m.H.Mwhd24/TOX2MWLxAExXi4qgi"
- username: "{env}ENV_USERNAME"
password: "{env}ENV_PASSWORD"
- username: basic
password: basic
modify: false
rules:
- regex: false
allow: false
path: /some/file
- path: /public/access/
modify: true
```
There are more ways to customize how you run WebDAV through flags and environment variables. Please run `webdav --help` for more information on that.
### Systemd
An example of how to use this with `systemd` is on [webdav.service.example](/webdav.service.example).
### 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:
@@ -76,8 +134,11 @@ The `allowed_*` properties are optional, the default value for each of them will
1. Use `withCredentials = true` in javascript.
2. Use the `username:password@host` syntax.
## Caveats
### Reverse Proxy Service
When you use a reverse proxy implementation like `Nginx` or `Apache`, please note the following fields to avoid causing `502` errors
When 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 / {
@@ -90,10 +151,31 @@ location / {
}
```
## Examples
### Systemd
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
```
## Contributing
Feel free to open an issue or a pull request.
## License
[MIT License](LICENSE) © [Henrique Dias](https://hacdias.com)
[MIT License](LICENSE) © [Henrique Dias](https://hacdias.com)
+8 -8
View File
@@ -19,14 +19,14 @@ import (
func init() {
flags := rootCmd.Flags()
flags.StringP("config", "c", "", "config file path")
flags.BoolP("tls", "t", false, "enable TLS")
flags.Bool("auth", false, "enable authentication")
flags.String("cert", "cert.pem", "path to TLS certificate")
flags.String("key", "key.pem", "path to TLS key")
flags.StringP("address", "a", "0.0.0.0", "address to listen on")
flags.StringP("port", "p", "0", "port to listen on")
flags.StringP("prefix", "P", "/", "URL path prefix")
flags.String("log_format", "console", "logging format")
flags.BoolP("tls", "t", lib.DefaultTLS, "enable TLS")
flags.Bool("auth", lib.DefaultAuth, "enable authentication")
flags.String("cert", lib.DefaultCert, "path to TLS certificate")
flags.String("key", lib.DefaultKey, "path to TLS key")
flags.StringP("address", "a", lib.DefaultAddress, "address to listen on")
flags.IntP("port", "p", lib.DefaultPort, "port to listen on")
flags.StringP("prefix", "P", lib.DefaultPrefix, "URL path prefix")
flags.String("log_format", lib.DefaultLogFormat, "logging format")
}
var rootCmd = &cobra.Command{
+46 -9
View File
@@ -10,6 +10,21 @@ import (
"github.com/spf13/viper"
)
const (
DefaultScope = "/"
DefaultModify = false
DefaultDebug = false
DefaultNoSniff = false
DefaultTLS = false
DefaultAuth = false
DefaultCert = "cert.pem"
DefaultKey = "key.pem"
DefaultAddress = "0.0.0.0"
DefaultPort = 0
DefaultPrefix = "/"
DefaultLogFormat = "console"
)
type Config struct {
Permissions `mapstructure:",squash"`
Debug bool
@@ -20,7 +35,7 @@ type Config struct {
Key string
Prefix string
NoSniff bool
LogFormat string
LogFormat string `mapstructure:"log_format"`
Auth bool
CORS CORS
Users []User
@@ -54,11 +69,28 @@ 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
v.SetDefault("CORS.AllowedHeaders", []string{"*"})
v.SetDefault("CORS.AllowedHosts", []string{"*"})
v.SetDefault("CORS.AllowedMethods", []string{"*"})
// Defaults shared with flags
v.SetDefault("Scope", DefaultScope)
v.SetDefault("Modify", DefaultModify)
v.SetDefault("Debug", DefaultDebug)
v.SetDefault("NoSniff", DefaultNoSniff)
v.SetDefault("TLS", DefaultTLS)
v.SetDefault("Cert", DefaultCert)
v.SetDefault("Key", DefaultKey)
v.SetDefault("Address", DefaultAddress)
v.SetDefault("Port", DefaultPort)
v.SetDefault("Auth", DefaultAuth)
v.SetDefault("Prefix", DefaultPrefix)
v.SetDefault("Log_Format", DefaultLogFormat)
// Other defaults
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()
@@ -108,6 +140,11 @@ func (c *Config) Validate() error {
return errors.New("invalid config: auth cannot be disabled with users defined")
}
c.Scope, err = filepath.Abs(c.Scope)
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")
@@ -146,8 +183,8 @@ func (c *Config) Validate() error {
type CORS struct {
Enabled bool
Credentials bool
AllowedHeaders []string
AllowedHosts []string
AllowedMethods []string
ExposedHeaders []string
AllowedHeaders []string `mapstructure:"allowed_headers"`
AllowedHosts []string `mapstructure:"allowed_hosts"`
AllowedMethods []string `mapstructure:"allowed_methods"`
ExposedHeaders []string `mapstructure:"exposed_headers"`
}
+163 -15
View File
@@ -5,12 +5,13 @@ import (
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func writeAndParseConfig(t *testing.T, content string) *Config {
func writeAndParseConfig(t *testing.T, content, extension string) *Config {
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "config.yml")
tmpFile := filepath.Join(tmpDir, "config"+extension)
err := os.WriteFile(tmpFile, []byte(content), 0666)
require.NoError(t, err)
@@ -24,9 +25,17 @@ func writeAndParseConfig(t *testing.T, content string) *Config {
func TestConfigDefaults(t *testing.T) {
t.Parallel()
cfg := writeAndParseConfig(t, "")
cfg := writeAndParseConfig(t, "", ".yml")
require.NoError(t, cfg.Validate())
require.EqualValues(t, DefaultAuth, cfg.Auth)
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, DefaultLogFormat, cfg.LogFormat)
require.NotEmpty(t, cfg.Scope)
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHeaders)
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHosts)
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedMethods)
@@ -35,7 +44,24 @@ func TestConfigDefaults(t *testing.T) {
func TestConfigCascade(t *testing.T) {
t.Parallel()
content := `
check := func(t *testing.T, cfg *Config) {
require.True(t, cfg.Modify)
require.Equal(t, "/", cfg.Scope)
require.Len(t, cfg.Rules, 1)
require.Len(t, cfg.Users, 2)
require.True(t, cfg.Users[0].Modify)
require.Equal(t, "/", cfg.Users[0].Scope)
require.Len(t, cfg.Users[0].Rules, 1)
require.False(t, cfg.Users[1].Modify)
require.Equal(t, "/basic", cfg.Users[1].Scope)
require.Len(t, cfg.Users[1].Rules, 0)
}
t.Run("YAML", func(t *testing.T) {
content := `
auth: true
scope: /
modify: true
@@ -52,20 +78,142 @@ users:
modify: false
rules: []`
cfg := writeAndParseConfig(t, content)
cfg := writeAndParseConfig(t, content, ".yml")
require.NoError(t, cfg.Validate())
check(t, cfg)
})
t.Run("JSON", func(t *testing.T) {
content := `{
"auth": true,
"scope": "/",
"modify": true,
"rules": [
{
"path": "/public/access/",
"modify": true
}
],
"users": [
{
"username": "admin",
"password": "admin"
},
{
"username": "basic",
"password": "basic",
"scope": "/basic",
"modify": false,
"rules": []
}
]
}`
cfg := writeAndParseConfig(t, content, ".json")
require.NoError(t, cfg.Validate())
check(t, cfg)
})
t.Run("`TOML", func(t *testing.T) {
content := `auth = true
scope = "/"
modify = true
[[rules]]
path = "/public/access/"
modify = true
[[users]]
username = "admin"
password = "admin"
[[users]]
username = "basic"
password = "basic"
scope = "/basic"
modify = false
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.Modify)
require.Equal(t, "/", cfg.Scope)
require.Len(t, cfg.Rules, 1)
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)
}
require.Len(t, cfg.Users, 2)
func TestConfigRules(t *testing.T) {
content := `
auth: false
scope: /
modify: true
rules:
- path: '^.+\.js$'
regex: true
modify: true
- path: /public/access/
regex: false
modify: true`
require.True(t, cfg.Users[0].Modify)
require.Equal(t, "/", cfg.Users[0].Scope)
require.Len(t, cfg.Users[0].Rules, 1)
cfg := writeAndParseConfig(t, content, ".yaml")
require.NoError(t, cfg.Validate())
require.False(t, cfg.Users[1].Modify)
require.Equal(t, "/basic", cfg.Users[1].Scope)
require.Len(t, cfg.Users[1].Rules, 0)
require.Len(t, cfg.Rules, 2)
require.Empty(t, cfg.Rules[0].Path)
require.NotNil(t, cfg.Rules[0].Regexp)
require.True(t, cfg.Rules[0].Regexp.MatchString("/my/path/to/file.js"))
require.False(t, cfg.Rules[0].Regexp.MatchString("/my/path/to/file.ts"))
require.NotEmpty(t, cfg.Rules[1].Path)
require.Nil(t, cfg.Rules[1].Regexp)
}
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_MODIFY", "true"))
require.NoError(t, os.Setenv("WD_SCOPE", "/test"))
cfg, err := ParseConfig("", nil)
require.NoError(t, err)
assert.Equal(t, 1234, cfg.Port)
assert.Equal(t, "/test", cfg.Scope)
assert.Equal(t, true, cfg.Debug)
assert.Equal(t, true, cfg.Modify)
// Reset
require.NoError(t, os.Setenv("WD_PORT", ""))
require.NoError(t, os.Setenv("WD_DEBUG", ""))
require.NoError(t, os.Setenv("WD_MODIFY", ""))
require.NoError(t, os.Setenv("WD_SCOPE", ""))
}
+10 -3
View File
@@ -15,7 +15,6 @@ type handlerUser struct {
}
type Handler struct {
*Config
user *handlerUser
users map[string]*handlerUser
}
@@ -70,7 +69,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := h.user
// Authentication
if h.Auth {
if len(h.users) > 0 {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
// Gets the correct user for this request.
@@ -107,7 +106,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
if r.Method == "HEAD" {
w = newResponseWriterNoBody(w)
w = responseWriterNoBody{w}
}
// Excerpt from RFC4918, section 9.4:
@@ -131,3 +130,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Runs the WebDAV.
user.ServeHTTP(w, r)
}
type responseWriterNoBody struct {
http.ResponseWriter
}
func (w responseWriterNoBody) Write(data []byte) (int, error) {
return 0, nil
}
+2 -1
View File
@@ -31,6 +31,7 @@ func (r *Rule) Validate() error {
}
r.Regexp = rp
r.Path = ""
r.Regex = false
}
return nil
@@ -38,7 +39,7 @@ func (r *Rule) Validate() error {
// Matches checks if [Rule] matches the given path.
func (r *Rule) Matches(path string) bool {
if r.Regex {
if r.Regexp != nil {
return r.Regexp.MatchString(path)
}
-27
View File
@@ -1,27 +0,0 @@
package lib
import "net/http"
var _ http.ResponseWriter = responseWriterNoBody{}
// responseWriterNoBody is a wrapper used to suppress the body of the response
// to a request. Mainly used for HEAD requests.
type responseWriterNoBody struct {
http.ResponseWriter
}
// newResponseWriterNoBody creates a new responseWriterNoBody.
func newResponseWriterNoBody(w http.ResponseWriter) *responseWriterNoBody {
return &responseWriterNoBody{w}
}
// Write suppress the body.
func (w responseWriterNoBody) Write(data []byte) (int, error) {
return 0, nil
}
// WriteHeader writes the header to the http.ResponseWriter.
func (w responseWriterNoBody) WriteHeader(statusCode int) {
w.Header().Del("Content-Length")
w.ResponseWriter.WriteHeader(statusCode)
}
-12
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