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

This commit is contained in:
Simon Frei 2020-10-22 19:54:35 +02:00 committed by GitHub
parent 1c2be84e4e
commit f0f60ba2e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 717 additions and 154 deletions

1
go.mod
View File

@ -24,6 +24,7 @@ require (
github.com/greatroar/blobloom v0.3.0 github.com/greatroar/blobloom v0.3.0
github.com/jackpal/gateway v1.0.6 github.com/jackpal/gateway v1.0.6
github.com/jackpal/go-nat-pmp v1.0.2 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/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/kr/pretty v0.2.0 // indirect github.com/kr/pretty v0.2.0 // indirect
github.com/lib/pq v1.2.0 github.com/lib/pq v1.2.0

1
go.sum
View File

@ -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.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 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/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/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 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=

View File

@ -268,7 +268,7 @@ angular.module('syncthing.core')
$scope.$on(Events.CONFIG_SAVED, function (event, arg) { $scope.$on(Events.CONFIG_SAVED, function (event, arg) {
updateLocalConfig(arg.data); updateLocalConfig(arg.data);
$http.get(urlbase + '/system/config/insync').success(function (data) { $http.get(urlbase + '/config/insync').success(function (data) {
$scope.configInSync = data.configInSync; $scope.configInSync = data.configInSync;
}).error($scope.emitHTTPError); }).error($scope.emitHTTPError);
}); });
@ -578,12 +578,12 @@ angular.module('syncthing.core')
} }
function refreshConfig() { function refreshConfig() {
$http.get(urlbase + '/system/config').success(function (data) { $http.get(urlbase + '/config').success(function (data) {
updateLocalConfig(data); updateLocalConfig(data);
console.log("refreshConfig", data); console.log("refreshConfig", data);
}).error($scope.emitHTTPError); }).error($scope.emitHTTPError);
$http.get(urlbase + '/system/config/insync').success(function (data) { $http.get(urlbase + '/config/insync').success(function (data) {
$scope.configInSync = data.configInSync; $scope.configInSync = data.configInSync;
}).error($scope.emitHTTPError); }).error($scope.emitHTTPError);
} }
@ -1257,7 +1257,7 @@ angular.module('syncthing.core')
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}; };
$http.post(urlbase + '/system/config', cfg, opts).success(function () { $http.put(urlbase + '/config', cfg, opts).success(function () {
refreshConfig(); refreshConfig();
if (callback) { if (callback) {

View File

@ -31,10 +31,10 @@ import (
"strings" "strings"
"time" "time"
"github.com/julienschmidt/httprouter"
metrics "github.com/rcrowley/go-metrics" metrics "github.com/rcrowley/go-metrics"
"github.com/thejerf/suture" "github.com/thejerf/suture"
"github.com/vitrun/qart/qr" "github.com/vitrun/qart/qr"
"golang.org/x/crypto/bcrypt"
"github.com/syncthing/syncthing/lib/build" "github.com/syncthing/syncthing/lib/build"
"github.com/syncthing/syncthing/lib/config" "github.com/syncthing/syncthing/lib/config"
@ -81,7 +81,6 @@ type service struct {
connectionsService connections.Service connectionsService connections.Service
fss model.FolderSummaryService fss model.FolderSummaryService
urService *ur.Service urService *ur.Service
systemConfigMut sync.Mutex // serializes posts to /rest/system/config
contr Controller contr Controller
noUpgrade bool noUpgrade bool
tlsDefaultCommonName string tlsDefaultCommonName string
@ -123,7 +122,6 @@ func New(id protocol.DeviceID, cfg config.Wrapper, assetDir, tlsDefaultCommonNam
connectionsService: connectionsService, connectionsService: connectionsService,
fss: fss, fss: fss,
urService: urService, urService: urService,
systemConfigMut: sync.NewMutex(),
guiErrors: errors, guiErrors: errors,
systemLog: systemLog, systemLog: systemLog,
contr: contr, contr: contr,
@ -243,60 +241,80 @@ func (s *service) serve(ctx context.Context) {
s.cfg.Subscribe(s) s.cfg.Subscribe(s)
defer s.cfg.Unsubscribe(s) defer s.cfg.Unsubscribe(s)
restMux := httprouter.New()
// The GET handlers // The GET handlers
getRestMux := http.NewServeMux() restMux.HandlerFunc(http.MethodGet, "/rest/db/completion", s.getDBCompletion) // [device] [folder]
getRestMux.HandleFunc("/rest/db/completion", s.getDBCompletion) // [device] [folder] restMux.HandlerFunc(http.MethodGet, "/rest/db/file", s.getDBFile) // folder file
getRestMux.HandleFunc("/rest/db/file", s.getDBFile) // folder file restMux.HandlerFunc(http.MethodGet, "/rest/db/ignores", s.getDBIgnores) // folder
getRestMux.HandleFunc("/rest/db/ignores", s.getDBIgnores) // folder restMux.HandlerFunc(http.MethodGet, "/rest/db/need", s.getDBNeed) // folder [perpage] [page]
getRestMux.HandleFunc("/rest/db/need", s.getDBNeed) // folder [perpage] [page] restMux.HandlerFunc(http.MethodGet, "/rest/db/remoteneed", s.getDBRemoteNeed) // device folder [perpage] [page]
getRestMux.HandleFunc("/rest/db/remoteneed", s.getDBRemoteNeed) // device folder [perpage] [page] restMux.HandlerFunc(http.MethodGet, "/rest/db/localchanged", s.getDBLocalChanged) // folder
getRestMux.HandleFunc("/rest/db/localchanged", s.getDBLocalChanged) // folder restMux.HandlerFunc(http.MethodGet, "/rest/db/status", s.getDBStatus) // folder
getRestMux.HandleFunc("/rest/db/status", s.getDBStatus) // folder restMux.HandlerFunc(http.MethodGet, "/rest/db/browse", s.getDBBrowse) // folder [prefix] [dirsonly] [levels]
getRestMux.HandleFunc("/rest/db/browse", s.getDBBrowse) // folder [prefix] [dirsonly] [levels] restMux.HandlerFunc(http.MethodGet, "/rest/folder/versions", s.getFolderVersions) // folder
getRestMux.HandleFunc("/rest/folder/versions", s.getFolderVersions) // folder restMux.HandlerFunc(http.MethodGet, "/rest/folder/errors", s.getFolderErrors) // folder
getRestMux.HandleFunc("/rest/folder/errors", s.getFolderErrors) // folder restMux.HandlerFunc(http.MethodGet, "/rest/folder/pullerrors", s.getFolderErrors) // folder (deprecated)
getRestMux.HandleFunc("/rest/folder/pullerrors", s.getFolderErrors) // folder (deprecated) restMux.HandlerFunc(http.MethodGet, "/rest/events", s.getIndexEvents) // [since] [limit] [timeout] [events]
getRestMux.HandleFunc("/rest/events", s.getIndexEvents) // [since] [limit] [timeout] [events] restMux.HandlerFunc(http.MethodGet, "/rest/events/disk", s.getDiskEvents) // [since] [limit] [timeout]
getRestMux.HandleFunc("/rest/events/disk", s.getDiskEvents) // [since] [limit] [timeout] restMux.HandlerFunc(http.MethodGet, "/rest/stats/device", s.getDeviceStats) // -
getRestMux.HandleFunc("/rest/stats/device", s.getDeviceStats) // - restMux.HandlerFunc(http.MethodGet, "/rest/stats/folder", s.getFolderStats) // -
getRestMux.HandleFunc("/rest/stats/folder", s.getFolderStats) // - restMux.HandlerFunc(http.MethodGet, "/rest/svc/deviceid", s.getDeviceID) // id
getRestMux.HandleFunc("/rest/svc/deviceid", s.getDeviceID) // id restMux.HandlerFunc(http.MethodGet, "/rest/svc/lang", s.getLang) // -
getRestMux.HandleFunc("/rest/svc/lang", s.getLang) // - restMux.HandlerFunc(http.MethodGet, "/rest/svc/report", s.getReport) // -
getRestMux.HandleFunc("/rest/svc/report", s.getReport) // - restMux.HandlerFunc(http.MethodGet, "/rest/svc/random/string", s.getRandomString) // [length]
getRestMux.HandleFunc("/rest/svc/random/string", s.getRandomString) // [length] restMux.HandlerFunc(http.MethodGet, "/rest/system/browse", s.getSystemBrowse) // current
getRestMux.HandleFunc("/rest/system/browse", s.getSystemBrowse) // current restMux.HandlerFunc(http.MethodGet, "/rest/system/connections", s.getSystemConnections) // -
getRestMux.HandleFunc("/rest/system/config", s.getSystemConfig) // - restMux.HandlerFunc(http.MethodGet, "/rest/system/discovery", s.getSystemDiscovery) // -
getRestMux.HandleFunc("/rest/system/config/insync", s.getSystemConfigInsync) // - restMux.HandlerFunc(http.MethodGet, "/rest/system/error", s.getSystemError) // -
getRestMux.HandleFunc("/rest/system/connections", s.getSystemConnections) // - restMux.HandlerFunc(http.MethodGet, "/rest/system/ping", s.restPing) // -
getRestMux.HandleFunc("/rest/system/discovery", s.getSystemDiscovery) // - restMux.HandlerFunc(http.MethodGet, "/rest/system/status", s.getSystemStatus) // -
getRestMux.HandleFunc("/rest/system/error", s.getSystemError) // - restMux.HandlerFunc(http.MethodGet, "/rest/system/upgrade", s.getSystemUpgrade) // -
getRestMux.HandleFunc("/rest/system/ping", s.restPing) // - restMux.HandlerFunc(http.MethodGet, "/rest/system/version", s.getSystemVersion) // -
getRestMux.HandleFunc("/rest/system/status", s.getSystemStatus) // - restMux.HandlerFunc(http.MethodGet, "/rest/system/debug", s.getSystemDebug) // -
getRestMux.HandleFunc("/rest/system/upgrade", s.getSystemUpgrade) // - restMux.HandlerFunc(http.MethodGet, "/rest/system/log", s.getSystemLog) // [since]
getRestMux.HandleFunc("/rest/system/version", s.getSystemVersion) // - restMux.HandlerFunc(http.MethodGet, "/rest/system/log.txt", s.getSystemLogTxt) // [since]
getRestMux.HandleFunc("/rest/system/debug", s.getSystemDebug) // -
getRestMux.HandleFunc("/rest/system/log", s.getSystemLog) // [since]
getRestMux.HandleFunc("/rest/system/log.txt", s.getSystemLogTxt) // [since]
// The POST handlers // The POST handlers
postRestMux := http.NewServeMux() restMux.HandlerFunc(http.MethodPost, "/rest/db/prio", s.postDBPrio) // folder file [perpage] [page]
postRestMux.HandleFunc("/rest/db/prio", s.postDBPrio) // folder file [perpage] [page] restMux.HandlerFunc(http.MethodPost, "/rest/db/ignores", s.postDBIgnores) // folder
postRestMux.HandleFunc("/rest/db/ignores", s.postDBIgnores) // folder restMux.HandlerFunc(http.MethodPost, "/rest/db/override", s.postDBOverride) // folder
postRestMux.HandleFunc("/rest/db/override", s.postDBOverride) // folder restMux.HandlerFunc(http.MethodPost, "/rest/db/revert", s.postDBRevert) // folder
postRestMux.HandleFunc("/rest/db/revert", s.postDBRevert) // folder restMux.HandlerFunc(http.MethodPost, "/rest/db/scan", s.postDBScan) // folder [sub...] [delay]
postRestMux.HandleFunc("/rest/db/scan", s.postDBScan) // folder [sub...] [delay] restMux.HandlerFunc(http.MethodPost, "/rest/folder/versions", s.postFolderVersionsRestore) // folder <body>
postRestMux.HandleFunc("/rest/folder/versions", s.postFolderVersionsRestore) // folder <body> restMux.HandlerFunc(http.MethodPost, "/rest/system/error", s.postSystemError) // <body>
postRestMux.HandleFunc("/rest/system/config", s.postSystemConfig) // <body> restMux.HandlerFunc(http.MethodPost, "/rest/system/error/clear", s.postSystemErrorClear) // -
postRestMux.HandleFunc("/rest/system/error", s.postSystemError) // <body> restMux.HandlerFunc(http.MethodPost, "/rest/system/ping", s.restPing) // -
postRestMux.HandleFunc("/rest/system/error/clear", s.postSystemErrorClear) // - restMux.HandlerFunc(http.MethodPost, "/rest/system/reset", s.postSystemReset) // [folder]
postRestMux.HandleFunc("/rest/system/ping", s.restPing) // - restMux.HandlerFunc(http.MethodPost, "/rest/system/restart", s.postSystemRestart) // -
postRestMux.HandleFunc("/rest/system/reset", s.postSystemReset) // [folder] restMux.HandlerFunc(http.MethodPost, "/rest/system/shutdown", s.postSystemShutdown) // -
postRestMux.HandleFunc("/rest/system/restart", s.postSystemRestart) // - restMux.HandlerFunc(http.MethodPost, "/rest/system/upgrade", s.postSystemUpgrade) // -
postRestMux.HandleFunc("/rest/system/shutdown", s.postSystemShutdown) // - restMux.HandlerFunc(http.MethodPost, "/rest/system/pause", s.makeDevicePauseHandler(true)) // [device]
postRestMux.HandleFunc("/rest/system/upgrade", s.postSystemUpgrade) // - restMux.HandlerFunc(http.MethodPost, "/rest/system/resume", s.makeDevicePauseHandler(false)) // [device]
postRestMux.HandleFunc("/rest/system/pause", s.makeDevicePauseHandler(true)) // [device] restMux.HandlerFunc(http.MethodPost, "/rest/system/debug", s.postSystemDebug) // [enable] [disable]
postRestMux.HandleFunc("/rest/system/resume", s.makeDevicePauseHandler(false)) // [device]
postRestMux.HandleFunc("/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 // Debug endpoints, not for general use
debugMux := http.NewServeMux() 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/cpuprof", s.getCPUProf) // duration
debugMux.HandleFunc("/rest/debug/heapprof", s.getHeapProf) debugMux.HandleFunc("/rest/debug/heapprof", s.getHeapProf)
debugMux.HandleFunc("/rest/debug/support", s.getSupportBundle) 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 // A handler that disables caching
// caching noCacheRestMux := noCacheMiddleware(metricsMiddleware(restMux))
restMux := noCacheMiddleware(metricsMiddleware(getPostHandler(getRestMux, postRestMux)))
// The main routing handler // The main routing handler
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/rest/", restMux) mux.Handle("/rest/", noCacheRestMux)
mux.HandleFunc("/qr/", s.getQR) mux.HandleFunc("/qr/", s.getQR)
// Serve compiled in assets unless an asset directory was set (for development) // 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 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 { func debugMiddleware(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t0 := time.Now() 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) { func (s *service) postSystemRestart(w http.ResponseWriter, r *http.Request) {
s.flushResponse(`{"ok": "restarting"}`, w) s.flushResponse(`{"ok": "restarting"}`, w)
go s.contr.Restart() go s.contr.Restart()

View File

@ -40,8 +40,13 @@ import (
var ( var (
confDir = filepath.Join("testdata", "config") confDir = filepath.Join("testdata", "config")
token = filepath.Join(confDir, "csrftokens.txt") 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) { func TestMain(m *testing.M) {
orig := locations.GetBaseDir(locations.ConfigBaseDir) orig := locations.GetBaseDir(locations.ConfigBaseDir)
locations.SetBaseDir(locations.ConfigBaseDir, confDir) locations.SetBaseDir(locations.ConfigBaseDir, confDir)
@ -396,6 +401,56 @@ func TestAPIServiceRequests(t *testing.T) {
Type: "text/plain", Type: "text/plain",
Prefix: "", 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 { 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) m := new(mockedModel)
assetDir := "../../gui" assetDir := "../../gui"
eventSub := new(mockedEventSub) 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) 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" { if host == "" || host == "0.0.0.0" {
host = "127.0.0.1" 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 { func equalStrings(a, b []string) bool {
if len(a) != len(b) { if len(a) != len(b) {
return false return false

378
lib/api/confighandler.go Normal file
View File

@ -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)
}
}

View File

@ -28,6 +28,10 @@ func (c *mockedConfig) LDAP() config.LDAPConfiguration {
return config.LDAPConfiguration{} return config.LDAPConfiguration{}
} }
func (c *mockedConfig) SetLDAP(config.LDAPConfiguration) (config.Waiter, error) {
return noopWaiter{}, nil
}
func (c *mockedConfig) RawCopy() config.Configuration { func (c *mockedConfig) RawCopy() config.Configuration {
cfg := config.Configuration{} cfg := config.Configuration{}
util.SetDefaults(&cfg.Options) util.SetDefaults(&cfg.Options)
@ -54,6 +58,10 @@ func (c *mockedConfig) Devices() map[protocol.DeviceID]config.DeviceConfiguratio
return nil return nil
} }
func (c *mockedConfig) DeviceList() []config.DeviceConfiguration {
return nil
}
func (c *mockedConfig) SetDevice(config.DeviceConfiguration) (config.Waiter, error) { func (c *mockedConfig) SetDevice(config.DeviceConfiguration) (config.Waiter, error) {
return noopWaiter{}, nil return noopWaiter{}, nil
} }
@ -102,6 +110,10 @@ func (c *mockedConfig) SetFolders(folders []config.FolderConfiguration) (config.
return noopWaiter{}, nil 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) { func (c *mockedConfig) Device(id protocol.DeviceID) (config.DeviceConfiguration, bool) {
return config.DeviceConfiguration{}, false return config.DeviceConfiguration{}, false
} }

View File

@ -304,7 +304,9 @@ func (cfg *Configuration) clean() error {
} }
// Upgrade configuration versions as appropriate // Upgrade configuration versions as appropriate
migrationsMut.Lock()
migrations.apply(cfg) migrations.apply(cfg)
migrationsMut.Unlock()
// Build a list of available devices // Build a list of available devices
existingDevices := make(map[protocol.DeviceID]bool) existingDevices := make(map[protocol.DeviceID]bool)

View File

@ -14,6 +14,7 @@ import (
"runtime" "runtime"
"sort" "sort"
"strings" "strings"
"sync"
"github.com/syncthing/syncthing/lib/fs" "github.com/syncthing/syncthing/lib/fs"
"github.com/syncthing/syncthing/lib/upgrade" "github.com/syncthing/syncthing/lib/upgrade"
@ -24,30 +25,33 @@ import (
// config version. The conversion function can be nil in which case we just // 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, // update the config version. The order of migrations doesn't matter here,
// put the newest on top for readability. // put the newest on top for readability.
var migrations = migrationSet{ var (
{32, migrateToConfigV32}, migrations = migrationSet{
{31, migrateToConfigV31}, {32, migrateToConfigV32},
{30, migrateToConfigV30}, {31, migrateToConfigV31},
{29, migrateToConfigV29}, {30, migrateToConfigV30},
{28, migrateToConfigV28}, {29, migrateToConfigV29},
{27, migrateToConfigV27}, {28, migrateToConfigV28},
{26, nil}, // triggers database update {27, migrateToConfigV27},
{25, migrateToConfigV25}, {26, nil}, // triggers database update
{24, migrateToConfigV24}, {25, migrateToConfigV25},
{23, migrateToConfigV23}, {24, migrateToConfigV24},
{22, migrateToConfigV22}, {23, migrateToConfigV23},
{21, migrateToConfigV21}, {22, migrateToConfigV22},
{20, migrateToConfigV20}, {21, migrateToConfigV21},
{19, nil}, // Triggers a database tweak {20, migrateToConfigV20},
{18, migrateToConfigV18}, {19, nil}, // Triggers a database tweak
{17, nil}, // Fsync = true removed {18, migrateToConfigV18},
{16, nil}, // Triggers a database tweak {17, nil}, // Fsync = true removed
{15, migrateToConfigV15}, {16, nil}, // Triggers a database tweak
{14, migrateToConfigV14}, {15, migrateToConfigV15},
{13, migrateToConfigV13}, {14, migrateToConfigV14},
{12, migrateToConfigV12}, {13, migrateToConfigV13},
{11, migrateToConfigV11}, {12, migrateToConfigV12},
} {11, migrateToConfigV11},
}
migrationsMut = sync.Mutex{}
)
type migrationSet []migration type migrationSet []migration

View File

@ -26,7 +26,9 @@ func TestMigrateCrashReporting(t *testing.T) {
for i, tc := range cases { for i, tc := range cases {
cfg := Configuration{Version: 28, Options: tc.opts} cfg := Configuration{Version: 28, Options: tc.opts}
migrationsMut.Lock()
migrations.apply(&cfg) migrations.apply(&cfg)
migrationsMut.Unlock()
if cfg.Options.CREnabled != tc.enabled { if cfg.Options.CREnabled != tc.enabled {
t.Errorf("%d: unexpected result, CREnabled: %v != %v", i, cfg.Options.CREnabled, tc.enabled) t.Errorf("%d: unexpected result, CREnabled: %v != %v", i, cfg.Options.CREnabled, tc.enabled)
} }

View File

@ -64,6 +64,7 @@ type Wrapper interface {
GUI() GUIConfiguration GUI() GUIConfiguration
SetGUI(gui GUIConfiguration) (Waiter, error) SetGUI(gui GUIConfiguration) (Waiter, error)
LDAP() LDAPConfiguration LDAP() LDAPConfiguration
SetLDAP(ldap LDAPConfiguration) (Waiter, error)
Options() OptionsConfiguration Options() OptionsConfiguration
SetOptions(opts OptionsConfiguration) (Waiter, error) SetOptions(opts OptionsConfiguration) (Waiter, error)
@ -71,11 +72,13 @@ type Wrapper interface {
Folder(id string) (FolderConfiguration, bool) Folder(id string) (FolderConfiguration, bool)
Folders() map[string]FolderConfiguration Folders() map[string]FolderConfiguration
FolderList() []FolderConfiguration FolderList() []FolderConfiguration
RemoveFolder(id string) (Waiter, error)
SetFolder(fld FolderConfiguration) (Waiter, error) SetFolder(fld FolderConfiguration) (Waiter, error)
SetFolders(folders []FolderConfiguration) (Waiter, error) SetFolders(folders []FolderConfiguration) (Waiter, error)
Device(id protocol.DeviceID) (DeviceConfiguration, bool) Device(id protocol.DeviceID) (DeviceConfiguration, bool)
Devices() map[protocol.DeviceID]DeviceConfiguration Devices() map[protocol.DeviceID]DeviceConfiguration
DeviceList() []DeviceConfiguration
RemoveDevice(id protocol.DeviceID) (Waiter, error) RemoveDevice(id protocol.DeviceID) (Waiter, error)
SetDevice(DeviceConfiguration) (Waiter, error) SetDevice(DeviceConfiguration) (Waiter, error)
SetDevices([]DeviceConfiguration) (Waiter, error) SetDevices([]DeviceConfiguration) (Waiter, error)
@ -230,6 +233,13 @@ func (w *wrapper) Devices() map[protocol.DeviceID]DeviceConfiguration {
return deviceMap 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 // SetDevices adds new devices to the configuration, or overwrites existing
// devices with the same ID. // devices with the same ID.
func (w *wrapper) SetDevices(devs []DeviceConfiguration) (Waiter, error) { func (w *wrapper) SetDevices(devs []DeviceConfiguration) (Waiter, error) {
@ -327,6 +337,22 @@ func (w *wrapper) SetFolders(folders []FolderConfiguration) (Waiter, error) {
return w.replaceLocked(newCfg) 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. // Options returns the current options configuration object.
func (w *wrapper) Options() OptionsConfiguration { func (w *wrapper) Options() OptionsConfiguration {
w.mut.Lock() w.mut.Lock()
@ -349,6 +375,14 @@ func (w *wrapper) LDAP() LDAPConfiguration {
return w.cfg.LDAP.Copy() 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. // GUI returns the current GUI configuration object.
func (w *wrapper) GUI() GUIConfiguration { func (w *wrapper) GUI() GUIConfiguration {
w.mut.Lock() w.mut.Lock()

View File

@ -84,7 +84,7 @@ func (n DeviceID) Short() ShortID {
return ShortID(binary.BigEndian.Uint64(n[:])) return ShortID(binary.BigEndian.Uint64(n[:]))
} }
func (n *DeviceID) MarshalText() ([]byte, error) { func (n DeviceID) MarshalText() ([]byte, error) {
return []byte(n.String()), nil return []byte(n.String()), nil
} }