Explorar o código

lib/api: Add /rest/config endpoint (fixes #6540) (#7001)

Simon Frei %!s(int64=5) %!d(string=hai) anos
pai
achega
f0f60ba2e7

+ 1 - 0
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

+ 1 - 0
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=

+ 4 - 4
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) {

+ 76 - 123
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 <body>
-	postRestMux.HandleFunc("/rest/system/config", s.postSystemConfig)              // <body>
-	postRestMux.HandleFunc("/rest/system/error", s.postSystemError)                // <body>
-	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 <body>
+	restMux.HandlerFunc(http.MethodPost, "/rest/system/error", s.postSystemError)                // <body>
+	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()

+ 178 - 2
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

+ 378 - 0
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)
+	}
+}

+ 12 - 0
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
 }

+ 2 - 0
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)

+ 28 - 24
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
 

+ 2 - 0
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)
 		}

+ 34 - 0
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()

+ 1 - 1
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
 }