diff --git a/cmd/serve/http/http.go b/cmd/serve/http/http.go
index 5db094dce..594ac551c 100644
--- a/cmd/serve/http/http.go
+++ b/cmd/serve/http/http.go
@@ -2,7 +2,8 @@
package http
import (
- "html/template"
+ "context"
+ "fmt"
"io"
"log"
"net/http"
@@ -10,16 +11,15 @@ import (
"path"
"strconv"
"strings"
+ "sync"
"time"
- "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/rclone/rclone/cmd"
- "github.com/rclone/rclone/cmd/serve/http/data"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/accounting"
- httplib "github.com/rclone/rclone/lib/http"
- "github.com/rclone/rclone/lib/http/auth"
+ "github.com/rclone/rclone/lib/atexit"
+ libhttp "github.com/rclone/rclone/lib/http"
"github.com/rclone/rclone/lib/http/serve"
"github.com/rclone/rclone/vfs"
"github.com/rclone/rclone/vfs/vfsflags"
@@ -28,20 +28,27 @@ import (
// Options required for http server
type Options struct {
- data.Options
+ Auth libhttp.AuthConfig
+ HTTP libhttp.HTTPConfig
+ Template libhttp.TemplateConfig
}
// DefaultOpt is the default values used for Options
-var DefaultOpt = Options{}
+var DefaultOpt = Options{
+ Auth: libhttp.DefaultAuthCfg(),
+ HTTP: libhttp.DefaultHTTPCfg(),
+ Template: libhttp.DefaultTemplateCfg(),
+}
// Opt is options set by command line flags
var Opt = DefaultOpt
func init() {
- data.AddFlags(Command.Flags(), "", &Opt.Options)
- httplib.AddFlags(Command.Flags())
- auth.AddFlags(Command.Flags())
- vfsflags.AddFlags(Command.Flags())
+ flagSet := Command.Flags()
+ libhttp.AddAuthFlagsPrefix(flagSet, "", &Opt.Auth)
+ libhttp.AddHTTPFlagsPrefix(flagSet, "", &Opt.HTTP)
+ libhttp.AddTemplateFlagsPrefix(flagSet, "", &Opt.Template)
+ vfsflags.AddFlags(flagSet)
}
// Command definition for cobra
@@ -59,60 +66,78 @@ The server will log errors. Use ` + "`-v`" + ` to see access logs.
` + "`--bwlimit`" + ` will be respected for file transfers. Use ` + "`--stats`" + ` to
control the stats printing.
-` + httplib.Help + data.Help + auth.Help + vfs.Help,
+` + libhttp.Help + libhttp.TemplateHelp + libhttp.AuthHelp + vfs.Help,
Annotations: map[string]string{
"versionIntroduced": "v1.39",
},
Run: func(command *cobra.Command, args []string) {
cmd.CheckArgs(1, 1, command, args)
f := cmd.NewFsSrc(args)
+
cmd.Run(false, true, command, func() error {
- s := newServer(f, Opt.Template)
- router, err := httplib.Router()
+ ctx := context.Background()
+
+ s, err := run(ctx, f, Opt)
if err != nil {
- return err
+ log.Fatal(err)
}
- s.Bind(router)
- httplib.Wait()
+
+ var finaliseOnce sync.Once
+ finalise := func() {
+ finaliseOnce.Do(func() {
+ if err := s.server.Shutdown(); err != nil {
+ log.Printf("error shutting down server: %v", err)
+ }
+ })
+ }
+ fnHandle := atexit.Register(finalise)
+ defer atexit.Unregister(fnHandle)
+
+ s.server.Wait()
return nil
})
},
}
// server contains everything to run the server
-type server struct {
- f fs.Fs
- vfs *vfs.VFS
- HTMLTemplate *template.Template // HTML template for web interface
+type serveCmd struct {
+ f fs.Fs
+ vfs *vfs.VFS
+ server libhttp.Server
}
-func newServer(f fs.Fs, templatePath string) *server {
- htmlTemplate, templateErr := data.GetTemplate(templatePath)
- if templateErr != nil {
- log.Fatalf(templateErr.Error())
- }
- s := &server{
- f: f,
- vfs: vfs.New(f, &vfsflags.Opt),
- HTMLTemplate: htmlTemplate,
- }
- return s
-}
+func run(ctx context.Context, f fs.Fs, opt Options) (*serveCmd, error) {
+ var err error
-func (s *server) Bind(router chi.Router) {
- if m := auth.Auth(auth.Opt); m != nil {
- router.Use(m)
+ s := &serveCmd{
+ f: f,
+ vfs: vfs.New(f, &vfsflags.Opt),
}
+
+ s.server, err = libhttp.NewServer(ctx,
+ libhttp.WithConfig(opt.HTTP),
+ libhttp.WithAuth(opt.Auth),
+ libhttp.WithTemplate(opt.Template),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to init server: %w", err)
+ }
+
+ router := s.server.Router()
router.Use(
middleware.SetHeader("Accept-Ranges", "bytes"),
middleware.SetHeader("Server", "rclone/"+fs.Version),
)
router.Get("/*", s.handler)
router.Head("/*", s.handler)
+
+ s.server.Serve()
+
+ return s, nil
}
// handler reads incoming requests and dispatches them
-func (s *server) handler(w http.ResponseWriter, r *http.Request) {
+func (s *serveCmd) handler(w http.ResponseWriter, r *http.Request) {
isDir := strings.HasSuffix(r.URL.Path, "/")
remote := strings.Trim(r.URL.Path, "/")
if isDir {
@@ -123,7 +148,7 @@ func (s *server) handler(w http.ResponseWriter, r *http.Request) {
}
// serveDir serves a directory index at dirRemote
-func (s *server) serveDir(w http.ResponseWriter, r *http.Request, dirRemote string) {
+func (s *serveCmd) serveDir(w http.ResponseWriter, r *http.Request, dirRemote string) {
// List the directory
node, err := s.vfs.Stat(dirRemote)
if err == vfs.ENOENT {
@@ -145,7 +170,7 @@ func (s *server) serveDir(w http.ResponseWriter, r *http.Request, dirRemote stri
}
// Make the entries for display
- directory := serve.NewDirectory(dirRemote, s.HTMLTemplate)
+ directory := serve.NewDirectory(dirRemote, s.server.HTMLTemplate())
for _, node := range dirEntries {
if vfsflags.Opt.NoModTime {
directory.AddHTMLEntry(node.Path(), node.IsDir(), node.Size(), time.Time{})
@@ -165,7 +190,7 @@ func (s *server) serveDir(w http.ResponseWriter, r *http.Request, dirRemote stri
}
// serveFile serves a file object at remote
-func (s *server) serveFile(w http.ResponseWriter, r *http.Request, remote string) {
+func (s *serveCmd) serveFile(w http.ResponseWriter, r *http.Request, remote string) {
node, err := s.vfs.Stat(remote)
if err == vfs.ENOENT {
fs.Infof(remote, "%s: File not found", r.RemoteAddr)
diff --git a/cmd/serve/http/http_test.go b/cmd/serve/http/http_test.go
index 0c77e4b61..9f264adce 100644
--- a/cmd/serve/http/http_test.go
+++ b/cmd/serve/http/http_test.go
@@ -14,14 +14,14 @@ import (
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config/configfile"
"github.com/rclone/rclone/fs/filter"
- httplib "github.com/rclone/rclone/lib/http"
+ libhttp "github.com/rclone/rclone/lib/http"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var (
updateGolden = flag.Bool("updategolden", false, "update golden files for regression test")
- httpServer *server
+ sc *serveCmd
testURL string
)
@@ -30,16 +30,25 @@ const (
testTemplate = "testdata/golden/testindex.html"
)
-func startServer(t *testing.T, f fs.Fs) {
- opt := httplib.DefaultOpt
- opt.ListenAddr = testBindAddress
- httpServer = newServer(f, testTemplate)
- router, err := httplib.Router()
- if err != nil {
- t.Fatal(err.Error())
+func start(t *testing.T, f fs.Fs) {
+ ctx := context.Background()
+
+ opts := Options{
+ HTTP: libhttp.DefaultHTTPCfg(),
+ Template: libhttp.TemplateConfig{
+ Path: testTemplate,
+ },
}
- httpServer.Bind(router)
- testURL = httplib.URL()
+ opts.HTTP.ListenAddr = []string{testBindAddress}
+
+ s, err := run(ctx, f, opts)
+ require.NoError(t, err, "failed to start server")
+ sc = s
+
+ urls := s.server.URLs()
+ require.Len(t, urls, 1, "expected one URL")
+
+ testURL = urls[0]
// try to connect to the test server
pause := time.Millisecond
@@ -54,7 +63,6 @@ func startServer(t *testing.T, f fs.Fs) {
pause *= 2
}
t.Fatal("couldn't connect to server")
-
}
var (
@@ -84,7 +92,7 @@ func TestInit(t *testing.T) {
require.NoError(t, err)
require.NoError(t, obj.SetModTime(context.Background(), expectedTime))
- startServer(t, f)
+ start(t, f)
}
// check body against the file, or re-write body if -updategolden is
@@ -229,7 +237,3 @@ func TestGET(t *testing.T) {
checkGolden(t, test.Golden, body)
}
}
-
-func TestFinalise(t *testing.T) {
- _ = httplib.Shutdown()
-}
diff --git a/lib/http/auth.go b/lib/http/auth.go
new file mode 100644
index 000000000..b7784c4b4
--- /dev/null
+++ b/lib/http/auth.go
@@ -0,0 +1,70 @@
+package http
+
+import (
+ "github.com/rclone/rclone/fs/config/flags"
+ "github.com/spf13/pflag"
+)
+
+// Help contains text describing the http authentication to add to the command
+// help.
+var AuthHelp = `
+#### Authentication
+
+By default this will serve files without needing a login.
+
+You can either use an htpasswd file which can take lots of users, or
+set a single username and password with the ` + "`--user` and `--pass`" + ` flags.
+
+Use ` + "`--htpasswd /path/to/htpasswd`" + ` to provide an htpasswd file. This is
+in standard apache format and supports MD5, SHA1 and BCrypt for basic
+authentication. Bcrypt is recommended.
+
+To create an htpasswd file:
+
+ touch htpasswd
+ htpasswd -B htpasswd user
+ htpasswd -B htpasswd anotherUser
+
+The password file can be updated while rclone is running.
+
+Use ` + "`--realm`" + ` to set the authentication realm.
+
+Use ` + "`--salt`" + ` to change the password hashing salt from the default.
+`
+
+// CustomAuthFn if used will be used to authenticate user, pass. If an error
+// is returned then the user is not authenticated.
+//
+// If a non nil value is returned then it is added to the context under the key
+type CustomAuthFn func(user, pass string) (value interface{}, err error)
+
+// AuthConfig contains options for the http authentication
+type AuthConfig struct {
+ HtPasswd string // htpasswd file - if not provided no authentication is done
+ Realm string // realm for authentication
+ BasicUser string // single username for basic auth if not using Htpasswd
+ BasicPass string // password for BasicUser
+ Salt string // password hashing salt
+ CustomAuthFn CustomAuthFn `json:"-"` // custom Auth (not set by command line flags)
+}
+
+// AddFlagsPrefix adds flags to the flag set for AuthConfig
+func (cfg *AuthConfig) AddFlagsPrefix(flagSet *pflag.FlagSet, prefix string) {
+ flags.StringVarP(flagSet, &cfg.HtPasswd, prefix+"htpasswd", "", cfg.HtPasswd, "A htpasswd file - if not provided no authentication is done")
+ flags.StringVarP(flagSet, &cfg.Realm, prefix+"realm", "", cfg.Realm, "Realm for authentication")
+ flags.StringVarP(flagSet, &cfg.BasicUser, prefix+"user", "", cfg.BasicUser, "User name for authentication")
+ flags.StringVarP(flagSet, &cfg.BasicPass, prefix+"pass", "", cfg.BasicPass, "Password for authentication")
+ flags.StringVarP(flagSet, &cfg.Salt, prefix+"salt", "", cfg.Salt, "Password hashing salt")
+}
+
+// AddAuthFlagsPrefix adds flags to the flag set for AuthConfig
+func AddAuthFlagsPrefix(flagSet *pflag.FlagSet, prefix string, cfg *AuthConfig) {
+ cfg.AddFlagsPrefix(flagSet, prefix)
+}
+
+// DefaultAuthCfg returns a new config which can be customized by command line flags
+func DefaultAuthCfg() AuthConfig {
+ return AuthConfig{
+ Salt: "dlPL2MqE",
+ }
+}
diff --git a/lib/http/auth/auth.go b/lib/http/auth/auth.go
deleted file mode 100644
index db50c8a30..000000000
--- a/lib/http/auth/auth.go
+++ /dev/null
@@ -1,84 +0,0 @@
-// Package auth provides authentication for http.
-package auth
-
-import (
- "github.com/rclone/rclone/fs/config/flags"
- "github.com/rclone/rclone/lib/http"
- "github.com/spf13/pflag"
-)
-
-// Help contains text describing the http authentication to add to the command
-// help.
-var Help = `
-#### Authentication
-
-By default this will serve files without needing a login.
-
-You can either use an htpasswd file which can take lots of users, or
-set a single username and password with the ` + "`--user` and `--pass`" + ` flags.
-
-Use ` + "`--htpasswd /path/to/htpasswd`" + ` to provide an htpasswd file. This is
-in standard apache format and supports MD5, SHA1 and BCrypt for basic
-authentication. Bcrypt is recommended.
-
-To create an htpasswd file:
-
- touch htpasswd
- htpasswd -B htpasswd user
- htpasswd -B htpasswd anotherUser
-
-The password file can be updated while rclone is running.
-
-Use ` + "`--realm`" + ` to set the authentication realm.
-
-Use ` + "`--salt`" + ` to change the password hashing salt from the default.
-`
-
-// CustomAuthFn if used will be used to authenticate user, pass. If an error
-// is returned then the user is not authenticated.
-//
-// If a non nil value is returned then it is added to the context under the key
-type CustomAuthFn func(user, pass string) (value interface{}, err error)
-
-// Options contains options for the http authentication
-type Options struct {
- HtPasswd string // htpasswd file - if not provided no authentication is done
- Realm string // realm for authentication
- BasicUser string // single username for basic auth if not using Htpasswd
- BasicPass string // password for BasicUser
- Salt string // password hashing salt
- Auth CustomAuthFn `json:"-"` // custom Auth (not set by command line flags)
-}
-
-// Auth instantiates middleware that authenticates users based on the configuration
-func Auth(opt Options) http.Middleware {
- if opt.Auth != nil {
- return CustomAuth(opt.Auth, opt.Realm)
- } else if opt.HtPasswd != "" {
- return HtPasswdAuth(opt.HtPasswd, opt.Realm)
- } else if opt.BasicUser != "" {
- return SingleAuth(opt.BasicUser, opt.BasicPass, opt.Realm, opt.Salt)
- }
- return nil
-}
-
-// Options set by command line flags
-var (
- Opt = Options{
- Salt: "dlPL2MqE",
- }
-)
-
-// AddFlagsPrefix adds flags for http/auth
-func AddFlagsPrefix(flagSet *pflag.FlagSet, prefix string, Opt *Options) {
- flags.StringVarP(flagSet, &Opt.HtPasswd, prefix+"htpasswd", "", Opt.HtPasswd, "A htpasswd file - if not provided no authentication is done")
- flags.StringVarP(flagSet, &Opt.Realm, prefix+"realm", "", Opt.Realm, "Realm for authentication")
- flags.StringVarP(flagSet, &Opt.BasicUser, prefix+"user", "", Opt.BasicUser, "User name for authentication")
- flags.StringVarP(flagSet, &Opt.BasicPass, prefix+"pass", "", Opt.BasicPass, "Password for authentication")
- flags.StringVarP(flagSet, &Opt.Salt, prefix+"salt", "", Opt.Salt, "Password hashing salt")
-}
-
-// AddFlags adds flags for the http/auth
-func AddFlags(flagSet *pflag.FlagSet) {
- AddFlagsPrefix(flagSet, "", &Opt)
-}
diff --git a/lib/http/auth/basic.go b/lib/http/auth/basic.go
deleted file mode 100644
index 69907c009..000000000
--- a/lib/http/auth/basic.go
+++ /dev/null
@@ -1,120 +0,0 @@
-package auth
-
-import (
- "context"
- "encoding/base64"
- "net/http"
- "strings"
-
- auth "github.com/abbot/go-http-auth"
- "github.com/rclone/rclone/fs"
- httplib "github.com/rclone/rclone/lib/http"
-)
-
-// parseAuthorization parses the Authorization header into user, pass
-// it returns a boolean as to whether the parse was successful
-func parseAuthorization(r *http.Request) (user, pass string, ok bool) {
- authHeader := r.Header.Get("Authorization")
- if authHeader != "" {
- s := strings.SplitN(authHeader, " ", 2)
- if len(s) == 2 && s[0] == "Basic" {
- b, err := base64.StdEncoding.DecodeString(s[1])
- if err == nil {
- parts := strings.SplitN(string(b), ":", 2)
- user = parts[0]
- if len(parts) > 1 {
- pass = parts[1]
- ok = true
- }
- }
- }
- }
- return
-}
-
-type contextUserType struct{}
-
-// ContextUserKey is a simple context key for storing the username of the request
-var ContextUserKey = &contextUserType{}
-
-type contextAuthType struct{}
-
-// ContextAuthKey is a simple context key for storing info returned by CustomAuthFn
-var ContextAuthKey = &contextAuthType{}
-
-// LoggedBasicAuth extends BasicAuth to include access logging
-type LoggedBasicAuth struct {
- auth.BasicAuth
-}
-
-// CheckAuth extends BasicAuth.CheckAuth to emit a log entry for unauthorised requests
-func (a *LoggedBasicAuth) CheckAuth(r *http.Request) string {
- username := a.BasicAuth.CheckAuth(r)
- if username == "" {
- user, _, _ := parseAuthorization(r)
- fs.Infof(r.URL.Path, "%s: Unauthorized request from %s", r.RemoteAddr, user)
- }
- return username
-}
-
-// NewLoggedBasicAuthenticator instantiates a new instance of LoggedBasicAuthenticator
-func NewLoggedBasicAuthenticator(realm string, secrets auth.SecretProvider) *LoggedBasicAuth {
- return &LoggedBasicAuth{BasicAuth: auth.BasicAuth{Realm: realm, Secrets: secrets}}
-}
-
-// Helper to generate required interface for middleware
-func basicAuth(authenticator *LoggedBasicAuth) httplib.Middleware {
- return func(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if username := authenticator.CheckAuth(r); username == "" {
- authenticator.RequireAuth(w, r)
- } else {
- r = r.WithContext(context.WithValue(r.Context(), ContextUserKey, username))
- next.ServeHTTP(w, r)
- }
- })
- }
-}
-
-// HtPasswdAuth instantiates middleware that authenticates against the passed htpasswd file
-func HtPasswdAuth(path, realm string) httplib.Middleware {
- fs.Infof(nil, "Using %q as htpasswd storage", path)
- secretProvider := auth.HtpasswdFileProvider(path)
- authenticator := NewLoggedBasicAuthenticator(realm, secretProvider)
- return basicAuth(authenticator)
-}
-
-// SingleAuth instantiates middleware that authenticates for a single user
-func SingleAuth(user, pass, realm, salt string) httplib.Middleware {
- fs.Infof(nil, "Using --user %s --pass XXXX as authenticated user", user)
- pass = string(auth.MD5Crypt([]byte(pass), []byte(salt), []byte("$1$")))
- secretProvider := func(u, r string) string {
- if user == u {
- return pass
- }
- return ""
- }
- authenticator := NewLoggedBasicAuthenticator(realm, secretProvider)
- return basicAuth(authenticator)
-}
-
-// CustomAuth instantiates middleware that authenticates using a custom function
-func CustomAuth(fn CustomAuthFn, realm string) httplib.Middleware {
- return func(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- user, pass, ok := parseAuthorization(r)
- if ok {
- value, err := fn(user, pass)
- if err != nil {
- fs.Infof(r.URL.Path, "%s: Auth failed from %s: %v", r.RemoteAddr, user, err)
- auth.NewBasicAuthenticator(realm, func(user, realm string) string { return "" }).RequireAuth(w, r) //Reuse BasicAuth error reporting
- return
- }
- if value != nil {
- r = r.WithContext(context.WithValue(r.Context(), ContextAuthKey, value))
- }
- next.ServeHTTP(w, r)
- }
- })
- }
-}
diff --git a/lib/http/context.go b/lib/http/context.go
new file mode 100644
index 000000000..34bff8fa6
--- /dev/null
+++ b/lib/http/context.go
@@ -0,0 +1,68 @@
+package http
+
+import (
+ "context"
+ "net"
+ "net/http"
+)
+
+type ctxKey int
+
+const (
+ ctxKeyAuth ctxKey = iota
+ ctxKeyPublicURL
+ ctxKeyUnixSock
+ ctxKeyUser
+)
+
+// NewBaseContext initializes the context for all requests, adding info for use in middleware and handlers
+func NewBaseContext(ctx context.Context, url string) func(l net.Listener) context.Context {
+ return func(l net.Listener) context.Context {
+ if l.Addr().Network() == "unix" {
+ ctx = context.WithValue(ctx, ctxKeyUnixSock, true)
+ return ctx
+ }
+
+ ctx = context.WithValue(ctx, ctxKeyPublicURL, url)
+ return ctx
+ }
+}
+
+// IsAuthenticated checks if this request was authenticated via a middleware
+func IsAuthenticated(r *http.Request) bool {
+ if v := r.Context().Value(ctxKeyAuth); v != nil {
+ return true
+ }
+ if v := r.Context().Value(ctxKeyUser); v != nil {
+ return true
+ }
+ return false
+}
+
+// IsUnixSocket checks if the request was received on a unix socket, used to skip auth & CORS
+func IsUnixSocket(r *http.Request) bool {
+ v, _ := r.Context().Value(ctxKeyUnixSock).(bool)
+ return v
+}
+
+// PublicURL returns the URL defined in NewBaseContext, used for logging & CORS
+func PublicURL(r *http.Request) string {
+ v, _ := r.Context().Value(ctxKeyPublicURL).(string)
+ return v
+}
+
+// CtxGetAuth is a wrapper over the private Auth context key
+func CtxGetAuth(ctx context.Context) interface{} {
+ return ctx.Value(ctxKeyAuth)
+}
+
+// CtxGetUser is a wrapper over the private User context key
+func CtxGetUser(ctx context.Context) (string, bool) {
+ v, ok := ctx.Value(ctxKeyUser).(string)
+ return v, ok
+}
+
+// CtxSetUser is a test helper that injects a User value into context
+func CtxSetUser(ctx context.Context, value string) context.Context {
+ return context.WithValue(ctx, ctxKeyUser, value)
+}
diff --git a/lib/http/http.go b/lib/http/http.go
deleted file mode 100644
index 0138b0fec..000000000
--- a/lib/http/http.go
+++ /dev/null
@@ -1,441 +0,0 @@
-// Package http provides a registration interface for http services
-package http
-
-import (
- "context"
- "crypto/tls"
- "crypto/x509"
- "errors"
- "fmt"
- "log"
- "net"
- "net/http"
- "os"
- "strings"
- "sync"
- "time"
-
- "github.com/go-chi/chi/v5"
- "github.com/rclone/rclone/fs/config/flags"
- "github.com/spf13/pflag"
-)
-
-// Help contains text describing the http server to add to the command
-// help.
-var Help = `
-### Server options
-
-Use ` + "`--addr`" + ` to specify which IP address and port the server should
-listen on, eg ` + "`--addr 1.2.3.4:8000` or `--addr :8080`" + ` to listen to all
-IPs. By default it only listens on localhost. You can use port
-:0 to let the OS choose an available port.
-
-If you set ` + "`--addr`" + ` to listen on a public or LAN accessible IP address
-then using Authentication is advised - see the next section for info.
-
-` + "`--server-read-timeout` and `--server-write-timeout`" + ` can be used to
-control the timeouts on the server. Note that this is the total time
-for a transfer.
-
-` + "`--max-header-bytes`" + ` controls the maximum number of bytes the server will
-accept in the HTTP header.
-
-` + "`--baseurl`" + ` controls the URL prefix that rclone serves from. By default
-rclone will serve from the root. If you used ` + "`--baseurl \"/rclone\"`" + ` then
-rclone would serve from a URL starting with "/rclone/". This is
-useful if you wish to proxy rclone serve. Rclone automatically
-inserts leading and trailing "/" on ` + "`--baseurl`" + `, so ` + "`--baseurl \"rclone\"`" + `,
-` + "`--baseurl \"/rclone\"` and `--baseurl \"/rclone/\"`" + ` are all treated
-identically.
-
-#### SSL/TLS
-
-By default this will serve over http. If you want you can serve over
-https. You will need to supply the ` + "`--cert` and `--key`" + ` flags.
-If you wish to do client side certificate validation then you will need to
-supply ` + "`--client-ca`" + ` also.
-
-` + "`--cert`" + ` should be a either a PEM encoded certificate or a concatenation
-of that with the CA certificate. ` + "`--key`" + ` should be the PEM encoded
-private key and ` + "`--client-ca`" + ` should be the PEM encoded client
-certificate authority certificate.
-
---min-tls-version is minimum TLS version that is acceptable. Valid
- values are "tls1.0", "tls1.1", "tls1.2" and "tls1.3" (default
- "tls1.0").
-`
-
-// Middleware function signature required by chi.Router.Use()
-type Middleware func(http.Handler) http.Handler
-
-// Options contains options for the http Server
-type Options struct {
- ListenAddr string // Port to listen on
- BaseURL string // prefix to strip from URLs
- ServerReadTimeout time.Duration // Timeout for server reading data
- ServerWriteTimeout time.Duration // Timeout for server writing data
- MaxHeaderBytes int // Maximum size of request header
- SslCert string // Path to SSL PEM key (concatenation of certificate and CA certificate)
- SslKey string // Path to SSL PEM Private key
- SslCertBody []byte // SSL PEM key (concatenation of certificate and CA certificate) body, ignores SslCert
- SslKeyBody []byte // SSL PEM Private key body, ignores SslKey
- ClientCA string // Client certificate authority to verify clients with
- MinTLSVersion string // MinTLSVersion contains the minimum TLS version that is acceptable.
-}
-
-// DefaultOpt is the default values used for Options
-var DefaultOpt = Options{
- ListenAddr: "127.0.0.1:8080",
- ServerReadTimeout: 1 * time.Hour,
- ServerWriteTimeout: 1 * time.Hour,
- MaxHeaderBytes: 4096,
- MinTLSVersion: "tls1.0",
-}
-
-// Server interface of http server
-type Server interface {
- Router() chi.Router
- Route(pattern string, fn func(r chi.Router)) chi.Router
- Mount(pattern string, h http.Handler)
- Shutdown() error
-}
-
-type server struct {
- addrs []net.Addr
- tlsAddrs []net.Addr
- listeners []net.Listener
- tlsListeners []net.Listener
- httpServer *http.Server
- baseRouter chi.Router
- closing *sync.WaitGroup
- useSSL bool
-}
-
-var (
- defaultServer *server
- defaultServerOptions = DefaultOpt
- defaultServerMutex sync.Mutex
-)
-
-func useSSL(opt Options) bool {
- return opt.SslKey != "" || len(opt.SslKeyBody) > 0
-}
-
-// NewServer instantiates a new http server using provided listeners and options
-// This function is provided if the default http server does not meet a services requirements and should not generally be used
-// A http server can listen using multiple listeners. For example, a listener for port 80, and a listener for port 443.
-// tlsListeners are ignored if opt.SslKey is not provided
-func NewServer(listeners, tlsListeners []net.Listener, opt Options) (Server, error) {
- // Validate input
- if len(listeners) == 0 && len(tlsListeners) == 0 {
- return nil, errors.New("can't create server without listeners")
- }
-
- // Prepare TLS config
- var tlsConfig *tls.Config
-
- useSSL := useSSL(opt)
- if (len(opt.SslCertBody) > 0) != (len(opt.SslKeyBody) > 0) {
- err := errors.New("need both SslCertBody and SslKeyBody to use SSL")
- log.Fatalf(err.Error())
- return nil, err
- }
- if (opt.SslCert != "") != (opt.SslKey != "") {
- err := errors.New("need both -cert and -key to use SSL")
- log.Fatalf(err.Error())
- return nil, err
- }
-
- if useSSL {
- var cert tls.Certificate
- var err error
- if len(opt.SslCertBody) > 0 {
- cert, err = tls.X509KeyPair(opt.SslCertBody, opt.SslKeyBody)
- } else {
- cert, err = tls.LoadX509KeyPair(opt.SslCert, opt.SslKey)
- }
- if err != nil {
- log.Fatal(err)
- }
- var minTLSVersion uint16
- switch opt.MinTLSVersion {
- case "tls1.0":
- minTLSVersion = tls.VersionTLS10
- case "tls1.1":
- minTLSVersion = tls.VersionTLS11
- case "tls1.2":
- minTLSVersion = tls.VersionTLS12
- case "tls1.3":
- minTLSVersion = tls.VersionTLS13
- default:
- err = errors.New("Invalid value for --min-tls-version")
- log.Fatalf(err.Error())
- return nil, err
- }
- tlsConfig = &tls.Config{
- MinVersion: minTLSVersion,
- Certificates: []tls.Certificate{cert},
- }
- } else if len(listeners) == 0 && len(tlsListeners) != 0 {
- return nil, errors.New("no SslKey or non-tlsListeners")
- }
-
- if opt.ClientCA != "" {
- if !useSSL {
- err := errors.New("can't use --client-ca without --cert and --key")
- log.Fatalf(err.Error())
- return nil, err
- }
- certpool := x509.NewCertPool()
- pem, err := os.ReadFile(opt.ClientCA)
- if err != nil {
- log.Fatalf("Failed to read client certificate authority: %v", err)
- return nil, err
- }
- if !certpool.AppendCertsFromPEM(pem) {
- err := errors.New("can't parse client certificate authority")
- log.Fatalf(err.Error())
- return nil, err
- }
- tlsConfig.ClientCAs = certpool
- tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert
- }
-
- // Ignore passing "/" for BaseURL
- opt.BaseURL = strings.Trim(opt.BaseURL, "/")
- if opt.BaseURL != "" {
- opt.BaseURL = "/" + opt.BaseURL
- }
-
- // Build base router
- var router chi.Router = chi.NewRouter()
- router.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) {
- http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
- })
- router.NotFound(func(w http.ResponseWriter, _ *http.Request) {
- http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
- })
-
- handler := router.(http.Handler)
- if opt.BaseURL != "" {
- handler = http.StripPrefix(opt.BaseURL, handler)
- }
-
- // Serve on listeners
- httpServer := &http.Server{
- Handler: handler,
- ReadTimeout: opt.ServerReadTimeout,
- WriteTimeout: opt.ServerWriteTimeout,
- MaxHeaderBytes: opt.MaxHeaderBytes,
- ReadHeaderTimeout: 10 * time.Second, // time to send the headers
- IdleTimeout: 60 * time.Second, // time to keep idle connections open
- TLSConfig: tlsConfig,
- }
-
- addrs, tlsAddrs := make([]net.Addr, len(listeners)), make([]net.Addr, len(tlsListeners))
-
- wg := &sync.WaitGroup{}
-
- for i, l := range listeners {
- addrs[i] = l.Addr()
- }
-
- if useSSL {
- for i, l := range tlsListeners {
- tlsAddrs[i] = l.Addr()
- }
- }
-
- return &server{addrs, tlsAddrs, listeners, tlsListeners, httpServer, router, wg, useSSL}, nil
-}
-
-func (s *server) Serve() {
- serve := func(l net.Listener, tls bool) {
- defer s.closing.Done()
- var err error
- if tls {
- err = s.httpServer.ServeTLS(l, "", "")
- } else {
- err = s.httpServer.Serve(l)
- }
- if err != http.ErrServerClosed && err != nil {
- log.Fatalf(err.Error())
- }
- }
-
- s.closing.Add(len(s.listeners))
- for _, l := range s.listeners {
- go serve(l, false)
- }
-
- if s.useSSL {
- s.closing.Add(len(s.tlsListeners))
- for _, l := range s.tlsListeners {
- go serve(l, true)
- }
- }
-}
-
-// Wait blocks while the server is serving requests
-func (s *server) Wait() {
- s.closing.Wait()
-}
-
-// Router returns the server base router
-func (s *server) Router() chi.Router {
- return s.baseRouter
-}
-
-// Route mounts a sub-Router along a `pattern` string.
-func (s *server) Route(pattern string, fn func(r chi.Router)) chi.Router {
- return s.baseRouter.Route(pattern, fn)
-}
-
-// Mount attaches another http.Handler along ./pattern/*
-func (s *server) Mount(pattern string, h http.Handler) {
- s.baseRouter.Mount(pattern, h)
-}
-
-// Shutdown gracefully shuts down the server
-func (s *server) Shutdown() error {
- if err := s.httpServer.Shutdown(context.Background()); err != nil {
- return err
- }
- s.closing.Wait()
- return nil
-}
-
-//---- Default HTTP server convenience functions ----
-
-// Router returns the server base router
-func Router() (chi.Router, error) {
- if err := start(); err != nil {
- return nil, err
- }
- return defaultServer.baseRouter, nil
-}
-
-// Route mounts a sub-Router along a `pattern` string.
-func Route(pattern string, fn func(r chi.Router)) (chi.Router, error) {
- if err := start(); err != nil {
- return nil, err
- }
- return defaultServer.Route(pattern, fn), nil
-}
-
-// Mount attaches another http.Handler along ./pattern/*
-func Mount(pattern string, h http.Handler) error {
- if err := start(); err != nil {
- return err
- }
- defaultServer.Mount(pattern, h)
- return nil
-}
-
-// Restart or start the default http server using the default options and no handlers
-func Restart() error {
- if e := Shutdown(); e != nil {
- return e
- }
-
- return start()
-}
-
-// Wait blocks while the default http server is serving requests
-func Wait() {
- defaultServer.Wait()
-}
-
-// Start the default server
-func start() error {
- defaultServerMutex.Lock()
- defer defaultServerMutex.Unlock()
-
- if defaultServer != nil {
- // Server already started, do nothing
- return nil
- }
-
- var err error
- var l net.Listener
- l, err = net.Listen("tcp", defaultServerOptions.ListenAddr)
- if err != nil {
- return err
- }
-
- var s Server
- if useSSL(defaultServerOptions) {
- s, err = NewServer([]net.Listener{}, []net.Listener{l}, defaultServerOptions)
- } else {
- s, err = NewServer([]net.Listener{l}, []net.Listener{}, defaultServerOptions)
- }
- if err != nil {
- return err
- }
- defaultServer = s.(*server)
- defaultServer.Serve()
- return nil
-}
-
-// Shutdown gracefully shuts down the default http server
-func Shutdown() error {
- defaultServerMutex.Lock()
- defer defaultServerMutex.Unlock()
- if defaultServer != nil {
- s := defaultServer
- defaultServer = nil
- return s.Shutdown()
- }
- return nil
-}
-
-// GetOptions thread safe getter for the default server options
-func GetOptions() Options {
- defaultServerMutex.Lock()
- defer defaultServerMutex.Unlock()
- return defaultServerOptions
-}
-
-// SetOptions thread safe setter for the default server options
-func SetOptions(opt Options) {
- defaultServerMutex.Lock()
- defer defaultServerMutex.Unlock()
- defaultServerOptions = opt
-}
-
-//---- Utility functions ----
-
-// URL of default http server
-func URL() string {
- if defaultServer == nil {
- panic("Server not running")
- }
- for _, a := range defaultServer.addrs {
- return fmt.Sprintf("http://%s%s/", a.String(), defaultServerOptions.BaseURL)
- }
- for _, a := range defaultServer.tlsAddrs {
- return fmt.Sprintf("https://%s%s/", a.String(), defaultServerOptions.BaseURL)
- }
- panic("Server is running with no listener")
-}
-
-//---- Command line flags ----
-
-// AddFlagsPrefix adds flags for the httplib
-func AddFlagsPrefix(flagSet *pflag.FlagSet, prefix string, Opt *Options) {
- flags.StringVarP(flagSet, &Opt.ListenAddr, prefix+"addr", "", Opt.ListenAddr, "IPaddress:Port or :Port to bind server to")
- flags.DurationVarP(flagSet, &Opt.ServerReadTimeout, prefix+"server-read-timeout", "", Opt.ServerReadTimeout, "Timeout for server reading data")
- flags.DurationVarP(flagSet, &Opt.ServerWriteTimeout, prefix+"server-write-timeout", "", Opt.ServerWriteTimeout, "Timeout for server writing data")
- flags.IntVarP(flagSet, &Opt.MaxHeaderBytes, prefix+"max-header-bytes", "", Opt.MaxHeaderBytes, "Maximum size of request header")
- flags.StringVarP(flagSet, &Opt.SslCert, prefix+"cert", "", Opt.SslCert, "SSL PEM key (concatenation of certificate and CA certificate)")
- flags.StringVarP(flagSet, &Opt.SslKey, prefix+"key", "", Opt.SslKey, "SSL PEM Private key")
- flags.StringVarP(flagSet, &Opt.ClientCA, prefix+"client-ca", "", Opt.ClientCA, "Client certificate authority to verify clients with")
- flags.StringVarP(flagSet, &Opt.BaseURL, prefix+"baseurl", "", Opt.BaseURL, "Prefix for URLs - leave blank for root")
- flags.StringVarP(flagSet, &Opt.MinTLSVersion, prefix+"min-tls-version", "", Opt.MinTLSVersion, "Minimum TLS version that is acceptable")
-
-}
-
-// AddFlags adds flags for the httplib
-func AddFlags(flagSet *pflag.FlagSet) {
- AddFlagsPrefix(flagSet, "", &defaultServerOptions)
-}
diff --git a/lib/http/http_test.go b/lib/http/http_test.go
deleted file mode 100644
index fd9850059..000000000
--- a/lib/http/http_test.go
+++ /dev/null
@@ -1,515 +0,0 @@
-package http
-
-import (
- "crypto/tls"
- "net"
- "net/http"
- "reflect"
- "strings"
- "testing"
- "time"
-
- "golang.org/x/net/nettest"
-
- "github.com/go-chi/chi/v5"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func TestGetOptions(t *testing.T) {
- tests := []struct {
- name string
- want Options
- }{
- {name: "basic", want: defaultServerOptions},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if got := GetOptions(); !reflect.DeepEqual(got, tt.want) {
- t.Errorf("GetOptions() = %v, want %v", got, tt.want)
- }
- })
- }
-}
-
-func TestMount(t *testing.T) {
- type args struct {
- pattern string
- h http.Handler
- }
- tests := []struct {
- name string
- args args
- wantErr bool
- }{
- {name: "basic", args: args{
- pattern: "/",
- h: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {}),
- }, wantErr: false},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if tt.wantErr {
- require.Error(t, Mount(tt.args.pattern, tt.args.h))
- } else {
- require.NoError(t, Mount(tt.args.pattern, tt.args.h))
- }
- assert.NotNil(t, defaultServer)
- assert.True(t, defaultServer.baseRouter.Match(chi.NewRouteContext(), "GET", tt.args.pattern), "Failed to match route after registering")
- })
- if err := Shutdown(); err != nil {
- t.Fatal(err)
- }
- }
-}
-
-func TestNewServer(t *testing.T) {
- type args struct {
- listeners []net.Listener
- tlsListeners []net.Listener
- opt Options
- }
- listener, err := nettest.NewLocalListener("tcp")
- if err != nil {
- t.Fatal(err)
- }
- tests := []struct {
- name string
- args args
- wantErr bool
- }{
- {name: "default http", args: args{
- listeners: []net.Listener{listener},
- tlsListeners: []net.Listener{},
- opt: defaultServerOptions,
- }, wantErr: false},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got, err := NewServer(tt.args.listeners, tt.args.tlsListeners, tt.args.opt)
- if (err != nil) != tt.wantErr {
- t.Errorf("NewServer() error = %v, wantErr %v", err, tt.wantErr)
- return
- }
- s, ok := got.(*server)
- require.True(t, ok, "NewServer returned unexpected type")
- if len(tt.args.listeners) > 0 {
- assert.Equal(t, listener.Addr(), s.addrs[0])
- } else {
- assert.Empty(t, s.addrs)
- }
- if len(tt.args.tlsListeners) > 0 {
- assert.Equal(t, listener.Addr(), s.tlsAddrs[0])
- } else {
- assert.Empty(t, s.tlsAddrs)
- }
- if tt.args.opt.BaseURL != "" {
- assert.NotSame(t, s.baseRouter, s.httpServer.Handler, "should have wrapped baseRouter")
- } else {
- assert.Same(t, s.baseRouter, s.httpServer.Handler, "should be baseRouter")
- }
- if useSSL(tt.args.opt) {
- assert.NotNil(t, s.httpServer.TLSConfig, "missing SSL config")
- } else {
- assert.Nil(t, s.httpServer.TLSConfig, "unexpectedly has SSL config")
- }
- })
- }
-}
-
-func TestRestart(t *testing.T) {
- tests := []struct {
- name string
- started bool
- wantErr bool
- }{
- {name: "started", started: true, wantErr: false},
- {name: "stopped", started: false, wantErr: false},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if tt.started {
- require.NoError(t, Restart()) // Call it twice basically
- } else {
- require.NoError(t, Shutdown())
- }
- current := defaultServer
- if err := Restart(); (err != nil) != tt.wantErr {
- t.Errorf("Restart() error = %v, wantErr %v", err, tt.wantErr)
- }
- assert.NotNil(t, defaultServer, "failed to start default server")
- assert.NotSame(t, current, defaultServer, "same server instance as before restart")
- })
- }
-}
-
-func TestRoute(t *testing.T) {
- type args struct {
- pattern string
- fn func(r chi.Router)
- }
- tests := []struct {
- name string
- args args
- test func(t *testing.T, r chi.Router)
- }{
- {
- name: "basic",
- args: args{
- pattern: "/basic",
- fn: func(r chi.Router) {},
- },
- test: func(t *testing.T, r chi.Router) {
- require.Len(t, r.Routes(), 1)
- assert.Equal(t, r.Routes()[0].Pattern, "/basic/*")
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- require.NoError(t, Restart())
- _, err := Route(tt.args.pattern, tt.args.fn)
- require.NoError(t, err)
- tt.test(t, defaultServer.baseRouter)
- })
-
- if err := Shutdown(); err != nil {
- t.Fatal(err)
- }
- }
-}
-
-func TestSetOptions(t *testing.T) {
- type args struct {
- opt Options
- }
- tests := []struct {
- name string
- args args
- }{
- {
- name: "basic",
- args: args{opt: Options{
- ListenAddr: "127.0.0.1:9999",
- BaseURL: "/basic",
- ServerReadTimeout: 1,
- ServerWriteTimeout: 1,
- MaxHeaderBytes: 1,
- }},
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- SetOptions(tt.args.opt)
- require.Equal(t, tt.args.opt, defaultServerOptions)
- require.NoError(t, Restart())
- if useSSL(tt.args.opt) {
- assert.Equal(t, tt.args.opt.ListenAddr, defaultServer.tlsAddrs[0].String())
- } else {
- assert.Equal(t, tt.args.opt.ListenAddr, defaultServer.addrs[0].String())
- }
- assert.Equal(t, tt.args.opt.ServerReadTimeout, defaultServer.httpServer.ReadTimeout)
- assert.Equal(t, tt.args.opt.ServerWriteTimeout, defaultServer.httpServer.WriteTimeout)
- assert.Equal(t, tt.args.opt.MaxHeaderBytes, defaultServer.httpServer.MaxHeaderBytes)
- if tt.args.opt.BaseURL != "" && tt.args.opt.BaseURL != "/" {
- assert.NotSame(t, defaultServer.httpServer.Handler, defaultServer.baseRouter, "BaseURL ignored")
- }
- })
- SetOptions(DefaultOpt)
- }
-}
-
-func TestShutdown(t *testing.T) {
- tests := []struct {
- name string
- started bool
- wantErr bool
- }{
- {name: "started", started: true, wantErr: false},
- {name: "stopped", started: false, wantErr: false},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if tt.started {
- require.NoError(t, Restart())
- } else {
- require.NoError(t, Shutdown()) // Call it twice basically
- }
- if err := Shutdown(); (err != nil) != tt.wantErr {
- t.Errorf("Shutdown() error = %v, wantErr %v", err, tt.wantErr)
- }
- assert.Nil(t, defaultServer, "default server not deleted")
- })
- }
-}
-
-func TestURL(t *testing.T) {
- tests := []struct {
- name string
- want string
- }{
- {name: "basic", want: "http://127.0.0.1:8080/"},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- require.NoError(t, Restart())
- if got := URL(); got != tt.want {
- t.Errorf("URL() = %v, want %v", got, tt.want)
- }
- })
- }
-}
-
-func Test_server_Mount(t *testing.T) {
- type args struct {
- pattern string
- h http.Handler
- }
- tests := []struct {
- name string
- args args
- opt Options
- }{
- {name: "basic", args: args{
- pattern: "/",
- h: http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {}),
- }, opt: defaultServerOptions},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- listener, err := nettest.NewLocalListener("tcp")
- require.NoError(t, err)
- s, err2 := NewServer([]net.Listener{listener}, []net.Listener{}, tt.opt)
- require.NoError(t, err2)
- s.Mount(tt.args.pattern, tt.args.h)
- srv, ok := s.(*server)
- require.True(t, ok)
- assert.NotNil(t, srv)
- assert.True(t, srv.baseRouter.Match(chi.NewRouteContext(), "GET", tt.args.pattern), "Failed to Match() route after registering")
- })
- }
-}
-
-func Test_server_Route(t *testing.T) {
- type args struct {
- pattern string
- fn func(r chi.Router)
- }
- tests := []struct {
- name string
- args args
- opt Options
- test func(t *testing.T, r chi.Router)
- }{
- {
- name: "basic",
- args: args{
- pattern: "/basic",
- fn: func(r chi.Router) {
-
- },
- },
- test: func(t *testing.T, r chi.Router) {
- require.Len(t, r.Routes(), 1)
- assert.Equal(t, r.Routes()[0].Pattern, "/basic/*")
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- listener, err := nettest.NewLocalListener("tcp")
- require.NoError(t, err)
- s, err2 := NewServer([]net.Listener{listener}, []net.Listener{}, tt.opt)
- require.NoError(t, err2)
- s.Route(tt.args.pattern, tt.args.fn)
- srv, ok := s.(*server)
- require.True(t, ok)
- assert.NotNil(t, srv)
- tt.test(t, srv.baseRouter)
- })
- }
-}
-
-func Test_server_Shutdown(t *testing.T) {
- tests := []struct {
- name string
- opt Options
- wantErr bool
- }{
- {
- name: "basic",
- opt: defaultServerOptions,
- wantErr: false,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- listener, err := nettest.NewLocalListener("tcp")
- require.NoError(t, err)
- s, err2 := NewServer([]net.Listener{listener}, []net.Listener{}, tt.opt)
- require.NoError(t, err2)
- srv, ok := s.(*server)
- require.True(t, ok)
- if err := s.Shutdown(); (err != nil) != tt.wantErr {
- t.Errorf("Shutdown() error = %v, wantErr %v", err, tt.wantErr)
- }
- assert.EqualError(t, srv.httpServer.Serve(listener), http.ErrServerClosed.Error())
- })
- }
-}
-
-func Test_start(t *testing.T) {
- http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
- sslServerOptions := defaultServerOptions
- sslServerOptions.SslCertBody = []byte(`-----BEGIN CERTIFICATE-----
-MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw
-DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow
-EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d
-7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B
-5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr
-BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1
-NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l
-Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc
-6MF9+Yw1Yy0t
------END CERTIFICATE-----`)
- sslServerOptions.SslKeyBody = []byte(`-----BEGIN EC PRIVATE KEY-----
-MHcCAQEEIIrYSSNQFaA2Hwf1duRSxKtLYX5CB04fSeQ6tF1aY/PuoAoGCCqGSM49
-AwEHoUQDQgAEPR3tU2Fta9ktY+6P9G0cWO+0kETA6SFs38GecTyudlHz6xvCdz8q
-EKTcWGekdmdDPsHloRNtsiCa697B2O9IFA==
------END EC PRIVATE KEY-----`)
- tests := []struct {
- name string
- opt Options
- ssl bool
- wantErr bool
- }{
- {
- name: "basic",
- opt: defaultServerOptions,
- wantErr: false,
- },
- {
- name: "ssl",
- opt: sslServerOptions,
- ssl: true,
- wantErr: false,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- defer func() {
- err := Shutdown()
- if err != nil {
- t.Fatal("couldn't shutdown server")
- }
- }()
- SetOptions(tt.opt)
- if err := start(); (err != nil) != tt.wantErr {
- t.Errorf("start() error = %v, wantErr %v", err, tt.wantErr)
- return
- }
- s := defaultServer
- router := s.Router()
- router.Head("/", func(writer http.ResponseWriter, request *http.Request) {
- writer.WriteHeader(201)
- })
- testURL := URL()
- if tt.ssl {
- assert.True(t, useSSL(tt.opt))
- assert.Equal(t, tt.opt.ListenAddr, s.tlsAddrs[0].String())
- assert.True(t, strings.HasPrefix(testURL, "https://"))
- } else {
- assert.True(t, strings.HasPrefix(testURL, "http://"))
- assert.Equal(t, tt.opt.ListenAddr, s.addrs[0].String())
- }
-
- // try to connect to the test server
- pause := time.Millisecond
- for i := 0; i < 10; i++ {
- resp, err := http.Head(testURL)
- if err == nil {
- _ = resp.Body.Close()
- return
- }
- // t.Logf("couldn't connect, sleeping for %v: %v", pause, err)
- time.Sleep(pause)
- pause *= 2
- }
- t.Fatal("couldn't connect to server")
-
- /* accessing s.httpServer.* can't be done synchronously and is a race condition
- assert.Equal(t, tt.opt.ServerReadTimeout, defaultServer.httpServer.ReadTimeout)
- assert.Equal(t, tt.opt.ServerWriteTimeout, defaultServer.httpServer.WriteTimeout)
- assert.Equal(t, tt.opt.MaxHeaderBytes, defaultServer.httpServer.MaxHeaderBytes)
- if tt.opt.BaseURL != "" && tt.opt.BaseURL != "/" {
- assert.NotSame(t, s.baseRouter, s.httpServer.Handler, "should have wrapped baseRouter")
- } else {
- assert.Same(t, s.baseRouter, s.httpServer.Handler, "should be baseRouter")
- }
- if useSSL(tt.opt) {
- require.NotNil(t, s.httpServer.TLSConfig, "missing SSL config")
- assert.NotEmpty(t, s.httpServer.TLSConfig.Certificates, "missing SSL config")
- } else if s.httpServer.TLSConfig != nil {
- assert.Empty(t, s.httpServer.TLSConfig.Certificates, "unexpectedly has SSL config")
- }
- */
- })
- }
-}
-
-func Test_useSSL(t *testing.T) {
- type args struct {
- opt Options
- }
- tests := []struct {
- name string
- args args
- want bool
- }{
- {
- name: "basic",
- args: args{opt: Options{
- SslCert: "",
- SslKey: "",
- ClientCA: "",
- }},
- want: false,
- },
- {
- name: "basic",
- args: args{opt: Options{
- SslCert: "",
- SslKey: "test",
- ClientCA: "",
- }},
- want: true,
- },
- {
- name: "body",
- args: args{opt: Options{
- SslCert: "",
- SslKey: "",
- SslKeyBody: []byte(`test`),
- ClientCA: "",
- }},
- want: true,
- },
- {
- name: "basic",
- args: args{opt: Options{
- SslCert: "",
- SslKey: "test",
- ClientCA: "",
- MinTLSVersion: "tls1.2",
- }},
- want: true,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if got := useSSL(tt.args.opt); got != tt.want {
- t.Errorf("useSSL() = %v, want %v", got, tt.want)
- }
- })
- }
-}
diff --git a/lib/http/middleware.go b/lib/http/middleware.go
new file mode 100644
index 000000000..d19d75259
--- /dev/null
+++ b/lib/http/middleware.go
@@ -0,0 +1,171 @@
+package http
+
+import (
+ "context"
+ "encoding/base64"
+ "fmt"
+ "net/http"
+ "strings"
+ "sync"
+
+ goauth "github.com/abbot/go-http-auth"
+ "github.com/rclone/rclone/fs"
+)
+
+// parseAuthorization parses the Authorization header into user, pass
+// it returns a boolean as to whether the parse was successful
+func parseAuthorization(r *http.Request) (user, pass string, ok bool) {
+ authHeader := r.Header.Get("Authorization")
+ if authHeader != "" {
+ s := strings.SplitN(authHeader, " ", 2)
+ if len(s) == 2 && s[0] == "Basic" {
+ b, err := base64.StdEncoding.DecodeString(s[1])
+ if err == nil {
+ parts := strings.SplitN(string(b), ":", 2)
+ user = parts[0]
+ if len(parts) > 1 {
+ pass = parts[1]
+ ok = true
+ }
+ }
+ }
+ }
+ return
+}
+
+type LoggedBasicAuth struct {
+ goauth.BasicAuth
+}
+
+// CheckAuth extends BasicAuth.CheckAuth to emit a log entry for unauthorised requests
+func (a *LoggedBasicAuth) CheckAuth(r *http.Request) string {
+ username := a.BasicAuth.CheckAuth(r)
+ if username == "" {
+ user, _, _ := parseAuthorization(r)
+ fs.Infof(r.URL.Path, "%s: Unauthorized request from %s", r.RemoteAddr, user)
+ }
+ return username
+}
+
+// NewLoggedBasicAuthenticator instantiates a new instance of LoggedBasicAuthenticator
+func NewLoggedBasicAuthenticator(realm string, secrets goauth.SecretProvider) *LoggedBasicAuth {
+ return &LoggedBasicAuth{BasicAuth: goauth.BasicAuth{Realm: realm, Secrets: secrets}}
+}
+
+// Helper to generate required interface for middleware
+func basicAuth(authenticator *LoggedBasicAuth) func(next http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // skip auth for unix socket
+ if IsUnixSocket(r) {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ username := authenticator.CheckAuth(r)
+ if username == "" {
+ authenticator.RequireAuth(w, r)
+ return
+ }
+ ctx := context.WithValue(r.Context(), ctxKeyUser, username)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+ }
+}
+
+// MiddlewareAuthHtpasswd instantiates middleware that authenticates against the passed htpasswd file
+func MiddlewareAuthHtpasswd(path, realm string) Middleware {
+ fs.Infof(nil, "Using %q as htpasswd storage", path)
+ secretProvider := goauth.HtpasswdFileProvider(path)
+ authenticator := NewLoggedBasicAuthenticator(realm, secretProvider)
+ return basicAuth(authenticator)
+}
+
+// MiddlewareAuthBasic instantiates middleware that authenticates for a single user
+func MiddlewareAuthBasic(user, pass, realm, salt string) Middleware {
+ fs.Infof(nil, "Using --user %s --pass XXXX as authenticated user", user)
+ pass = string(goauth.MD5Crypt([]byte(pass), []byte(salt), []byte("$1$")))
+ secretProvider := func(u, r string) string {
+ if user == u {
+ return pass
+ }
+ return ""
+ }
+ authenticator := NewLoggedBasicAuthenticator(realm, secretProvider)
+ return basicAuth(authenticator)
+}
+
+// MiddlewareAuthCustom instantiates middleware that authenticates using a custom function
+func MiddlewareAuthCustom(fn CustomAuthFn, realm string) Middleware {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // skip auth for unix socket
+ if IsUnixSocket(r) {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ user, pass, ok := parseAuthorization(r)
+ if !ok {
+ code := http.StatusUnauthorized
+ w.Header().Set("Content-Type", "text/plain")
+ w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q, charset="UTF-8"`, realm))
+ http.Error(w, http.StatusText(code), code)
+ return
+ }
+
+ value, err := fn(user, pass)
+ if err != nil {
+ fs.Infof(r.URL.Path, "%s: Auth failed from %s: %v", r.RemoteAddr, user, err)
+ goauth.NewBasicAuthenticator(realm, func(user, realm string) string { return "" }).RequireAuth(w, r) //Reuse BasicAuth error reporting
+ return
+ }
+
+ if value != nil {
+ r = r.WithContext(context.WithValue(r.Context(), ctxKeyAuth, value))
+ }
+
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+var onlyOnceWarningAllowOrigin sync.Once
+
+// MiddlewareCORS instantiates middleware that handles basic CORS protections for rcd
+func MiddlewareCORS(allowOrigin string) Middleware {
+ onlyOnceWarningAllowOrigin.Do(func() {
+ if allowOrigin == "*" {
+ fs.Logf(nil, "Warning: Allow origin set to *. This can cause serious security problems.")
+ }
+ })
+
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // skip cors for unix sockets
+ if IsUnixSocket(r) {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ if allowOrigin != "" {
+ w.Header().Add("Access-Control-Allow-Origin", allowOrigin)
+ } else {
+ w.Header().Add("Access-Control-Allow-Origin", PublicURL(r))
+ }
+
+ // echo back access control headers client needs
+ w.Header().Add("Access-Control-Request-Method", "POST, OPTIONS, GET, HEAD")
+ w.Header().Add("Access-Control-Allow-Headers", "authorization, Content-Type")
+
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+// MiddlewareStripPrefix instantiates middleware that removes the BaseURL from the path
+func MiddlewareStripPrefix(prefix string) Middleware {
+ return func(next http.Handler) http.Handler {
+ return http.StripPrefix(prefix, next)
+ }
+}
diff --git a/lib/http/middleware_test.go b/lib/http/middleware_test.go
new file mode 100644
index 000000000..4839b87e3
--- /dev/null
+++ b/lib/http/middleware_test.go
@@ -0,0 +1,231 @@
+package http
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestMiddlewareAuth(t *testing.T) {
+ servers := []struct {
+ name string
+ http HTTPConfig
+ auth AuthConfig
+ user string
+ pass string
+ }{
+ {
+ name: "Basic",
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ },
+ auth: AuthConfig{
+ Realm: "test",
+ BasicUser: "test",
+ BasicPass: "test",
+ },
+ user: "test",
+ pass: "test",
+ },
+ {
+ name: "Htpasswd/MD5",
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ },
+ auth: AuthConfig{
+ Realm: "test",
+ HtPasswd: "./testdata/.htpasswd",
+ },
+ user: "md5",
+ pass: "md5",
+ },
+ {
+ name: "Htpasswd/SHA",
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ },
+ auth: AuthConfig{
+ Realm: "test",
+ HtPasswd: "./testdata/.htpasswd",
+ },
+ user: "sha",
+ pass: "sha",
+ },
+ {
+ name: "Htpasswd/Bcrypt",
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ },
+ auth: AuthConfig{
+ Realm: "test",
+ HtPasswd: "./testdata/.htpasswd",
+ },
+ user: "bcrypt",
+ pass: "bcrypt",
+ },
+ {
+ name: "Custom",
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ },
+ auth: AuthConfig{
+ Realm: "test",
+ CustomAuthFn: func(user, pass string) (value interface{}, err error) {
+ if user == "custom" && pass == "custom" {
+ return true, nil
+ }
+ return nil, errors.New("invalid credentials")
+ },
+ },
+ user: "custom",
+ pass: "custom",
+ },
+ }
+
+ for _, ss := range servers {
+ t.Run(ss.name, func(t *testing.T) {
+ s, err := NewServer(context.Background(), WithConfig(ss.http), WithAuth(ss.auth))
+ require.NoError(t, err)
+ defer func() {
+ require.NoError(t, s.Shutdown())
+ }()
+
+ expected := []byte("secret-page")
+ s.Router().Mount("/", testEchoHandler(expected))
+ s.Serve()
+
+ url := testGetServerURL(t, s)
+
+ t.Run("NoCreds", func(t *testing.T) {
+ client := &http.Client{}
+ req, err := http.NewRequest("GET", url, nil)
+ require.NoError(t, err)
+
+ resp, err := client.Do(req)
+ require.NoError(t, err)
+ defer func() {
+ _ = resp.Body.Close()
+ }()
+
+ require.Equal(t, http.StatusUnauthorized, resp.StatusCode, "using no creds should return unauthorized")
+
+ wwwAuthHeader := resp.Header.Get("WWW-Authenticate")
+ require.NotEmpty(t, wwwAuthHeader, "resp should contain WWW-Authtentication header")
+ require.Contains(t, wwwAuthHeader, fmt.Sprintf("realm=%q", ss.auth.Realm), "WWW-Authtentication header should contain relam")
+ })
+
+ t.Run("BadCreds", func(t *testing.T) {
+ client := &http.Client{}
+ req, err := http.NewRequest("GET", url, nil)
+ require.NoError(t, err)
+
+ req.SetBasicAuth(ss.user+"BAD", ss.pass+"BAD")
+
+ resp, err := client.Do(req)
+ require.NoError(t, err)
+ defer func() {
+ _ = resp.Body.Close()
+ }()
+
+ require.Equal(t, http.StatusUnauthorized, resp.StatusCode, "using bad creds should return unauthorized")
+
+ wwwAuthHeader := resp.Header.Get("WWW-Authenticate")
+ require.NotEmpty(t, wwwAuthHeader, "resp should contain WWW-Authtentication header")
+ require.Contains(t, wwwAuthHeader, fmt.Sprintf("realm=%q", ss.auth.Realm), "WWW-Authtentication header should contain relam")
+ })
+
+ t.Run("GoodCreds", func(t *testing.T) {
+ client := &http.Client{}
+ req, err := http.NewRequest("GET", url, nil)
+ require.NoError(t, err)
+
+ req.SetBasicAuth(ss.user, ss.pass)
+
+ resp, err := client.Do(req)
+ require.NoError(t, err)
+ defer func() {
+ _ = resp.Body.Close()
+ }()
+
+ require.Equal(t, http.StatusOK, resp.StatusCode, "using good creds should return ok")
+
+ testExpectRespBody(t, resp, expected)
+ })
+ })
+ }
+}
+
+var _testCORSHeaderKeys = []string{
+ "Access-Control-Allow-Origin",
+ "Access-Control-Request-Method",
+ "Access-Control-Allow-Headers",
+}
+
+func TestMiddlewareCORS(t *testing.T) {
+ servers := []struct {
+ name string
+ http HTTPConfig
+ origin string
+ }{
+ {
+ name: "EmptyOrigin",
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ },
+ origin: "",
+ },
+ {
+ name: "CustomOrigin",
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ },
+ origin: "http://test.rclone.org",
+ },
+ }
+
+ for _, ss := range servers {
+ t.Run(ss.name, func(t *testing.T) {
+ s, err := NewServer(context.Background(), WithConfig(ss.http))
+ require.NoError(t, err)
+ defer func() {
+ require.NoError(t, s.Shutdown())
+ }()
+
+ s.Router().Use(MiddlewareCORS(ss.origin))
+
+ expected := []byte("data")
+ s.Router().Mount("/", testEchoHandler(expected))
+ s.Serve()
+
+ url := testGetServerURL(t, s)
+
+ client := &http.Client{}
+ req, err := http.NewRequest("GET", url, nil)
+ require.NoError(t, err)
+
+ resp, err := client.Do(req)
+ require.NoError(t, err)
+ defer func() {
+ _ = resp.Body.Close()
+ }()
+
+ require.Equal(t, http.StatusOK, resp.StatusCode, "should return ok")
+
+ testExpectRespBody(t, resp, expected)
+
+ for _, key := range _testCORSHeaderKeys {
+ require.Contains(t, resp.Header, key, "CORS headers should be sent")
+ }
+
+ expectedOrigin := url
+ if ss.origin != "" {
+ expectedOrigin = ss.origin
+ }
+ require.Equal(t, expectedOrigin, resp.Header.Get("Access-Control-Allow-Origin"), "allow origin should match")
+ })
+ }
+}
diff --git a/lib/http/serve/dir_test.go b/lib/http/serve/dir_test.go
index be128ae33..bdcf3f948 100644
--- a/lib/http/serve/dir_test.go
+++ b/lib/http/serve/dir_test.go
@@ -3,7 +3,7 @@ package serve
import (
"errors"
"html/template"
- "io"
+ "io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
@@ -94,7 +94,7 @@ func TestError(t *testing.T) {
Error("potato", w, "sausage", err)
resp := w.Result()
assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
- body, _ := io.ReadAll(resp.Body)
+ body, _ := ioutil.ReadAll(resp.Body)
assert.Equal(t, "sausage.\n", string(body))
}
@@ -108,7 +108,7 @@ func TestServe(t *testing.T) {
d.Serve(w, r)
resp := w.Result()
assert.Equal(t, http.StatusOK, resp.StatusCode)
- body, _ := io.ReadAll(resp.Body)
+ body, _ := ioutil.ReadAll(resp.Body)
assert.Equal(t, `
diff --git a/lib/http/serve/serve_test.go b/lib/http/serve/serve_test.go
index b6cc70976..934fabe65 100644
--- a/lib/http/serve/serve_test.go
+++ b/lib/http/serve/serve_test.go
@@ -1,7 +1,7 @@
package serve
import (
- "io"
+ "io/ioutil"
"net/http"
"net/http/httptest"
"testing"
@@ -17,7 +17,7 @@ func TestObjectBadMethod(t *testing.T) {
Object(w, r, o)
resp := w.Result()
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
- body, _ := io.ReadAll(resp.Body)
+ body, _ := ioutil.ReadAll(resp.Body)
assert.Equal(t, "Method Not Allowed\n", string(body))
}
@@ -30,7 +30,7 @@ func TestObjectHEAD(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "5", resp.Header.Get("Content-Length"))
assert.Equal(t, "bytes", resp.Header.Get("Accept-Ranges"))
- body, _ := io.ReadAll(resp.Body)
+ body, _ := ioutil.ReadAll(resp.Body)
assert.Equal(t, "", string(body))
}
@@ -43,7 +43,7 @@ func TestObjectGET(t *testing.T) {
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "5", resp.Header.Get("Content-Length"))
assert.Equal(t, "bytes", resp.Header.Get("Accept-Ranges"))
- body, _ := io.ReadAll(resp.Body)
+ body, _ := ioutil.ReadAll(resp.Body)
assert.Equal(t, "hello", string(body))
}
@@ -58,7 +58,7 @@ func TestObjectRange(t *testing.T) {
assert.Equal(t, "3", resp.Header.Get("Content-Length"))
assert.Equal(t, "bytes", resp.Header.Get("Accept-Ranges"))
assert.Equal(t, "bytes 3-5/10", resp.Header.Get("Content-Range"))
- body, _ := io.ReadAll(resp.Body)
+ body, _ := ioutil.ReadAll(resp.Body)
assert.Equal(t, "345", string(body))
}
@@ -71,6 +71,6 @@ func TestObjectBadRange(t *testing.T) {
resp := w.Result()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
assert.Equal(t, "10", resp.Header.Get("Content-Length"))
- body, _ := io.ReadAll(resp.Body)
+ body, _ := ioutil.ReadAll(resp.Body)
assert.Equal(t, "Bad Request\n", string(body))
}
diff --git a/lib/http/server.go b/lib/http/server.go
new file mode 100644
index 000000000..d4e5e827b
--- /dev/null
+++ b/lib/http/server.go
@@ -0,0 +1,430 @@
+// Package http provides a registration interface for http services
+package http
+
+import (
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "errors"
+ "fmt"
+ "html/template"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/rclone/rclone/fs/config/flags"
+ "github.com/spf13/pflag"
+)
+
+// Help contains text describing the http server to add to the command
+// help.
+var Help = `
+### Server options
+
+Use ` + "`--addr`" + ` to specify which IP address and port the server should
+listen on, eg ` + "`--addr 1.2.3.4:8000` or `--addr :8080`" + ` to listen to all
+IPs. By default it only listens on localhost. You can use port
+:0 to let the OS choose an available port.
+
+If you set ` + "`--addr`" + ` to listen on a public or LAN accessible IP address
+then using Authentication is advised - see the next section for info.
+
+You can use a unix socket by setting the url to ` + "`unix:///path/to/socket`" + `
+or just by using an absolute path name. Note that unix sockets bypass the
+authentication - this is expected to be done with file system permissions.
+
+` + "`--addr`" + ` may be repeated to listen on multiple IPs/ports/sockets.
+
+` + "`--server-read-timeout` and `--server-write-timeout`" + ` can be used to
+control the timeouts on the server. Note that this is the total time
+for a transfer.
+
+` + "`--max-header-bytes`" + ` controls the maximum number of bytes the server will
+accept in the HTTP header.
+
+` + "`--baseurl`" + ` controls the URL prefix that rclone serves from. By default
+rclone will serve from the root. If you used ` + "`--baseurl \"/rclone\"`" + ` then
+rclone would serve from a URL starting with "/rclone/". This is
+useful if you wish to proxy rclone serve. Rclone automatically
+inserts leading and trailing "/" on ` + "`--baseurl`" + `, so ` + "`--baseurl \"rclone\"`" + `,
+` + "`--baseurl \"/rclone\"` and `--baseurl \"/rclone/\"`" + ` are all treated
+identically.
+
+#### TLS (SSL)
+
+By default this will serve over http. If you want you can serve over
+https. You will need to supply the ` + "`--cert` and `--key`" + ` flags.
+If you wish to do client side certificate validation then you will need to
+supply ` + "`--client-ca`" + ` also.
+
+` + "`--cert`" + ` should be a either a PEM encoded certificate or a concatenation
+of that with the CA certificate. ` + "`--key`" + ` should be the PEM encoded
+private key and ` + "`--client-ca`" + ` should be the PEM encoded client
+certificate authority certificate.
+
+--min-tls-version is minimum TLS version that is acceptable. Valid
+ values are "tls1.0", "tls1.1", "tls1.2" and "tls1.3" (default
+ "tls1.0").
+`
+
+// Middleware function signature required by chi.Router.Use()
+type Middleware func(http.Handler) http.Handler
+
+// HTTPConfig contains options for the http Server
+type HTTPConfig struct {
+ ListenAddr []string // Port to listen on
+ BaseURL string // prefix to strip from URLs
+ ServerReadTimeout time.Duration // Timeout for server reading data
+ ServerWriteTimeout time.Duration // Timeout for server writing data
+ MaxHeaderBytes int // Maximum size of request header
+ TLSCert string // Path to TLS PEM key (concatenation of certificate and CA certificate)
+ TLSKey string // Path to TLS PEM Private key
+ TLSCertBody []byte // TLS PEM key (concatenation of certificate and CA certificate) body, ignores TLSCert
+ TLSKeyBody []byte // TLS PEM Private key body, ignores TLSKey
+ ClientCA string // Client certificate authority to verify clients with
+ MinTLSVersion string // MinTLSVersion contains the minimum TLS version that is acceptable.
+ Template string
+}
+
+// AddFlagsPrefix adds flags for the httplib
+func (cfg *HTTPConfig) AddFlagsPrefix(flagSet *pflag.FlagSet, prefix string) {
+ flags.StringArrayVarP(flagSet, &cfg.ListenAddr, prefix+"addr", "", cfg.ListenAddr, "IPaddress:Port or :Port to bind server to")
+ flags.DurationVarP(flagSet, &cfg.ServerReadTimeout, prefix+"server-read-timeout", "", cfg.ServerReadTimeout, "Timeout for server reading data")
+ flags.DurationVarP(flagSet, &cfg.ServerWriteTimeout, prefix+"server-write-timeout", "", cfg.ServerWriteTimeout, "Timeout for server writing data")
+ flags.IntVarP(flagSet, &cfg.MaxHeaderBytes, prefix+"max-header-bytes", "", cfg.MaxHeaderBytes, "Maximum size of request header")
+ flags.StringVarP(flagSet, &cfg.TLSCert, prefix+"cert", "", cfg.TLSCert, "TLS PEM key (concatenation of certificate and CA certificate)")
+ flags.StringVarP(flagSet, &cfg.TLSKey, prefix+"key", "", cfg.TLSKey, "TLS PEM Private key")
+ flags.StringVarP(flagSet, &cfg.ClientCA, prefix+"client-ca", "", cfg.ClientCA, "Client certificate authority to verify clients with")
+ flags.StringVarP(flagSet, &cfg.BaseURL, prefix+"baseurl", "", cfg.BaseURL, "Prefix for URLs - leave blank for root")
+ flags.StringVarP(flagSet, &cfg.MinTLSVersion, prefix+"min-tls-version", "", cfg.MinTLSVersion, "Minimum TLS version that is acceptable")
+}
+
+// AddHTTPFlagsPrefix adds flags for the httplib
+func AddHTTPFlagsPrefix(flagSet *pflag.FlagSet, prefix string, cfg *HTTPConfig) {
+ cfg.AddFlagsPrefix(flagSet, prefix)
+}
+
+// DefaultHTTPCfg is the default values used for Config
+func DefaultHTTPCfg() HTTPConfig {
+ return HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:8080"},
+ ServerReadTimeout: 1 * time.Hour,
+ ServerWriteTimeout: 1 * time.Hour,
+ MaxHeaderBytes: 4096,
+ MinTLSVersion: "tls1.0",
+ }
+}
+
+// Server interface of http server
+type Server interface {
+ Router() chi.Router
+ Serve()
+ Shutdown() error
+ HTMLTemplate() *template.Template
+ URLs() []string
+ Wait()
+}
+
+type instance struct {
+ url string
+ listener net.Listener
+ httpServer *http.Server
+}
+
+func (s instance) serve(wg *sync.WaitGroup) {
+ defer wg.Done()
+ err := s.httpServer.Serve(s.listener)
+ if err != http.ErrServerClosed && err != nil {
+ log.Printf("%s: unexpected error: %s", s.listener.Addr(), err.Error())
+ }
+}
+
+type server struct {
+ wg sync.WaitGroup
+ mux chi.Router
+ tlsConfig *tls.Config
+ instances []instance
+ auth AuthConfig
+ cfg HTTPConfig
+ template *TemplateConfig
+ htmlTemplate *template.Template
+}
+
+// Option allows customizing the server
+type Option func(*server)
+
+// WithAuth option initializes the appropriate auth middleware
+func WithAuth(cfg AuthConfig) Option {
+ return func(s *server) {
+ s.auth = cfg
+ }
+}
+
+// WithConfig option applies the HTTPConfig to the server, overriding defaults
+func WithConfig(cfg HTTPConfig) Option {
+ return func(s *server) {
+ s.cfg = cfg
+ }
+}
+
+// WithTemplate option allows the parsing of a template
+func WithTemplate(cfg TemplateConfig) Option {
+ return func(s *server) {
+ s.template = &cfg
+ }
+}
+
+// NewServer instantiates a new http server using provided listeners and options
+// This function is provided if the default http server does not meet a services requirements and should not generally be used
+// A http server can listen using multiple listeners. For example, a listener for port 80, and a listener for port 443.
+// tlsListeners are ignored if opt.TLSKey is not provided
+func NewServer(ctx context.Context, options ...Option) (*server, error) {
+ s := &server{
+ mux: chi.NewRouter(),
+ cfg: DefaultHTTPCfg(),
+ }
+
+ for _, opt := range options {
+ opt(s)
+ }
+
+ // Build base router
+ s.mux.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) {
+ http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+ })
+ s.mux.NotFound(func(w http.ResponseWriter, _ *http.Request) {
+ http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
+ })
+
+ // Ignore passing "/" for BaseURL
+ s.cfg.BaseURL = strings.Trim(s.cfg.BaseURL, "/")
+ if s.cfg.BaseURL != "" {
+ s.cfg.BaseURL = "/" + s.cfg.BaseURL
+ s.mux.Use(MiddlewareStripPrefix(s.cfg.BaseURL))
+ }
+
+ s.initAuth()
+
+ err := s.initTemplate()
+ if err != nil {
+ return nil, err
+ }
+
+ err = s.initTLS()
+ if err != nil {
+ return nil, err
+ }
+
+ for _, addr := range s.cfg.ListenAddr {
+ var url string
+ var network = "tcp"
+ var tlsCfg *tls.Config
+
+ if strings.HasPrefix(addr, "unix://") || filepath.IsAbs(addr) {
+ network = "unix"
+ addr = strings.TrimPrefix(addr, "unix://")
+ url = addr
+
+ } else if strings.HasPrefix(addr, "tls://") || (len(s.cfg.ListenAddr) == 1 && s.tlsConfig != nil) {
+ tlsCfg = s.tlsConfig
+ addr = strings.TrimPrefix(addr, "tls://")
+ }
+
+ var listener net.Listener
+ if tlsCfg == nil {
+ listener, err = net.Listen(network, addr)
+ } else {
+ listener, err = tls.Listen(network, addr, tlsCfg)
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ if network == "tcp" {
+ var secure string
+ if tlsCfg != nil {
+ secure = "s"
+ }
+ url = fmt.Sprintf("http%s://%s%s/", secure, listener.Addr().String(), s.cfg.BaseURL)
+ }
+
+ ii := instance{
+ url: url,
+ listener: listener,
+ httpServer: &http.Server{
+ Handler: s.mux,
+ ReadTimeout: s.cfg.ServerReadTimeout,
+ WriteTimeout: s.cfg.ServerWriteTimeout,
+ MaxHeaderBytes: s.cfg.MaxHeaderBytes,
+ ReadHeaderTimeout: 10 * time.Second, // time to send the headers
+ IdleTimeout: 60 * time.Second, // time to keep idle connections open
+ TLSConfig: tlsCfg,
+ BaseContext: NewBaseContext(ctx, url),
+ },
+ }
+
+ s.instances = append(s.instances, ii)
+ }
+
+ return s, nil
+}
+
+func (s *server) initAuth() {
+ if s.auth.CustomAuthFn != nil {
+ s.mux.Use(MiddlewareAuthCustom(s.auth.CustomAuthFn, s.auth.Realm))
+ return
+ }
+
+ if s.auth.HtPasswd != "" {
+ s.mux.Use(MiddlewareAuthHtpasswd(s.auth.HtPasswd, s.auth.Realm))
+ return
+ }
+
+ if s.auth.BasicUser != "" {
+ s.mux.Use(MiddlewareAuthBasic(s.auth.BasicUser, s.auth.BasicPass, s.auth.Realm, s.auth.Salt))
+ return
+ }
+}
+
+func (s *server) initTemplate() error {
+ if s.template == nil {
+ return nil
+ }
+
+ var err error
+ s.htmlTemplate, err = GetTemplate(s.template.Path)
+ if err != nil {
+ err = fmt.Errorf("failed to get template: %w", err)
+ }
+
+ return err
+}
+
+var (
+ // hard coded errors, allowing for easier testing
+ ErrInvalidMinTLSVersion = errors.New("invalid value for --min-tls-version")
+ ErrTLSBodyMismatch = errors.New("need both TLSCertBody and TLSKeyBody to use TLS")
+ ErrTLSFileMismatch = errors.New("need both --cert and --key to use TLS")
+ ErrTLSParseCA = errors.New("unable to parse client certificate authority")
+)
+
+func (s *server) initTLS() error {
+ if s.cfg.TLSCert == "" && s.cfg.TLSKey == "" && len(s.cfg.TLSCertBody) == 0 && len(s.cfg.TLSKeyBody) == 0 {
+ return nil
+ }
+
+ if (len(s.cfg.TLSCertBody) > 0) != (len(s.cfg.TLSKeyBody) > 0) {
+ return ErrTLSBodyMismatch
+ }
+
+ if (s.cfg.TLSCert != "") != (s.cfg.TLSKey != "") {
+ return ErrTLSFileMismatch
+ }
+
+ var cert tls.Certificate
+ var err error
+ if len(s.cfg.TLSCertBody) > 0 {
+ cert, err = tls.X509KeyPair(s.cfg.TLSCertBody, s.cfg.TLSKeyBody)
+ } else {
+ cert, err = tls.LoadX509KeyPair(s.cfg.TLSCert, s.cfg.TLSKey)
+ }
+ if err != nil {
+ return err
+ }
+
+ var minTLSVersion uint16
+ switch s.cfg.MinTLSVersion {
+ case "tls1.0":
+ minTLSVersion = tls.VersionTLS10
+ case "tls1.1":
+ minTLSVersion = tls.VersionTLS11
+ case "tls1.2":
+ minTLSVersion = tls.VersionTLS12
+ case "tls1.3":
+ minTLSVersion = tls.VersionTLS13
+ default:
+ return fmt.Errorf("%w: %s", ErrInvalidMinTLSVersion, s.cfg.MinTLSVersion)
+ }
+
+ s.tlsConfig = &tls.Config{
+ MinVersion: minTLSVersion,
+ Certificates: []tls.Certificate{cert},
+ }
+
+ if s.cfg.ClientCA != "" {
+ // if !useTLS {
+ // err := errors.New("can't use --client-ca without --cert and --key")
+ // log.Fatalf(err.Error())
+ // }
+ certpool := x509.NewCertPool()
+ pem, err := os.ReadFile(s.cfg.ClientCA)
+ if err != nil {
+ return err
+ }
+
+ if !certpool.AppendCertsFromPEM(pem) {
+ return ErrTLSParseCA
+ }
+
+ s.tlsConfig.ClientCAs = certpool
+ s.tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert
+ }
+
+ return nil
+}
+
+// Serve starts the HTTP server on each listener
+func (s *server) Serve() {
+ s.wg.Add(len(s.instances))
+ for _, ii := range s.instances {
+ // TODO: decide how/when to log listening url
+ // log.Printf("listening on %s", ii.url)
+ go ii.serve(&s.wg)
+ }
+}
+
+// Wait blocks while the server is serving requests
+func (s *server) Wait() {
+ s.wg.Wait()
+}
+
+// Router returns the server base router
+func (s *server) Router() chi.Router {
+ return s.mux
+}
+
+// Shutdown gracefully shuts down the server
+func (s *server) Shutdown() error {
+ ctx := context.Background()
+ for _, ii := range s.instances {
+ if err := ii.httpServer.Shutdown(ctx); err != nil {
+ log.Printf("error shutting down server: %s", err)
+ continue
+ }
+ }
+ s.wg.Wait()
+ return nil
+}
+
+// HTMLTemplate returns the parsed template, if WithTemplate option was passed.
+func (s *server) HTMLTemplate() *template.Template {
+ return s.htmlTemplate
+}
+
+// URLs returns all configured URLS
+func (s *server) URLs() []string {
+ var out []string
+ for _, ii := range s.instances {
+ if ii.listener.Addr().Network() == "unix" {
+ continue
+ }
+ out = append(out, ii.url)
+ }
+ return out
+}
diff --git a/lib/http/server_test.go b/lib/http/server_test.go
new file mode 100644
index 000000000..884214f43
--- /dev/null
+++ b/lib/http/server_test.go
@@ -0,0 +1,444 @@
+package http
+
+import (
+ "context"
+ "crypto/tls"
+ "io"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func testEchoHandler(data []byte) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write(data)
+ })
+}
+
+func testExpectRespBody(t *testing.T, resp *http.Response, expected []byte) {
+ body, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ require.Equal(t, expected, body)
+}
+
+func testGetServerURL(t *testing.T, s Server) string {
+ urls := s.URLs()
+ require.GreaterOrEqual(t, len(urls), 1, "server should return at least one url")
+ return urls[0]
+}
+
+func testNewHTTPClientUnix(path string) *http.Client {
+ return &http.Client{
+ Transport: &http.Transport{
+ DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
+ return net.Dial("unix", path)
+ },
+ },
+ }
+}
+
+func testReadTestdataFile(t *testing.T, path string) []byte {
+ data, err := os.ReadFile(filepath.Join("./testdata", path))
+ require.NoError(t, err, "")
+ return data
+}
+
+func TestNewServerUnix(t *testing.T) {
+ ctx := context.Background()
+
+ tempDir := t.TempDir()
+ path := filepath.Join(tempDir, "rclone.sock")
+
+ cfg := DefaultHTTPCfg()
+ cfg.ListenAddr = []string{path}
+
+ auth := AuthConfig{
+ BasicUser: "test",
+ BasicPass: "test",
+ }
+
+ s, err := NewServer(ctx, WithConfig(cfg), WithAuth(auth))
+ require.NoError(t, err)
+ defer func() {
+ require.NoError(t, s.Shutdown())
+ _, err := os.Stat(path)
+ require.ErrorIs(t, err, os.ErrNotExist, "shutdown should remove socket")
+ }()
+
+ require.Empty(t, s.URLs(), "unix socket should not appear in URLs")
+
+ s.Router().Use(MiddlewareCORS(""))
+
+ expected := []byte("hello world")
+ s.Router().Mount("/", testEchoHandler(expected))
+ s.Serve()
+
+ client := testNewHTTPClientUnix(path)
+ req, err := http.NewRequest("GET", "http://unix", nil)
+ require.NoError(t, err)
+
+ resp, err := client.Do(req)
+ require.NoError(t, err)
+
+ testExpectRespBody(t, resp, expected)
+
+ require.Equal(t, http.StatusOK, resp.StatusCode, "unix sockets should ignore auth")
+
+ for _, key := range _testCORSHeaderKeys {
+ require.NotContains(t, resp.Header, key, "unix sockets should not be sent CORS headers")
+ }
+}
+
+func TestNewServerHTTP(t *testing.T) {
+ ctx := context.Background()
+
+ cfg := DefaultHTTPCfg()
+ cfg.ListenAddr = []string{"127.0.0.1:0"}
+
+ auth := AuthConfig{
+ BasicUser: "test",
+ BasicPass: "test",
+ }
+
+ s, err := NewServer(ctx, WithConfig(cfg), WithAuth(auth))
+ require.NoError(t, err)
+ defer func() {
+ require.NoError(t, s.Shutdown())
+ }()
+
+ url := testGetServerURL(t, s)
+ require.True(t, strings.HasPrefix(url, "http://"), "url should have http scheme")
+
+ expected := []byte("hello world")
+ s.Router().Mount("/", testEchoHandler(expected))
+ s.Serve()
+
+ t.Run("StatusUnauthorized", func(t *testing.T) {
+ client := &http.Client{}
+ req, err := http.NewRequest("GET", url, nil)
+ require.NoError(t, err)
+
+ resp, err := client.Do(req)
+ require.NoError(t, err)
+ defer func() {
+ _ = resp.Body.Close()
+ }()
+
+ require.Equal(t, http.StatusUnauthorized, resp.StatusCode, "no basic auth creds should return unauthorized")
+ })
+
+ t.Run("StatusOK", func(t *testing.T) {
+ client := &http.Client{}
+ req, err := http.NewRequest("GET", url, nil)
+ require.NoError(t, err)
+
+ req.SetBasicAuth(auth.BasicUser, auth.BasicPass)
+
+ resp, err := client.Do(req)
+ require.NoError(t, err)
+ defer func() {
+ _ = resp.Body.Close()
+ }()
+
+ require.Equal(t, http.StatusOK, resp.StatusCode, "using basic auth creds should return ok")
+
+ testExpectRespBody(t, resp, expected)
+ })
+}
+func TestNewServerBaseURL(t *testing.T) {
+ servers := []struct {
+ name string
+ http HTTPConfig
+ suffix string
+ }{
+ {
+ name: "Empty",
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ BaseURL: "",
+ },
+ suffix: "/",
+ },
+ {
+ name: "Single/NoTrailingSlash",
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ BaseURL: "/rclone",
+ },
+ suffix: "/rclone/",
+ },
+ {
+ name: "Single/TrailingSlash",
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ BaseURL: "/rclone/",
+ },
+ suffix: "/rclone/",
+ },
+ {
+ name: "Multi/NoTrailingSlash",
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ BaseURL: "/rclone/test/base/url",
+ },
+ suffix: "/rclone/test/base/url/",
+ },
+ {
+ name: "Multi/TrailingSlash",
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ BaseURL: "/rclone/test/base/url/",
+ },
+ suffix: "/rclone/test/base/url/",
+ },
+ }
+
+ for _, ss := range servers {
+ t.Run(ss.name, func(t *testing.T) {
+ s, err := NewServer(context.Background(), WithConfig(ss.http))
+ require.NoError(t, err)
+ defer func() {
+ require.NoError(t, s.Shutdown())
+ }()
+
+ expected := []byte("data")
+ s.Router().Get("/", testEchoHandler(expected).ServeHTTP)
+ s.Serve()
+
+ url := testGetServerURL(t, s)
+ require.True(t, strings.HasPrefix(url, "http://"), "url should have http scheme")
+ require.True(t, strings.HasSuffix(url, ss.suffix), "url should have the expected suffix")
+
+ client := &http.Client{}
+ req, err := http.NewRequest("GET", url, nil)
+ require.NoError(t, err)
+
+ resp, err := client.Do(req)
+ require.NoError(t, err)
+ defer func() {
+ _ = resp.Body.Close()
+ }()
+
+ t.Log(url, resp.Request.URL)
+
+ require.Equal(t, http.StatusOK, resp.StatusCode, "should return ok")
+
+ testExpectRespBody(t, resp, expected)
+ })
+ }
+}
+
+func TestNewServerTLS(t *testing.T) {
+ certBytes := testReadTestdataFile(t, "local.crt")
+ keyBytes := testReadTestdataFile(t, "local.key")
+
+ // TODO: generate a proper cert with SAN
+ // TODO: generate CA, test mTLS
+ // clientCert, err := tls.X509KeyPair(certBytes, keyBytes)
+ // require.NoError(t, err, "should be testing with a valid self signed certificate")
+
+ servers := []struct {
+ name string
+ wantErr bool
+ err error
+ http HTTPConfig
+ }{
+ {
+ name: "FromFile/Valid",
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ TLSCert: "./testdata/local.crt",
+ TLSKey: "./testdata/local.key",
+ MinTLSVersion: "tls1.0",
+ },
+ },
+ {
+ name: "FromFile/NoCert",
+ wantErr: true,
+ err: ErrTLSFileMismatch,
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ TLSCert: "",
+ TLSKey: "./testdata/local.key",
+ MinTLSVersion: "tls1.0",
+ },
+ },
+ {
+ name: "FromFile/InvalidCert",
+ wantErr: true,
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ TLSCert: "./testdata/local.crt.invalid",
+ TLSKey: "./testdata/local.key",
+ MinTLSVersion: "tls1.0",
+ },
+ },
+ {
+ name: "FromFile/NoKey",
+ wantErr: true,
+ err: ErrTLSFileMismatch,
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ TLSCert: "./testdata/local.crt",
+ TLSKey: "",
+ MinTLSVersion: "tls1.0",
+ },
+ },
+ {
+ name: "FromFile/InvalidKey",
+ wantErr: true,
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ TLSCert: "./testdata/local.crt",
+ TLSKey: "./testdata/local.key.invalid",
+ MinTLSVersion: "tls1.0",
+ },
+ },
+ {
+ name: "FromBody/Valid",
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ TLSCertBody: certBytes,
+ TLSKeyBody: keyBytes,
+ MinTLSVersion: "tls1.0",
+ },
+ },
+ {
+ name: "FromBody/NoCert",
+ wantErr: true,
+ err: ErrTLSBodyMismatch,
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ TLSCertBody: nil,
+ TLSKeyBody: keyBytes,
+ MinTLSVersion: "tls1.0",
+ },
+ },
+ {
+ name: "FromBody/InvalidCert",
+ wantErr: true,
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ TLSCertBody: []byte("JUNK DATA"),
+ TLSKeyBody: keyBytes,
+ MinTLSVersion: "tls1.0",
+ },
+ },
+ {
+ name: "FromBody/NoKey",
+ wantErr: true,
+ err: ErrTLSBodyMismatch,
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ TLSCertBody: certBytes,
+ TLSKeyBody: nil,
+ MinTLSVersion: "tls1.0",
+ },
+ },
+ {
+ name: "FromBody/InvalidKey",
+ wantErr: true,
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ TLSCertBody: certBytes,
+ TLSKeyBody: []byte("JUNK DATA"),
+ MinTLSVersion: "tls1.0",
+ },
+ },
+ {
+ name: "MinTLSVersion/Valid/1.1",
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ TLSCertBody: certBytes,
+ TLSKeyBody: keyBytes,
+ MinTLSVersion: "tls1.1",
+ },
+ },
+ {
+ name: "MinTLSVersion/Valid/1.2",
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ TLSCertBody: certBytes,
+ TLSKeyBody: keyBytes,
+ MinTLSVersion: "tls1.2",
+ },
+ },
+ {
+ name: "MinTLSVersion/Valid/1.3",
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ TLSCertBody: certBytes,
+ TLSKeyBody: keyBytes,
+ MinTLSVersion: "tls1.3",
+ },
+ },
+ {
+ name: "MinTLSVersion/Invalid",
+ wantErr: true,
+ err: ErrInvalidMinTLSVersion,
+ http: HTTPConfig{
+ ListenAddr: []string{"127.0.0.1:0"},
+ TLSCertBody: certBytes,
+ TLSKeyBody: keyBytes,
+ MinTLSVersion: "tls0.9",
+ },
+ },
+ }
+
+ for _, ss := range servers {
+ t.Run(ss.name, func(t *testing.T) {
+ s, err := NewServer(context.Background(), WithConfig(ss.http))
+ if ss.wantErr == true {
+ if ss.err != nil {
+ require.ErrorIs(t, err, ss.err, "new server should return the expected error")
+ } else {
+ require.Error(t, err, "new server should return error for invalid TLS config")
+ }
+ return
+ }
+
+ require.NoError(t, err)
+ defer func() {
+ require.NoError(t, s.Shutdown())
+ }()
+
+ expected := []byte("secret-page")
+ s.Router().Mount("/", testEchoHandler(expected))
+ s.Serve()
+
+ url := testGetServerURL(t, s)
+ require.True(t, strings.HasPrefix(url, "https://"), "url should have https scheme")
+
+ client := &http.Client{
+ Transport: &http.Transport{
+ DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
+ dest := strings.TrimPrefix(url, "https://")
+ dest = strings.TrimSuffix(dest, "/")
+ return net.Dial("tcp", dest)
+ },
+ TLSClientConfig: &tls.Config{
+ // Certificates: []tls.Certificate{clientCert},
+ InsecureSkipVerify: true,
+ },
+ },
+ }
+ req, err := http.NewRequest("GET", "https://dev.rclone.org", nil)
+ require.NoError(t, err)
+
+ resp, err := client.Do(req)
+ require.NoError(t, err)
+ defer func() {
+ _ = resp.Body.Close()
+ }()
+
+ require.Equal(t, http.StatusOK, resp.StatusCode, "should return ok")
+
+ testExpectRespBody(t, resp, expected)
+ })
+ }
+}
diff --git a/lib/http/template.go b/lib/http/template.go
new file mode 100644
index 000000000..bed80dc30
--- /dev/null
+++ b/lib/http/template.go
@@ -0,0 +1,95 @@
+package http
+
+import (
+ "embed"
+ "html/template"
+ "os"
+ "time"
+
+ "github.com/spf13/pflag"
+
+ "github.com/rclone/rclone/fs/config/flags"
+)
+
+// TemplateHelp describes how to use a custom template
+var TemplateHelp = `
+#### Template
+
+` + "`--template`" + ` allows a user to specify a custom markup template for HTTP
+and WebDAV serve functions. The server exports the following markup
+to be used within the template to server pages:
+
+| Parameter | Description |
+| :---------- | :---------- |
+| .Name | The full path of a file/directory. |
+| .Title | Directory listing of .Name |
+| .Sort | The current sort used. This is changeable via ?sort= parameter |
+| | Sort Options: namedirfirst,name,size,time (default namedirfirst) |
+| .Order | The current ordering used. This is changeable via ?order= parameter |
+| | Order Options: asc,desc (default asc) |
+| .Query | Currently unused. |
+| .Breadcrumb | Allows for creating a relative navigation |
+|-- .Link | The relative to the root link of the Text. |
+|-- .Text | The Name of the directory. |
+| .Entries | Information about a specific file/directory. |
+|-- .URL | The 'url' of an entry. |
+|-- .Leaf | Currently same as 'URL' but intended to be 'just' the name. |
+|-- .IsDir | Boolean for if an entry is a directory or not. |
+|-- .Size | Size in Bytes of the entry. |
+|-- .ModTime | The UTC timestamp of an entry. |
+`
+
+// TemplateConfig for the templating functionality
+type TemplateConfig struct {
+ Path string
+}
+
+// AddFlagsPrefix for the templating functionality
+func (cfg *TemplateConfig) AddFlagsPrefix(flagSet *pflag.FlagSet, prefix string) {
+ flags.StringVarP(flagSet, &cfg.Path, prefix+"template", "", cfg.Path, "User-specified template")
+}
+
+// AddTemplateFlagsPrefix for the templating functionality
+func AddTemplateFlagsPrefix(flagSet *pflag.FlagSet, prefix string, cfg *TemplateConfig) {
+ cfg.AddFlagsPrefix(flagSet, prefix)
+}
+
+// DefaultTemplateCfg returns a new config which can be customized by command line flags
+func DefaultTemplateCfg() TemplateConfig {
+ return TemplateConfig{}
+}
+
+// AfterEpoch returns the time since the epoch for the given time
+func AfterEpoch(t time.Time) bool {
+ return t.After(time.Time{})
+}
+
+// Assets holds the embedded filesystem for the default template
+//
+//go:embed templates
+var Assets embed.FS
+
+// GetTemplate returns the HTML template for serving directories via HTTP/WebDAV
+func GetTemplate(tmpl string) (*template.Template, error) {
+ var readFile = os.ReadFile
+ if tmpl == "" {
+ tmpl = "templates/index.html"
+ readFile = Assets.ReadFile
+ }
+
+ data, err := readFile(tmpl)
+ if err != nil {
+ return nil, err
+ }
+
+ funcMap := template.FuncMap{
+ "afterEpoch": AfterEpoch,
+ }
+
+ tpl, err := template.New("index").Funcs(funcMap).Parse(string(data))
+ if err != nil {
+ return nil, err
+ }
+
+ return tpl, nil
+}
diff --git a/lib/http/templates/index.html b/lib/http/templates/index.html
new file mode 100644
index 000000000..348050c02
--- /dev/null
+++ b/lib/http/templates/index.html
@@ -0,0 +1,389 @@
+
+
+
+
+
+ {{html .Name}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+ Name
+ |
+
+ Size
+ |
+
+ Modified
+ |
+ |
+
+
+
+
+ |
+
+
+ Go up
+
+ |
+ — |
+ — |
+ |
+
+ {{- range .Entries}}
+
+
+ |
+
+ {{- if .IsDir}}
+
+ {{- else}}
+
+ {{- end}}
+ {{html .Leaf}}
+ |
+ {{- if .IsDir}}
+ — |
+ {{- else}}
+ {{.Size}} |
+ {{- end}}
+ {{- if .ModTime | afterEpoch }}
+ |
+ {{- else}}
+ — |
+ {{- end}}
+ |
+
+ {{- end}}
+
+
+
+
+
+
+
diff --git a/lib/http/testdata/.htpasswd b/lib/http/testdata/.htpasswd
new file mode 100644
index 000000000..5d0c17779
--- /dev/null
+++ b/lib/http/testdata/.htpasswd
@@ -0,0 +1,3 @@
+sha:{SHA}2PRZAyDhNDqRW2OUFwZQqPNdaSY=
+md5:$apr1$s7fogein$IK9ItbnGM14ct0bY4Uyik1
+bcrypt:$2y$10$K/b3mVXUA6X857TOTYIL9.Lbaeg9oBjMQwUX5NefpVUCcYP0Z5KY2
\ No newline at end of file
diff --git a/lib/http/testdata/local.crt b/lib/http/testdata/local.crt
new file mode 100644
index 000000000..c2bcf28fe
--- /dev/null
+++ b/lib/http/testdata/local.crt
@@ -0,0 +1,32 @@
+-----BEGIN CERTIFICATE-----
+MIIFeTCCA2GgAwIBAgIUewDoUKLIPBZlSAdOaBHPtfFDiUYwDQYJKoZIhvcNAQEL
+BQAwTDELMAkGA1UEBhMCVVMxDzANBgNVBAoMBnJjbG9uZTETMBEGA1UECwwKcmNs
+b25lLWRldjEXMBUGA1UEAwwOZGV2LnJjbG9uZS5vcmcwHhcNMjIxMDI3MDM1NjE2
+WhcNMzIxMDI0MDM1NjE2WjBMMQswCQYDVQQGEwJVUzEPMA0GA1UECgwGcmNsb25l
+MRMwEQYDVQQLDApyY2xvbmUtZGV2MRcwFQYDVQQDDA5kZXYucmNsb25lLm9yZzCC
+AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAJVMPbsmBVBi2DG17cPrJQxM
+hdUxkd8pcRtrKkLzqIQS18IKJ20BTzrVlJJETO1uMeXETSVVQ4znWLvhZo5Pgq72
+FlvEMEl6QXNNihEB+Bx9f6iArS0Tgo919Kg3JEYu9g3HfsPxt3C1xNJOlSrpPo4w
+YAX8MU+uy3IqPwgURVhPauR/2E64b9RSIsmNCJJXWnREeJVOpeYSUXx6S0fNM/wo
+BvbPSuB1BErYug9AfuvTavJO7VYnMuEEig/1DXYnPJpTTaZWzyhQIGDU52T5iOZk
+sdxv/Yxbq1kKDHmyhG9ALpCHqQMmR3bOEhLijU5UHfFR03eKnzfcQ7y5BMJBDIVs
+fbaBxlJZFHKq9ixUT/7crc1iUXb+gPg/FxMRuR96/fTPrluWlDYaZV3QMa3rxuvn
+PJM5boqGIH9fNxnsmho0V4ZrXgxg5VsnlwHZIlMuJVJGkUQFTl6XhIzkQRmjQM4C
+WO9KPSusvCM3gCM7j9Tyhi3xl7XRVlN9xb8vbnbyqcZe0lgJU/y3nrbWqORvUKb0
+FvarmtIH3f9yqu+UB2s8DIg5zPjVmTnIMSJYJi5Cjh1YT2YVJ4+JJ0KlxAwOVTU4
+zdup3fUA3Ne2o9ehJEJwuFPbMDCEHeIuTzOKleKCDbHRIvFLleJP4J9/qA1P2C2/
+ZAoS4M8vgd2O84eVm7pzAgMBAAGjUzBRMB0GA1UdDgQWBBTDsVFbaf4C4yDDOGml
+BMyKSgsT5DAfBgNVHSMEGDAWgBTDsVFbaf4C4yDDOGmlBMyKSgsT5DAPBgNVHRMB
+Af8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4ICAQAx2wDldW5wZfBGdIv9UCTL1hdB
+a858v+mWCAjXHaFCyMAe2UOBkSF4M8oIqgMXP3ZwysAhB54qKcpKgxKv6s5oyA/k
+diheH8Zl7GYnUS+A1v9ZUx5AwJbVNKCSXYfXQxTsoC1jV0W3HqeJIoxix3XJDZIL
+TCiA8+BMk72vtJBPNlV8wmuN2+aUEktZF1PqjbqU/6ajRvDIa4ogXIVoSunpFXt9
+h5Af2zOE051zqjqshr9Egz6Hl2EItjJgqZtwLD8qSZFzcXYgDvtG16YceGt+ljeC
+yLYE8Qvm9lfQka9Sxucu4+2kzCwjg5ubFaaSkiX6b/ue9KvPHm36JAV9NDwyyA/q
+BNNxK+0PniNPLBdxIkFZvVraLieDNCXV3cqH7781IP2PRvbzNAR1PFfo19U7bmHV
+PsOBj9kIQBhOLcxtxWvK93ptC6vLJYRsPha7kClVI5kht9oB8Mkfi2mAu2Pi7Pka
+ZYHl14XnJPUWEbxX26I4CAU09yGjnQhRwPfGGNPCaMMGsYl+2nn4j/rzbQ8uz7Kg
+l1TQS4WBpX4T6pxWM/mWERbBTLxniE8DNYPgHpgZJSXD16uG/ksyjdet7B86KA0v
+kymH793pOqW8rtrKbziSZyShJy5AYsGy0Xu4ymW03F8S5FUyjWIkRmyUbGTB1q02
+nniyTJh2BffUcH2iCg==
+-----END CERTIFICATE-----
diff --git a/lib/http/testdata/local.key b/lib/http/testdata/local.key
new file mode 100644
index 000000000..e855ac9fe
--- /dev/null
+++ b/lib/http/testdata/local.key
@@ -0,0 +1,52 @@
+-----BEGIN PRIVATE KEY-----
+MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCVTD27JgVQYtgx
+te3D6yUMTIXVMZHfKXEbaypC86iEEtfCCidtAU861ZSSREztbjHlxE0lVUOM51i7
+4WaOT4Ku9hZbxDBJekFzTYoRAfgcfX+ogK0tE4KPdfSoNyRGLvYNx37D8bdwtcTS
+TpUq6T6OMGAF/DFPrstyKj8IFEVYT2rkf9hOuG/UUiLJjQiSV1p0RHiVTqXmElF8
+ektHzTP8KAb2z0rgdQRK2LoPQH7r02ryTu1WJzLhBIoP9Q12JzyaU02mVs8oUCBg
+1Odk+YjmZLHcb/2MW6tZCgx5soRvQC6Qh6kDJkd2zhIS4o1OVB3xUdN3ip833EO8
+uQTCQQyFbH22gcZSWRRyqvYsVE/+3K3NYlF2/oD4PxcTEbkfev30z65blpQ2GmVd
+0DGt68br5zyTOW6KhiB/XzcZ7JoaNFeGa14MYOVbJ5cB2SJTLiVSRpFEBU5el4SM
+5EEZo0DOAljvSj0rrLwjN4AjO4/U8oYt8Ze10VZTfcW/L2528qnGXtJYCVP8t562
+1qjkb1Cm9Bb2q5rSB93/cqrvlAdrPAyIOcz41Zk5yDEiWCYuQo4dWE9mFSePiSdC
+pcQMDlU1OM3bqd31ANzXtqPXoSRCcLhT2zAwhB3iLk8zipXigg2x0SLxS5XiT+Cf
+f6gNT9gtv2QKEuDPL4HdjvOHlZu6cwIDAQABAoICAD7EzqFb01kgLZf8zqmTt8BL
+fesLy7IA6OpnrF14tq1MhMSyYzALoGVyfWPfbl5WeYkJ9otPJTbc3ywikG0dlap8
+kRrkyY5i5ZiWDYmoA8nqo5zS+LweW0J4i7Obd1dAkDdr2+qCuiabbVQkMMfZR3Ed
+eomZpZvEOAnYJCb/6sW9ognOjEFQfsfL/o8xidyI+GEwlmfjqJEpu3OzsOnPpt8J
+byAeN/NVj0fuhY87BQGeIfUc5ODXWydKsscRtqapyWtywY9BKRhgU7SSXnTQCtQe
+mr68oON9ePVW6bbSrKZfXBRszMyjr+ENs4CYGmPHrs0SI2+7asRgCWSTfyIymhHs
+DjF6BHAlQaOLnVcffuGhc6c0bnzIFvg1JenWVj9uQSkADW8TMkNgMLdcx8JoMd30
+9HiHZyQy7hMuzWjZSgS1wRp3KMGBo+WuMd4NF662nQPfylG2iJvedLtbvY7mBGkX
+1e6L4b1fkFAZWyfBj3JX1SqgcNDnqzDiqqUDwpY8b1G9nAegvWBeFbEgnORa6pVF
+8xWNtWlMsXYjTvPLbvDSZc6l0wOpFw7roGZgQpbaTjmbeqrZMsr/XDtH7ksLCBOh
+V/s2oMU0NUBu/mokvpLupBzVMbA7/TKGr36OrHw57P8dhsdD3bqx+UbUMgjsq6xs
+UAMcjWJFoc3ulgsVHF2BAoIBAQDEo5i63JkQL3+8rXQ25StbEgqVGXcGS2oZLu+G
+GYklJ0NyGrjoYWmZ/QQf9NbAhQKDSe/X2cWtTLMF9Y2yVsxQ2x1df3lonk9dHsoX
+O+Yp8C9zKt24dRWUsyklgkxAmb/nq3dRPtm/80ztNVQvYy5SQ7G5s/suXt9Wyd2y
+nl08ZWclaDkHNS33/8hYLHP3RkjLgcOu45UDb/3vXk6ljnlrnqe7nlRX5UrnVXs3
+K41xJpCSHmUe7SUoW1ExjzWhtQBkWWVWWV8gE8CNpU0nheTkA5KyXQ8QfQ13jxJj
+zPy/3OPGMIElbvEBcXxvcRncpuUIVO4tRy+oZuz2bmLwbBiBAoIBAQDCXhS5wMgJ
+fWNzsd9prFrENVz7CR2NK3LSdVpm4ZtR5f4mLyzYj/RcSIYC4U7uO0WXaoLTnhKs
+KbKAj5KC2HZUcqhLscajXPl6OjBOcEAlujh9VeTjEnx6XU81IwmAjrYr98xAz3HJ
+IOaD8MvfZ13JWDB9q5bq3A8EKTEAPh66mv/w4Ic8rnueFZgxEFpD4Z2bSB7xmEHj
+tE7VuUbdkLQ4jDrxwZuj7vC0+50pqDrSe0VY/KaGOnNtF1fVs3N+xlI9yUBhr9Yy
+ANqUlx8Ql0i2aIuTSR9h/Wk9uu3NmsON9FkLoYK4bg9jiMcA6eKyRK/9Txn/CygG
+zNy3+s2RhHjzAoIBAQCCZ5Pz6DPB3h4yPD2j4hr8jFxkQL0EeaLlDJFgNzMSZpV9
+6GbUBTYJHxhLMQ3yIsNl2fSrCwrjQMhAnXXY3WMmBAnXZaBYVxR+xtpyyhB7o4N0
+NutPVqZ3NNGGxIBZHx17P+UjBjFV8L4FWaZ4vqeLesU0SD29pMEsRzc1K3zdfsoG
+rrWTKBtSKljs0J4fUIcaHvZs1xSNcQnQYpR5iqDPVCocbIW2vKMOA0xxa/qjHVYm
+8O1SsyY/Oz//Q9/nW6fk5LwlpaNGHJNH3GXsXglLhWsVyk0hPC1gKouhj+HWQ2Dy
+oFwlPQurT12ccj8aa7vb6KcDdAARCCEB1HbcxnMBAoIBADnY6FA0eRSh9eRsDvMT
+cdwtiaPJHbtzL/RFKweto51nVxGkPrOhfHeuufvHdMdgaqDa+V7kD+ifbFno4REC
+PY16pm4I1fau6C0hflkJ/X19A+0BkGKokNWWScmlyOEzGDLTyD2Nv+69VP31v6eY
+ywfusFfmpr71iZ6SZ9wLoPemw/+7w2QjBfWRtb78f/DuCAs8FsGOsCWF92SShO3S
+cGDYE376QUk0Bv3GWQsZ34/fUk9eumz+nnXcWa7nfrs/aSCscfXg8F3ndSZ+J6e3
+btOjH89RFv8B/b16keX8ZrEsBQh6JD6huwDDp361HVwzJzG7xh/rARmtBQ/YnC/v
+/lMCggEAZ+EA8vRD7KSDLaxUl/DG0VQYtyJWsf83/NcLAizvYirI9batLjUF8ogy
+pNHmA1STVJko+P+M2V5v56lJWnrj6HPj91NPIqdgJ/hIIZNI1zXm/DlLOegrFH0W
+7fIY3+ZxzCIMuN4TvZ2H7n6NDBvTr6Vno17nFRGzy1suceIk7tx66KiFGmVt0sT/
+yyCQdyk+uM0KG3j19+QDrbtOTTmK3cOHTEOs7D2RmHSbn51jOckzL2mrhXvcGfTS
+cqYW21Gm9U2P7VjgQ9vHuGzUFIrskltAGuX38XdPiBu9vZp1hwPbYY9MLxrbW8UM
+ySc0NWyPv8/wyfen4dBRJ9OthwwcXw==
+-----END PRIVATE KEY-----