diff --git a/go.mod b/go.mod index 41e03eb8a..74ca0a18c 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/greatroar/blobloom v0.3.0 github.com/jackpal/gateway v1.0.6 github.com/jackpal/go-nat-pmp v1.0.2 + github.com/julienschmidt/httprouter v1.2.0 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/kr/pretty v0.2.0 // indirect github.com/lib/pq v1.2.0 diff --git a/go.sum b/go.sum index 19cf5cce9..50d3d0275 100644 --- a/go.sum +++ b/go.sum @@ -173,6 +173,7 @@ github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0 github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/julienschmidt/httprouter v1.2.0 h1:TDTW5Yz1mjftljbcKqRcrYhd4XeOoI98t+9HbQbYf7g= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= diff --git a/gui/default/syncthing/core/syncthingController.js b/gui/default/syncthing/core/syncthingController.js index 5af030622..b46430993 100755 --- a/gui/default/syncthing/core/syncthingController.js +++ b/gui/default/syncthing/core/syncthingController.js @@ -268,7 +268,7 @@ angular.module('syncthing.core') $scope.$on(Events.CONFIG_SAVED, function (event, arg) { updateLocalConfig(arg.data); - $http.get(urlbase + '/system/config/insync').success(function (data) { + $http.get(urlbase + '/config/insync').success(function (data) { $scope.configInSync = data.configInSync; }).error($scope.emitHTTPError); }); @@ -578,12 +578,12 @@ angular.module('syncthing.core') } function refreshConfig() { - $http.get(urlbase + '/system/config').success(function (data) { + $http.get(urlbase + '/config').success(function (data) { updateLocalConfig(data); console.log("refreshConfig", data); }).error($scope.emitHTTPError); - $http.get(urlbase + '/system/config/insync').success(function (data) { + $http.get(urlbase + '/config/insync').success(function (data) { $scope.configInSync = data.configInSync; }).error($scope.emitHTTPError); } @@ -1257,7 +1257,7 @@ angular.module('syncthing.core') 'Content-Type': 'application/json' } }; - $http.post(urlbase + '/system/config', cfg, opts).success(function () { + $http.put(urlbase + '/config', cfg, opts).success(function () { refreshConfig(); if (callback) { diff --git a/lib/api/api.go b/lib/api/api.go index 15a76bf80..9d8791bf5 100644 --- a/lib/api/api.go +++ b/lib/api/api.go @@ -31,10 +31,10 @@ import ( "strings" "time" + "github.com/julienschmidt/httprouter" metrics "github.com/rcrowley/go-metrics" "github.com/thejerf/suture" "github.com/vitrun/qart/qr" - "golang.org/x/crypto/bcrypt" "github.com/syncthing/syncthing/lib/build" "github.com/syncthing/syncthing/lib/config" @@ -81,7 +81,6 @@ type service struct { connectionsService connections.Service fss model.FolderSummaryService urService *ur.Service - systemConfigMut sync.Mutex // serializes posts to /rest/system/config contr Controller noUpgrade bool tlsDefaultCommonName string @@ -123,7 +122,6 @@ func New(id protocol.DeviceID, cfg config.Wrapper, assetDir, tlsDefaultCommonNam connectionsService: connectionsService, fss: fss, urService: urService, - systemConfigMut: sync.NewMutex(), guiErrors: errors, systemLog: systemLog, contr: contr, @@ -243,60 +241,80 @@ func (s *service) serve(ctx context.Context) { s.cfg.Subscribe(s) defer s.cfg.Unsubscribe(s) + restMux := httprouter.New() + // The GET handlers - getRestMux := http.NewServeMux() - getRestMux.HandleFunc("/rest/db/completion", s.getDBCompletion) // [device] [folder] - getRestMux.HandleFunc("/rest/db/file", s.getDBFile) // folder file - getRestMux.HandleFunc("/rest/db/ignores", s.getDBIgnores) // folder - getRestMux.HandleFunc("/rest/db/need", s.getDBNeed) // folder [perpage] [page] - getRestMux.HandleFunc("/rest/db/remoteneed", s.getDBRemoteNeed) // device folder [perpage] [page] - getRestMux.HandleFunc("/rest/db/localchanged", s.getDBLocalChanged) // folder - getRestMux.HandleFunc("/rest/db/status", s.getDBStatus) // folder - getRestMux.HandleFunc("/rest/db/browse", s.getDBBrowse) // folder [prefix] [dirsonly] [levels] - getRestMux.HandleFunc("/rest/folder/versions", s.getFolderVersions) // folder - getRestMux.HandleFunc("/rest/folder/errors", s.getFolderErrors) // folder - getRestMux.HandleFunc("/rest/folder/pullerrors", s.getFolderErrors) // folder (deprecated) - getRestMux.HandleFunc("/rest/events", s.getIndexEvents) // [since] [limit] [timeout] [events] - getRestMux.HandleFunc("/rest/events/disk", s.getDiskEvents) // [since] [limit] [timeout] - getRestMux.HandleFunc("/rest/stats/device", s.getDeviceStats) // - - getRestMux.HandleFunc("/rest/stats/folder", s.getFolderStats) // - - getRestMux.HandleFunc("/rest/svc/deviceid", s.getDeviceID) // id - getRestMux.HandleFunc("/rest/svc/lang", s.getLang) // - - getRestMux.HandleFunc("/rest/svc/report", s.getReport) // - - getRestMux.HandleFunc("/rest/svc/random/string", s.getRandomString) // [length] - getRestMux.HandleFunc("/rest/system/browse", s.getSystemBrowse) // current - getRestMux.HandleFunc("/rest/system/config", s.getSystemConfig) // - - getRestMux.HandleFunc("/rest/system/config/insync", s.getSystemConfigInsync) // - - getRestMux.HandleFunc("/rest/system/connections", s.getSystemConnections) // - - getRestMux.HandleFunc("/rest/system/discovery", s.getSystemDiscovery) // - - getRestMux.HandleFunc("/rest/system/error", s.getSystemError) // - - getRestMux.HandleFunc("/rest/system/ping", s.restPing) // - - getRestMux.HandleFunc("/rest/system/status", s.getSystemStatus) // - - getRestMux.HandleFunc("/rest/system/upgrade", s.getSystemUpgrade) // - - getRestMux.HandleFunc("/rest/system/version", s.getSystemVersion) // - - getRestMux.HandleFunc("/rest/system/debug", s.getSystemDebug) // - - getRestMux.HandleFunc("/rest/system/log", s.getSystemLog) // [since] - getRestMux.HandleFunc("/rest/system/log.txt", s.getSystemLogTxt) // [since] + restMux.HandlerFunc(http.MethodGet, "/rest/db/completion", s.getDBCompletion) // [device] [folder] + restMux.HandlerFunc(http.MethodGet, "/rest/db/file", s.getDBFile) // folder file + restMux.HandlerFunc(http.MethodGet, "/rest/db/ignores", s.getDBIgnores) // folder + restMux.HandlerFunc(http.MethodGet, "/rest/db/need", s.getDBNeed) // folder [perpage] [page] + restMux.HandlerFunc(http.MethodGet, "/rest/db/remoteneed", s.getDBRemoteNeed) // device folder [perpage] [page] + restMux.HandlerFunc(http.MethodGet, "/rest/db/localchanged", s.getDBLocalChanged) // folder + restMux.HandlerFunc(http.MethodGet, "/rest/db/status", s.getDBStatus) // folder + restMux.HandlerFunc(http.MethodGet, "/rest/db/browse", s.getDBBrowse) // folder [prefix] [dirsonly] [levels] + restMux.HandlerFunc(http.MethodGet, "/rest/folder/versions", s.getFolderVersions) // folder + restMux.HandlerFunc(http.MethodGet, "/rest/folder/errors", s.getFolderErrors) // folder + restMux.HandlerFunc(http.MethodGet, "/rest/folder/pullerrors", s.getFolderErrors) // folder (deprecated) + restMux.HandlerFunc(http.MethodGet, "/rest/events", s.getIndexEvents) // [since] [limit] [timeout] [events] + restMux.HandlerFunc(http.MethodGet, "/rest/events/disk", s.getDiskEvents) // [since] [limit] [timeout] + restMux.HandlerFunc(http.MethodGet, "/rest/stats/device", s.getDeviceStats) // - + restMux.HandlerFunc(http.MethodGet, "/rest/stats/folder", s.getFolderStats) // - + restMux.HandlerFunc(http.MethodGet, "/rest/svc/deviceid", s.getDeviceID) // id + restMux.HandlerFunc(http.MethodGet, "/rest/svc/lang", s.getLang) // - + restMux.HandlerFunc(http.MethodGet, "/rest/svc/report", s.getReport) // - + restMux.HandlerFunc(http.MethodGet, "/rest/svc/random/string", s.getRandomString) // [length] + restMux.HandlerFunc(http.MethodGet, "/rest/system/browse", s.getSystemBrowse) // current + restMux.HandlerFunc(http.MethodGet, "/rest/system/connections", s.getSystemConnections) // - + restMux.HandlerFunc(http.MethodGet, "/rest/system/discovery", s.getSystemDiscovery) // - + restMux.HandlerFunc(http.MethodGet, "/rest/system/error", s.getSystemError) // - + restMux.HandlerFunc(http.MethodGet, "/rest/system/ping", s.restPing) // - + restMux.HandlerFunc(http.MethodGet, "/rest/system/status", s.getSystemStatus) // - + restMux.HandlerFunc(http.MethodGet, "/rest/system/upgrade", s.getSystemUpgrade) // - + restMux.HandlerFunc(http.MethodGet, "/rest/system/version", s.getSystemVersion) // - + restMux.HandlerFunc(http.MethodGet, "/rest/system/debug", s.getSystemDebug) // - + restMux.HandlerFunc(http.MethodGet, "/rest/system/log", s.getSystemLog) // [since] + restMux.HandlerFunc(http.MethodGet, "/rest/system/log.txt", s.getSystemLogTxt) // [since] // The POST handlers - postRestMux := http.NewServeMux() - postRestMux.HandleFunc("/rest/db/prio", s.postDBPrio) // folder file [perpage] [page] - postRestMux.HandleFunc("/rest/db/ignores", s.postDBIgnores) // folder - postRestMux.HandleFunc("/rest/db/override", s.postDBOverride) // folder - postRestMux.HandleFunc("/rest/db/revert", s.postDBRevert) // folder - postRestMux.HandleFunc("/rest/db/scan", s.postDBScan) // folder [sub...] [delay] - postRestMux.HandleFunc("/rest/folder/versions", s.postFolderVersionsRestore) // folder - postRestMux.HandleFunc("/rest/system/config", s.postSystemConfig) // - postRestMux.HandleFunc("/rest/system/error", s.postSystemError) // - postRestMux.HandleFunc("/rest/system/error/clear", s.postSystemErrorClear) // - - postRestMux.HandleFunc("/rest/system/ping", s.restPing) // - - postRestMux.HandleFunc("/rest/system/reset", s.postSystemReset) // [folder] - postRestMux.HandleFunc("/rest/system/restart", s.postSystemRestart) // - - postRestMux.HandleFunc("/rest/system/shutdown", s.postSystemShutdown) // - - postRestMux.HandleFunc("/rest/system/upgrade", s.postSystemUpgrade) // - - postRestMux.HandleFunc("/rest/system/pause", s.makeDevicePauseHandler(true)) // [device] - postRestMux.HandleFunc("/rest/system/resume", s.makeDevicePauseHandler(false)) // [device] - postRestMux.HandleFunc("/rest/system/debug", s.postSystemDebug) // [enable] [disable] + restMux.HandlerFunc(http.MethodPost, "/rest/db/prio", s.postDBPrio) // folder file [perpage] [page] + restMux.HandlerFunc(http.MethodPost, "/rest/db/ignores", s.postDBIgnores) // folder + restMux.HandlerFunc(http.MethodPost, "/rest/db/override", s.postDBOverride) // folder + restMux.HandlerFunc(http.MethodPost, "/rest/db/revert", s.postDBRevert) // folder + restMux.HandlerFunc(http.MethodPost, "/rest/db/scan", s.postDBScan) // folder [sub...] [delay] + restMux.HandlerFunc(http.MethodPost, "/rest/folder/versions", s.postFolderVersionsRestore) // folder + restMux.HandlerFunc(http.MethodPost, "/rest/system/error", s.postSystemError) // + restMux.HandlerFunc(http.MethodPost, "/rest/system/error/clear", s.postSystemErrorClear) // - + restMux.HandlerFunc(http.MethodPost, "/rest/system/ping", s.restPing) // - + restMux.HandlerFunc(http.MethodPost, "/rest/system/reset", s.postSystemReset) // [folder] + restMux.HandlerFunc(http.MethodPost, "/rest/system/restart", s.postSystemRestart) // - + restMux.HandlerFunc(http.MethodPost, "/rest/system/shutdown", s.postSystemShutdown) // - + restMux.HandlerFunc(http.MethodPost, "/rest/system/upgrade", s.postSystemUpgrade) // - + restMux.HandlerFunc(http.MethodPost, "/rest/system/pause", s.makeDevicePauseHandler(true)) // [device] + restMux.HandlerFunc(http.MethodPost, "/rest/system/resume", s.makeDevicePauseHandler(false)) // [device] + restMux.HandlerFunc(http.MethodPost, "/rest/system/debug", s.postSystemDebug) // [enable] [disable] + + // Config endpoints + + configBuilder := &configMuxBuilder{ + Router: restMux, + id: s.id, + cfg: s.cfg, + mut: sync.NewMutex(), + } + + configBuilder.registerConfig("/rest/config/") + configBuilder.registerConfigInsync("/rest/config/insync") + configBuilder.registerFolders("/rest/config/folders") + configBuilder.registerDevices("/rest/config/devices") + configBuilder.registerFolder("/rest/config/folders/:id") + configBuilder.registerDevice("/rest/config/devices/:id") + configBuilder.registerOptions("/rest/config/options") + configBuilder.registerLDAP("/rest/config/ldap") + configBuilder.registerGUI("/rest/config/gui") + + // Deprecated config endpoints + configBuilder.registerConfigDeprecated("/rest/system/config") // POST instead of PUT + configBuilder.registerConfigInsync("/rest/system/config/insync") // Debug endpoints, not for general use debugMux := http.NewServeMux() @@ -305,15 +323,14 @@ func (s *service) serve(ctx context.Context) { debugMux.HandleFunc("/rest/debug/cpuprof", s.getCPUProf) // duration debugMux.HandleFunc("/rest/debug/heapprof", s.getHeapProf) debugMux.HandleFunc("/rest/debug/support", s.getSupportBundle) - getRestMux.Handle("/rest/debug/", s.whenDebugging(debugMux)) + restMux.Handler(http.MethodGet, "/rest/debug/", s.whenDebugging(debugMux)) - // A handler that splits requests between the two above and disables - // caching - restMux := noCacheMiddleware(metricsMiddleware(getPostHandler(getRestMux, postRestMux))) + // A handler that disables caching + noCacheRestMux := noCacheMiddleware(metricsMiddleware(restMux)) // The main routing handler mux := http.NewServeMux() - mux.Handle("/rest/", restMux) + mux.Handle("/rest/", noCacheRestMux) mux.HandleFunc("/qr/", s.getQR) // Serve compiled in assets unless an asset directory was set (for development) @@ -446,19 +463,6 @@ func (s *service) CommitConfiguration(from, to config.Configuration) bool { return true } -func getPostHandler(get, post http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "GET": - get.ServeHTTP(w, r) - case "POST": - post.ServeHTTP(w, r) - default: - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - } - }) -} - func debugMiddleware(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t0 := time.Now() @@ -837,57 +841,6 @@ func (s *service) getDBFile(w http.ResponseWriter, r *http.Request) { }) } -func (s *service) getSystemConfig(w http.ResponseWriter, r *http.Request) { - sendJSON(w, s.cfg.RawCopy()) -} - -func (s *service) postSystemConfig(w http.ResponseWriter, r *http.Request) { - s.systemConfigMut.Lock() - defer s.systemConfigMut.Unlock() - - to, err := config.ReadJSON(r.Body, s.id) - r.Body.Close() - if err != nil { - l.Warnln("Decoding posted config:", err) - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - if to.GUI.Password != s.cfg.GUI().Password { - if to.GUI.Password != "" && !bcryptExpr.MatchString(to.GUI.Password) { - hash, err := bcrypt.GenerateFromPassword([]byte(to.GUI.Password), 0) - if err != nil { - l.Warnln("bcrypting password:", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - to.GUI.Password = string(hash) - } - } - - // Activate and save. Wait for the configuration to become active before - // completing the request. - - if wg, err := s.cfg.Replace(to); err != nil { - l.Warnln("Replacing config:", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } else { - wg.Wait() - } - - if err := s.cfg.Save(); err != nil { - l.Warnln("Saving config:", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } -} - -func (s *service) getSystemConfigInsync(w http.ResponseWriter, r *http.Request) { - sendJSON(w, map[string]bool{"configInSync": !s.cfg.RequiresRestart()}) -} - func (s *service) postSystemRestart(w http.ResponseWriter, r *http.Request) { s.flushResponse(`{"ok": "restarting"}`, w) go s.contr.Restart() diff --git a/lib/api/api_test.go b/lib/api/api_test.go index 544432711..4ef45c956 100644 --- a/lib/api/api_test.go +++ b/lib/api/api_test.go @@ -40,8 +40,13 @@ import ( var ( confDir = filepath.Join("testdata", "config") token = filepath.Join(confDir, "csrftokens.txt") + dev1 protocol.DeviceID ) +func init() { + dev1, _ = protocol.DeviceIDFromString("AIR6LPZ-7K4PTTV-UXQSMUU-CPQ5YWH-OEDFIIQ-JUG777G-2YQXXR5-YD6AWQR") +} + func TestMain(m *testing.M) { orig := locations.GetBaseDir(locations.ConfigBaseDir) locations.SetBaseDir(locations.ConfigBaseDir, confDir) @@ -396,6 +401,56 @@ func TestAPIServiceRequests(t *testing.T) { Type: "text/plain", Prefix: "", }, + + // /rest/config + { + URL: "/rest/config/folders", + Code: 200, + Type: "application/json", + Prefix: "", + }, + { + URL: "/rest/config/folders/missing", + Code: 404, + Type: "text/plain", + Prefix: "", + }, + { + URL: "/rest/config/devices", + Code: 200, + Type: "application/json", + Prefix: "", + }, + { + URL: "/rest/config/devices/illegalid", + Code: 400, + Type: "text/plain", + Prefix: "", + }, + { + URL: "/rest/config/devices/" + protocol.GlobalDeviceID.String(), + Code: 404, + Type: "text/plain", + Prefix: "", + }, + { + URL: "/rest/config/options", + Code: 200, + Type: "application/json", + Prefix: "{", + }, + { + URL: "/rest/config/gui", + Code: 200, + Type: "application/json", + Prefix: "{", + }, + { + URL: "/rest/config/ldap", + Code: 200, + Type: "application/json", + Prefix: "{", + }, } for _, tc := range cases { @@ -520,7 +575,7 @@ func TestHTTPLogin(t *testing.T) { } } -func startHTTP(cfg *mockedConfig) (string, *suture.Supervisor, error) { +func startHTTP(cfg config.Wrapper) (string, *suture.Supervisor, error) { m := new(mockedModel) assetDir := "../../gui" eventSub := new(mockedEventSub) @@ -552,7 +607,7 @@ func startHTTP(cfg *mockedConfig) (string, *suture.Supervisor, error) { return "", nil, fmt.Errorf("weird address from API service: %w", err) } - host, _, _ := net.SplitHostPort(cfg.gui.RawAddress) + host, _, _ := net.SplitHostPort(cfg.GUI().RawAddress) if host == "" || host == "0.0.0.0" { host = "127.0.0.1" } @@ -1174,6 +1229,127 @@ func TestShouldRegenerateCertificate(t *testing.T) { } } +func TestConfigChanges(t *testing.T) { + t.Parallel() + + const testAPIKey = "foobarbaz" + cfg := config.Configuration{ + GUI: config.GUIConfiguration{ + RawAddress: "127.0.0.1:0", + RawUseTLS: false, + APIKey: testAPIKey, + }, + } + tmpFile, err := ioutil.TempFile("", "syncthing-testConfig-") + if err != nil { + panic(err) + } + defer os.Remove(tmpFile.Name()) + w := config.Wrap(tmpFile.Name(), cfg, events.NoopLogger) + tmpFile.Close() + baseURL, sup, err := startHTTP(w) + if err != nil { + t.Fatal("Unexpected error from getting base URL:", err) + } + defer sup.Stop() + + cli := &http.Client{ + Timeout: time.Second, + } + + do := func(req *http.Request, status int) *http.Response { + t.Helper() + req.Header.Set("X-API-Key", testAPIKey) + resp, err := cli.Do(req) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != status { + t.Errorf("Expected status %v, got %v", status, resp.StatusCode) + } + return resp + } + + mod := func(method, path string, data interface{}) { + t.Helper() + bs, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + req, _ := http.NewRequest(method, baseURL+path, bytes.NewReader(bs)) + do(req, http.StatusOK).Body.Close() + } + + get := func(path string) *http.Response { + t.Helper() + req, _ := http.NewRequest(http.MethodGet, baseURL+path, nil) + return do(req, http.StatusOK) + } + + dev1Path := "/rest/config/devices/" + dev1.String() + + // Create device + mod(http.MethodPut, "/rest/config/devices", []config.DeviceConfiguration{{DeviceID: dev1}}) + + // Check its there + get(dev1Path).Body.Close() + + // Modify just a single attribute + mod(http.MethodPatch, dev1Path, map[string]bool{"Paused": true}) + + // Check that attribute + resp := get(dev1Path) + var dev config.DeviceConfiguration + if err := unmarshalTo(resp.Body, &dev); err != nil { + t.Fatal(err) + } + if !dev.Paused { + t.Error("Expected device to be paused") + } + + folder2Path := "/rest/config/folders/folder2" + + // Create a folder and add another + mod(http.MethodPut, "/rest/config/folders", []config.FolderConfiguration{{ID: "folder1", Path: "folder1"}}) + mod(http.MethodPut, folder2Path, config.FolderConfiguration{ID: "folder2", Path: "folder2"}) + + // Check they are there + get("/rest/config/folders/folder1").Body.Close() + get(folder2Path).Body.Close() + + // Modify just a single attribute + mod(http.MethodPatch, folder2Path, map[string]bool{"Paused": true}) + + // Check that attribute + resp = get(folder2Path) + var folder config.FolderConfiguration + if err := unmarshalTo(resp.Body, &folder); err != nil { + t.Fatal(err) + } + if !dev.Paused { + t.Error("Expected folder to be paused") + } + + // Delete folder2 + req, _ := http.NewRequest(http.MethodDelete, baseURL+folder2Path, nil) + do(req, http.StatusOK) + + // Check folder1 is still there and folder2 gone + get("/rest/config/folders/folder1").Body.Close() + req, _ = http.NewRequest(http.MethodGet, baseURL+folder2Path, nil) + do(req, http.StatusNotFound) + + mod(http.MethodPatch, "/rest/config/options", map[string]int{"maxSendKbps": 50}) + resp = get("/rest/config/options") + var opts config.OptionsConfiguration + if err := unmarshalTo(resp.Body, &opts); err != nil { + t.Fatal(err) + } + if opts.MaxSendKbps != 50 { + t.Error("Exepcted 50 for MaxSendKbps, got", opts.MaxSendKbps) + } +} + func equalStrings(a, b []string) bool { if len(a) != len(b) { return false diff --git a/lib/api/confighandler.go b/lib/api/confighandler.go new file mode 100644 index 000000000..0bf3b1205 --- /dev/null +++ b/lib/api/confighandler.go @@ -0,0 +1,378 @@ +// Copyright (C) 2020 The Syncthing Authors. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +package api + +import ( + "encoding/json" + "io" + "io/ioutil" + "net/http" + + "github.com/julienschmidt/httprouter" + "golang.org/x/crypto/bcrypt" + + "github.com/syncthing/syncthing/lib/config" + "github.com/syncthing/syncthing/lib/protocol" + "github.com/syncthing/syncthing/lib/sync" +) + +type configMuxBuilder struct { + *httprouter.Router + id protocol.DeviceID + cfg config.Wrapper + mut sync.Mutex +} + +func (c *configMuxBuilder) registerConfig(path string) { + c.HandlerFunc(http.MethodGet, path, func(w http.ResponseWriter, _ *http.Request) { + sendJSON(w, c.cfg.RawCopy()) + }) + + c.HandlerFunc(http.MethodPut, path, func(w http.ResponseWriter, r *http.Request) { + c.adjustConfig(w, r) + }) +} + +func (c *configMuxBuilder) registerConfigDeprecated(path string) { + c.HandlerFunc(http.MethodGet, path, func(w http.ResponseWriter, _ *http.Request) { + sendJSON(w, c.cfg.RawCopy()) + }) + + c.HandlerFunc(http.MethodPost, path, func(w http.ResponseWriter, r *http.Request) { + c.adjustConfig(w, r) + }) +} + +func (c *configMuxBuilder) registerConfigInsync(path string) { + c.HandlerFunc(http.MethodGet, path, func(w http.ResponseWriter, _ *http.Request) { + sendJSON(w, map[string]bool{"configInSync": !c.cfg.RequiresRestart()}) + }) +} + +func (c *configMuxBuilder) registerFolders(path string) { + c.HandlerFunc(http.MethodGet, path, func(w http.ResponseWriter, _ *http.Request) { + sendJSON(w, c.cfg.FolderList()) + }) + + c.HandlerFunc(http.MethodPut, path, func(w http.ResponseWriter, r *http.Request) { + c.mut.Lock() + defer c.mut.Unlock() + var folders []config.FolderConfiguration + if err := unmarshalTo(r.Body, &folders); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + waiter, err := c.cfg.SetFolders(folders) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + c.finish(w, waiter) + }) + + c.HandlerFunc(http.MethodPost, path, func(w http.ResponseWriter, r *http.Request) { + c.mut.Lock() + defer c.mut.Unlock() + var folder config.FolderConfiguration + if err := unmarshalTo(r.Body, &folder); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + waiter, err := c.cfg.SetFolder(folder) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + c.finish(w, waiter) + }) +} + +func (c *configMuxBuilder) registerDevices(path string) { + c.HandlerFunc(http.MethodGet, path, func(w http.ResponseWriter, _ *http.Request) { + sendJSON(w, c.cfg.DeviceList()) + }) + + c.HandlerFunc(http.MethodPut, path, func(w http.ResponseWriter, r *http.Request) { + c.mut.Lock() + defer c.mut.Unlock() + var devices []config.DeviceConfiguration + if err := unmarshalTo(r.Body, &devices); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + waiter, err := c.cfg.SetDevices(devices) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + c.finish(w, waiter) + }) + + c.HandlerFunc(http.MethodPost, path, func(w http.ResponseWriter, r *http.Request) { + c.mut.Lock() + defer c.mut.Unlock() + var device config.DeviceConfiguration + if err := unmarshalTo(r.Body, &device); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + waiter, err := c.cfg.SetDevice(device) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + c.finish(w, waiter) + }) +} + +func (c *configMuxBuilder) registerFolder(path string) { + c.Handle(http.MethodGet, path, func(w http.ResponseWriter, _ *http.Request, p httprouter.Params) { + folder, ok := c.cfg.Folder(p.ByName("id")) + if !ok { + http.Error(w, "No folder with given ID", http.StatusNotFound) + return + } + sendJSON(w, folder) + }) + + c.Handle(http.MethodPut, path, func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + c.adjustFolder(w, r, config.FolderConfiguration{}) + }) + + c.Handle(http.MethodPatch, path, func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + folder, ok := c.cfg.Folder(p.ByName("id")) + if !ok { + http.Error(w, "No folder with given ID", http.StatusNotFound) + return + } + c.adjustFolder(w, r, folder) + }) + + c.Handle(http.MethodDelete, path, func(w http.ResponseWriter, _ *http.Request, p httprouter.Params) { + waiter, err := c.cfg.RemoveFolder(p.ByName("id")) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + c.finish(w, waiter) + }) +} + +func (c *configMuxBuilder) registerDevice(path string) { + deviceFromParams := func(w http.ResponseWriter, p httprouter.Params) (config.DeviceConfiguration, bool) { + id, err := protocol.DeviceIDFromString(p.ByName("id")) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return config.DeviceConfiguration{}, false + } + device, ok := c.cfg.Device(id) + if !ok { + http.Error(w, "No device with given ID", http.StatusNotFound) + return config.DeviceConfiguration{}, false + } + return device, true + } + + c.Handle(http.MethodGet, path, func(w http.ResponseWriter, _ *http.Request, p httprouter.Params) { + if device, ok := deviceFromParams(w, p); ok { + sendJSON(w, device) + } + }) + + c.Handle(http.MethodPut, path, func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + c.adjustDevice(w, r, config.DeviceConfiguration{}) + }) + + c.Handle(http.MethodPatch, path, func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + if device, ok := deviceFromParams(w, p); ok { + c.adjustDevice(w, r, device) + } + }) + + c.Handle(http.MethodDelete, path, func(w http.ResponseWriter, _ *http.Request, p httprouter.Params) { + id, err := protocol.DeviceIDFromString(p.ByName("id")) + waiter, err := c.cfg.RemoveDevice(id) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + c.finish(w, waiter) + }) +} + +func (c *configMuxBuilder) registerOptions(path string) { + c.HandlerFunc(http.MethodGet, path, func(w http.ResponseWriter, _ *http.Request) { + sendJSON(w, c.cfg.Options()) + }) + + c.HandlerFunc(http.MethodPut, path, func(w http.ResponseWriter, r *http.Request) { + c.adjustOptions(w, r, config.OptionsConfiguration{}) + }) + + c.HandlerFunc(http.MethodPatch, path, func(w http.ResponseWriter, r *http.Request) { + c.adjustOptions(w, r, c.cfg.Options()) + }) +} + +func (c *configMuxBuilder) registerLDAP(path string) { + c.HandlerFunc(http.MethodGet, path, func(w http.ResponseWriter, _ *http.Request) { + sendJSON(w, c.cfg.LDAP()) + }) + + c.HandlerFunc(http.MethodPut, path, func(w http.ResponseWriter, r *http.Request) { + c.adjustLDAP(w, r, config.LDAPConfiguration{}) + }) + + c.HandlerFunc(http.MethodPatch, path, func(w http.ResponseWriter, r *http.Request) { + c.adjustLDAP(w, r, c.cfg.LDAP()) + }) +} + +func (c *configMuxBuilder) registerGUI(path string) { + c.HandlerFunc(http.MethodGet, path, func(w http.ResponseWriter, _ *http.Request) { + sendJSON(w, c.cfg.GUI()) + }) + + c.HandlerFunc(http.MethodPut, path, func(w http.ResponseWriter, r *http.Request) { + c.adjustGUI(w, r, config.GUIConfiguration{}) + }) + + c.HandlerFunc(http.MethodPatch, path, func(w http.ResponseWriter, r *http.Request) { + c.adjustGUI(w, r, c.cfg.GUI()) + }) +} + +func (c *configMuxBuilder) adjustConfig(w http.ResponseWriter, r *http.Request) { + c.mut.Lock() + defer c.mut.Unlock() + cfg, err := config.ReadJSON(r.Body, c.id) + r.Body.Close() + if err != nil { + l.Warnln("Decoding posted config:", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if cfg.GUI.Password, err = checkGUIPassword(c.cfg.GUI().Password, cfg.GUI.Password); err != nil { + l.Warnln("bcrypting password:", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + waiter, err := c.cfg.Replace(cfg) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + c.finish(w, waiter) +} + +func (c *configMuxBuilder) adjustFolder(w http.ResponseWriter, r *http.Request, folder config.FolderConfiguration) { + c.mut.Lock() + defer c.mut.Unlock() + if err := unmarshalTo(r.Body, &folder); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + waiter, err := c.cfg.SetFolder(folder) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + c.finish(w, waiter) +} + +func (c *configMuxBuilder) adjustDevice(w http.ResponseWriter, r *http.Request, device config.DeviceConfiguration) { + c.mut.Lock() + defer c.mut.Unlock() + if err := unmarshalTo(r.Body, &device); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + waiter, err := c.cfg.SetDevice(device) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + c.finish(w, waiter) +} + +func (c *configMuxBuilder) adjustOptions(w http.ResponseWriter, r *http.Request, opts config.OptionsConfiguration) { + c.mut.Lock() + defer c.mut.Unlock() + if err := unmarshalTo(r.Body, &opts); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + waiter, err := c.cfg.SetOptions(opts) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + c.finish(w, waiter) +} + +func (c *configMuxBuilder) adjustGUI(w http.ResponseWriter, r *http.Request, gui config.GUIConfiguration) { + c.mut.Lock() + defer c.mut.Unlock() + oldPassword := gui.Password + err := unmarshalTo(r.Body, &gui) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if gui.Password, err = checkGUIPassword(oldPassword, gui.Password); err != nil { + l.Warnln("bcrypting password:", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + waiter, err := c.cfg.SetGUI(gui) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + c.finish(w, waiter) +} + +func (c *configMuxBuilder) adjustLDAP(w http.ResponseWriter, r *http.Request, ldap config.LDAPConfiguration) { + c.mut.Lock() + defer c.mut.Unlock() + if err := unmarshalTo(r.Body, &ldap); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + waiter, err := c.cfg.SetLDAP(ldap) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + c.finish(w, waiter) +} + +// Unmarshals the content of the given body and stores it in to (i.e. to must be a pointer). +func unmarshalTo(body io.ReadCloser, to interface{}) error { + bs, err := ioutil.ReadAll(body) + body.Close() + if err != nil { + return err + } + return json.Unmarshal(bs, to) +} + +func checkGUIPassword(oldPassword, newPassword string) (string, error) { + if newPassword == oldPassword { + return newPassword, nil + } + hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 0) + return string(hash), err +} + +func (c *configMuxBuilder) finish(w http.ResponseWriter, waiter config.Waiter) { + waiter.Wait() + if err := c.cfg.Save(); err != nil { + l.Warnln("Saving config:", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} diff --git a/lib/api/mocked_config_test.go b/lib/api/mocked_config_test.go index 1ac538d4b..9ff426ae8 100644 --- a/lib/api/mocked_config_test.go +++ b/lib/api/mocked_config_test.go @@ -28,6 +28,10 @@ func (c *mockedConfig) LDAP() config.LDAPConfiguration { return config.LDAPConfiguration{} } +func (c *mockedConfig) SetLDAP(config.LDAPConfiguration) (config.Waiter, error) { + return noopWaiter{}, nil +} + func (c *mockedConfig) RawCopy() config.Configuration { cfg := config.Configuration{} util.SetDefaults(&cfg.Options) @@ -54,6 +58,10 @@ func (c *mockedConfig) Devices() map[protocol.DeviceID]config.DeviceConfiguratio return nil } +func (c *mockedConfig) DeviceList() []config.DeviceConfiguration { + return nil +} + func (c *mockedConfig) SetDevice(config.DeviceConfiguration) (config.Waiter, error) { return noopWaiter{}, nil } @@ -102,6 +110,10 @@ func (c *mockedConfig) SetFolders(folders []config.FolderConfiguration) (config. return noopWaiter{}, nil } +func (c *mockedConfig) RemoveFolder(id string) (config.Waiter, error) { + return noopWaiter{}, nil +} + func (c *mockedConfig) Device(id protocol.DeviceID) (config.DeviceConfiguration, bool) { return config.DeviceConfiguration{}, false } diff --git a/lib/config/config.go b/lib/config/config.go index c7a760daf..dd7a26175 100644 --- a/lib/config/config.go +++ b/lib/config/config.go @@ -304,7 +304,9 @@ func (cfg *Configuration) clean() error { } // Upgrade configuration versions as appropriate + migrationsMut.Lock() migrations.apply(cfg) + migrationsMut.Unlock() // Build a list of available devices existingDevices := make(map[protocol.DeviceID]bool) diff --git a/lib/config/migrations.go b/lib/config/migrations.go index 5f47b0d37..09a23658d 100644 --- a/lib/config/migrations.go +++ b/lib/config/migrations.go @@ -14,6 +14,7 @@ import ( "runtime" "sort" "strings" + "sync" "github.com/syncthing/syncthing/lib/fs" "github.com/syncthing/syncthing/lib/upgrade" @@ -24,30 +25,33 @@ import ( // config version. The conversion function can be nil in which case we just // update the config version. The order of migrations doesn't matter here, // put the newest on top for readability. -var migrations = migrationSet{ - {32, migrateToConfigV32}, - {31, migrateToConfigV31}, - {30, migrateToConfigV30}, - {29, migrateToConfigV29}, - {28, migrateToConfigV28}, - {27, migrateToConfigV27}, - {26, nil}, // triggers database update - {25, migrateToConfigV25}, - {24, migrateToConfigV24}, - {23, migrateToConfigV23}, - {22, migrateToConfigV22}, - {21, migrateToConfigV21}, - {20, migrateToConfigV20}, - {19, nil}, // Triggers a database tweak - {18, migrateToConfigV18}, - {17, nil}, // Fsync = true removed - {16, nil}, // Triggers a database tweak - {15, migrateToConfigV15}, - {14, migrateToConfigV14}, - {13, migrateToConfigV13}, - {12, migrateToConfigV12}, - {11, migrateToConfigV11}, -} +var ( + migrations = migrationSet{ + {32, migrateToConfigV32}, + {31, migrateToConfigV31}, + {30, migrateToConfigV30}, + {29, migrateToConfigV29}, + {28, migrateToConfigV28}, + {27, migrateToConfigV27}, + {26, nil}, // triggers database update + {25, migrateToConfigV25}, + {24, migrateToConfigV24}, + {23, migrateToConfigV23}, + {22, migrateToConfigV22}, + {21, migrateToConfigV21}, + {20, migrateToConfigV20}, + {19, nil}, // Triggers a database tweak + {18, migrateToConfigV18}, + {17, nil}, // Fsync = true removed + {16, nil}, // Triggers a database tweak + {15, migrateToConfigV15}, + {14, migrateToConfigV14}, + {13, migrateToConfigV13}, + {12, migrateToConfigV12}, + {11, migrateToConfigV11}, + } + migrationsMut = sync.Mutex{} +) type migrationSet []migration diff --git a/lib/config/migrations_test.go b/lib/config/migrations_test.go index e8e43faed..9e4bf1114 100644 --- a/lib/config/migrations_test.go +++ b/lib/config/migrations_test.go @@ -26,7 +26,9 @@ func TestMigrateCrashReporting(t *testing.T) { for i, tc := range cases { cfg := Configuration{Version: 28, Options: tc.opts} + migrationsMut.Lock() migrations.apply(&cfg) + migrationsMut.Unlock() if cfg.Options.CREnabled != tc.enabled { t.Errorf("%d: unexpected result, CREnabled: %v != %v", i, cfg.Options.CREnabled, tc.enabled) } diff --git a/lib/config/wrapper.go b/lib/config/wrapper.go index 375d774b9..8d0620f25 100644 --- a/lib/config/wrapper.go +++ b/lib/config/wrapper.go @@ -64,6 +64,7 @@ type Wrapper interface { GUI() GUIConfiguration SetGUI(gui GUIConfiguration) (Waiter, error) LDAP() LDAPConfiguration + SetLDAP(ldap LDAPConfiguration) (Waiter, error) Options() OptionsConfiguration SetOptions(opts OptionsConfiguration) (Waiter, error) @@ -71,11 +72,13 @@ type Wrapper interface { Folder(id string) (FolderConfiguration, bool) Folders() map[string]FolderConfiguration FolderList() []FolderConfiguration + RemoveFolder(id string) (Waiter, error) SetFolder(fld FolderConfiguration) (Waiter, error) SetFolders(folders []FolderConfiguration) (Waiter, error) Device(id protocol.DeviceID) (DeviceConfiguration, bool) Devices() map[protocol.DeviceID]DeviceConfiguration + DeviceList() []DeviceConfiguration RemoveDevice(id protocol.DeviceID) (Waiter, error) SetDevice(DeviceConfiguration) (Waiter, error) SetDevices([]DeviceConfiguration) (Waiter, error) @@ -230,6 +233,13 @@ func (w *wrapper) Devices() map[protocol.DeviceID]DeviceConfiguration { return deviceMap } +// DeviceList returns a slice of devices. +func (w *wrapper) DeviceList() []DeviceConfiguration { + w.mut.Lock() + defer w.mut.Unlock() + return w.cfg.Copy().Devices +} + // SetDevices adds new devices to the configuration, or overwrites existing // devices with the same ID. func (w *wrapper) SetDevices(devs []DeviceConfiguration) (Waiter, error) { @@ -327,6 +337,22 @@ func (w *wrapper) SetFolders(folders []FolderConfiguration) (Waiter, error) { return w.replaceLocked(newCfg) } +// RemoveFolder removes the folder from the configuration +func (w *wrapper) RemoveFolder(id string) (Waiter, error) { + w.mut.Lock() + defer w.mut.Unlock() + + newCfg := w.cfg.Copy() + for i := range newCfg.Folders { + if newCfg.Folders[i].ID == id { + newCfg.Folders = append(newCfg.Folders[:i], newCfg.Folders[i+1:]...) + return w.replaceLocked(newCfg) + } + } + + return noopWaiter{}, nil +} + // Options returns the current options configuration object. func (w *wrapper) Options() OptionsConfiguration { w.mut.Lock() @@ -349,6 +375,14 @@ func (w *wrapper) LDAP() LDAPConfiguration { return w.cfg.LDAP.Copy() } +func (w *wrapper) SetLDAP(ldap LDAPConfiguration) (Waiter, error) { + w.mut.Lock() + defer w.mut.Unlock() + newCfg := w.cfg.Copy() + newCfg.LDAP = ldap.Copy() + return w.replaceLocked(newCfg) +} + // GUI returns the current GUI configuration object. func (w *wrapper) GUI() GUIConfiguration { w.mut.Lock() diff --git a/lib/protocol/deviceid.go b/lib/protocol/deviceid.go index c76b393fb..00f805967 100644 --- a/lib/protocol/deviceid.go +++ b/lib/protocol/deviceid.go @@ -84,7 +84,7 @@ func (n DeviceID) Short() ShortID { return ShortID(binary.BigEndian.Uint64(n[:])) } -func (n *DeviceID) MarshalText() ([]byte, error) { +func (n DeviceID) MarshalText() ([]byte, error) { return []byte(n.String()), nil }