From 939b19c3b723fe6c9dd0fd56765dd5b0e404c9ba Mon Sep 17 00:00:00 2001 From: Florian Apolloner Date: Fri, 7 Jun 2019 19:47:46 +0200 Subject: [PATCH] cmd: add support for private repositories in `serve restic` - fixes #3247 --- cmd/serve/httplib/httplib.go | 7 ++ cmd/serve/restic/restic.go | 20 ++++- cmd/serve/restic/restic_appendonly_test.go | 46 ----------- cmd/serve/restic/restic_privaterepos_test.go | 84 ++++++++++++++++++++ cmd/serve/restic/restic_test_utils.go | 57 +++++++++++++ 5 files changed, 164 insertions(+), 50 deletions(-) create mode 100644 cmd/serve/restic/restic_privaterepos_test.go create mode 100644 cmd/serve/restic/restic_test_utils.go diff --git a/cmd/serve/httplib/httplib.go b/cmd/serve/httplib/httplib.go index ccbf4647e..fc8393386 100644 --- a/cmd/serve/httplib/httplib.go +++ b/cmd/serve/httplib/httplib.go @@ -2,6 +2,7 @@ package httplib import ( + "context" "crypto/tls" "crypto/x509" "encoding/base64" @@ -114,6 +115,11 @@ type Server struct { HTMLTemplate *template.Template // HTML template for web interface } +type contextUserType struct{} + +// ContextUserKey is a simple context key +var ContextUserKey = &contextUserType{} + // singleUserProvider provides the encrypted password for a single user func (s *Server) singleUserProvider(user, realm string) string { if user == s.Opt.BasicUser { @@ -172,6 +178,7 @@ func NewServer(handler http.Handler, opt *Options) *Server { } authenticator.RequireAuth(w, r) } else { + r = r.WithContext(context.WithValue(r.Context(), ContextUserKey, username)) oldHandler.ServeHTTP(w, r) } }) diff --git a/cmd/serve/restic/restic.go b/cmd/serve/restic/restic.go index 00799dd54..c5543fcc2 100644 --- a/cmd/serve/restic/restic.go +++ b/cmd/serve/restic/restic.go @@ -29,14 +29,16 @@ import ( ) var ( - stdio bool - appendOnly bool + stdio bool + appendOnly bool + privateRepos bool ) func init() { httpflags.AddFlags(Command.Flags()) Command.Flags().BoolVar(&stdio, "stdio", false, "run an HTTP2 server on stdin/stdout") Command.Flags().BoolVar(&appendOnly, "append-only", false, "disallow deletion of repository data") + Command.Flags().BoolVar(&privateRepos, "private-repos", false, "users can only access their private repo") } // Command definition for cobra @@ -94,14 +96,14 @@ For example: $ export RESTIC_PASSWORD=yourpassword $ restic init created restic backend 8b1a4b56ae at rest:http://localhost:8080/ - + Please note that knowledge of your password is required to access the repository. Losing your password means that your data is irrecoverably lost. $ restic backup /path/to/files/to/backup scan [/path/to/files/to/backup] scanned 189 directories, 312 files in 0:00 - [0:00] 100.00% 38.128 MiB / 38.128 MiB 501 / 501 items 0 errors ETA 0:00 + [0:00] 100.00% 38.128 MiB / 38.128 MiB 501 / 501 items 0 errors ETA 0:00 duration: 0:00 snapshot 45c8fdd8 saved @@ -116,6 +118,10 @@ these **must** end with /. Eg $ export RESTIC_REPOSITORY=rest:http://localhost:8080/user2repo/ # backup user2 stuff +#### Private repositories #### + +The "--private-repos" flag can be used to limit users to repositories starting +with a path of "//". ` + httplib.Help, Run: func(command *cobra.Command, args []string) { cmd.CheckArgs(1, 1, command, args) @@ -209,6 +215,12 @@ func (s *server) handler(w http.ResponseWriter, r *http.Request) { remote := makeRemote(path) fs.Debugf(s.f, "%s %s", r.Method, path) + v := r.Context().Value(httplib.ContextUserKey) + if privateRepos && (v == nil || !strings.HasPrefix(path, "/"+v.(string)+"/")) { + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return + } + // Dispatch on path then method if strings.HasSuffix(path, "/") { switch r.Method { diff --git a/cmd/serve/restic/restic_appendonly_test.go b/cmd/serve/restic/restic_appendonly_test.go index 8f35b28f7..a45aa4e5f 100644 --- a/cmd/serve/restic/restic_appendonly_test.go +++ b/cmd/serve/restic/restic_appendonly_test.go @@ -8,61 +8,15 @@ import ( "io" "io/ioutil" "net/http" - "net/http/httptest" "os" "strings" "testing" "github.com/ncw/rclone/cmd" "github.com/ncw/rclone/cmd/serve/httplib/httpflags" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// declare a few helper functions - -// wantFunc tests the HTTP response in res and marks the test as errored if something is incorrect. -type wantFunc func(t testing.TB, res *httptest.ResponseRecorder) - -// newRequest returns a new HTTP request with the given params. On error, the -// test is marked as failed. -func newRequest(t testing.TB, method, path string, body io.Reader) *http.Request { - req, err := http.NewRequest(method, path, body) - require.NoError(t, err) - return req -} - -// wantCode returns a function which checks that the response has the correct HTTP status code. -func wantCode(code int) wantFunc { - return func(t testing.TB, res *httptest.ResponseRecorder) { - assert.Equal(t, code, res.Code) - } -} - -// wantBody returns a function which checks that the response has the data in the body. -func wantBody(body string) wantFunc { - return func(t testing.TB, res *httptest.ResponseRecorder) { - assert.NotNil(t, res.Body) - assert.Equal(t, res.Body.Bytes(), []byte(body)) - } -} - -// checkRequest uses f to process the request and runs the checker functions on the result. -func checkRequest(t testing.TB, f http.HandlerFunc, req *http.Request, want []wantFunc) { - rr := httptest.NewRecorder() - f(rr, req) - - for _, fn := range want { - fn(t, rr) - } -} - -// TestRequest is a sequence of HTTP requests with (optional) tests for the response. -type TestRequest struct { - req *http.Request - want []wantFunc -} - // createOverwriteDeleteSeq returns a sequence which will create a new file at // path, and then try to overwrite and delete it. func createOverwriteDeleteSeq(t testing.TB, path string) []TestRequest { diff --git a/cmd/serve/restic/restic_privaterepos_test.go b/cmd/serve/restic/restic_privaterepos_test.go new file mode 100644 index 000000000..5ca677a8b --- /dev/null +++ b/cmd/serve/restic/restic_privaterepos_test.go @@ -0,0 +1,84 @@ +// +build go1.9 + +package restic + +import ( + "context" + "crypto/rand" + "io" + "io/ioutil" + "net/http" + "os" + "strings" + "testing" + + "github.com/ncw/rclone/cmd/serve/httplib" + + "github.com/ncw/rclone/cmd" + "github.com/ncw/rclone/cmd/serve/httplib/httpflags" + "github.com/stretchr/testify/require" +) + +// newAuthenticatedRequest returns a new HTTP request with the given params. +func newAuthenticatedRequest(t testing.TB, method, path string, body io.Reader) *http.Request { + req := newRequest(t, method, path, body) + req = req.WithContext(context.WithValue(req.Context(), httplib.ContextUserKey, "test")) + req.Header.Add("Accept", resticAPIV2) + return req +} + +// TestResticPrivateRepositories runs tests on the restic handler code for private repositories +func TestResticPrivateRepositories(t *testing.T) { + buf := make([]byte, 32) + _, err := io.ReadFull(rand.Reader, buf) + require.NoError(t, err) + + // setup rclone with a local backend in a temporary directory + tempdir, err := ioutil.TempDir("", "rclone-restic-test-") + require.NoError(t, err) + + // make sure the tempdir is properly removed + defer func() { + err := os.RemoveAll(tempdir) + require.NoError(t, err) + }() + + // globally set private-repos mode & test user + prev := privateRepos + prevUser := httpflags.Opt.BasicUser + prevPassword := httpflags.Opt.BasicPass + privateRepos = true + httpflags.Opt.BasicUser = "test" + httpflags.Opt.BasicPass = "password" + // reset when done + defer func() { + privateRepos = prev + httpflags.Opt.BasicUser = prevUser + httpflags.Opt.BasicPass = prevPassword + }() + + // make a new file system in the temp dir + f := cmd.NewFsSrc([]string{tempdir}) + srv := newServer(f, &httpflags.Opt) + + // Requesting /test/ should allow access + reqs := []*http.Request{ + newAuthenticatedRequest(t, "POST", "/test/?create=true", nil), + newAuthenticatedRequest(t, "POST", "/test/config", strings.NewReader("foobar test config")), + newAuthenticatedRequest(t, "GET", "/test/config", nil), + } + for _, req := range reqs { + checkRequest(t, srv.handler, req, []wantFunc{wantCode(http.StatusOK)}) + } + + // Requesting everything else should raise forbidden errors + reqs = []*http.Request{ + newAuthenticatedRequest(t, "GET", "/", nil), + newAuthenticatedRequest(t, "POST", "/other_user", nil), + newAuthenticatedRequest(t, "GET", "/other_user/config", nil), + } + for _, req := range reqs { + checkRequest(t, srv.handler, req, []wantFunc{wantCode(http.StatusForbidden)}) + } + +} diff --git a/cmd/serve/restic/restic_test_utils.go b/cmd/serve/restic/restic_test_utils.go new file mode 100644 index 000000000..4ccc49b06 --- /dev/null +++ b/cmd/serve/restic/restic_test_utils.go @@ -0,0 +1,57 @@ +// +build go1.9 + +package restic + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// declare a few helper functions + +// wantFunc tests the HTTP response in res and marks the test as errored if something is incorrect. +type wantFunc func(t testing.TB, res *httptest.ResponseRecorder) + +// newRequest returns a new HTTP request with the given params. On error, the +// test is marked as failed. +func newRequest(t testing.TB, method, path string, body io.Reader) *http.Request { + req, err := http.NewRequest(method, path, body) + require.NoError(t, err) + return req +} + +// wantCode returns a function which checks that the response has the correct HTTP status code. +func wantCode(code int) wantFunc { + return func(t testing.TB, res *httptest.ResponseRecorder) { + assert.Equal(t, code, res.Code) + } +} + +// wantBody returns a function which checks that the response has the data in the body. +func wantBody(body string) wantFunc { + return func(t testing.TB, res *httptest.ResponseRecorder) { + assert.NotNil(t, res.Body) + assert.Equal(t, res.Body.Bytes(), []byte(body)) + } +} + +// checkRequest uses f to process the request and runs the checker functions on the result. +func checkRequest(t testing.TB, f http.HandlerFunc, req *http.Request, want []wantFunc) { + rr := httptest.NewRecorder() + f(rr, req) + + for _, fn := range want { + fn(t, rr) + } +} + +// TestRequest is a sequence of HTTP requests with (optional) tests for the response. +type TestRequest struct { + req *http.Request + want []wantFunc +}