Quellcode durchsuchen

all: Use new reflect based CLI (#5487)

Audrius Butkevicius vor 6 Jahren
Ursprung
Commit
dc929946fe

+ 4 - 4
build.go

@@ -768,10 +768,10 @@ func ldflags() string {
 
 	b := new(bytes.Buffer)
 	b.WriteString("-w")
-	fmt.Fprintf(b, " -X main.Version%c%s", sep, version)
-	fmt.Fprintf(b, " -X main.BuildStamp%c%d", sep, buildStamp())
-	fmt.Fprintf(b, " -X main.BuildUser%c%s", sep, buildUser())
-	fmt.Fprintf(b, " -X main.BuildHost%c%s", sep, buildHost())
+	fmt.Fprintf(b, " -X github.com/syncthing/syncthing/lib/build.Version%c%s", sep, version)
+	fmt.Fprintf(b, " -X github.com/syncthing/syncthing/lib/build.Stamp%c%d", sep, buildStamp())
+	fmt.Fprintf(b, " -X github.com/syncthing/syncthing/lib/build.User%c%s", sep, buildUser())
+	fmt.Fprintf(b, " -X github.com/syncthing/syncthing/lib/build.Host%c%s", sep, buildHost())
 	return b.String()
 }
 

+ 0 - 19
cmd/stcli/LICENSE

@@ -1,19 +0,0 @@
-Copyright (C) 2014 Audrius Butkevičius
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-
-- The above copyright notice and this permission notice shall be included in
-  all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.

+ 60 - 80
cmd/stcli/client.go

@@ -1,115 +1,95 @@
-// Copyright (C) 2014 Audrius Butkevičius
+// Copyright (C) 2019 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 main
 
 import (
 	"bytes"
+	"context"
 	"crypto/tls"
+	"fmt"
+	"net"
 	"net/http"
 	"strings"
 
-	"github.com/AudriusButkevicius/cli"
+	"github.com/syncthing/syncthing/lib/config"
 )
 
 type APIClient struct {
-	httpClient http.Client
-	endpoint   string
-	apikey     string
-	username   string
-	password   string
-	id         string
-	csrf       string
+	http.Client
+	cfg    config.GUIConfiguration
+	apikey string
 }
 
-var instance *APIClient
-
-func getClient(c *cli.Context) *APIClient {
-	if instance != nil {
-		return instance
-	}
-	endpoint := c.GlobalString("endpoint")
-	if !strings.HasPrefix(endpoint, "http") {
-		endpoint = "http://" + endpoint
-	}
+func getClient(cfg config.GUIConfiguration) *APIClient {
 	httpClient := http.Client{
 		Transport: &http.Transport{
 			TLSClientConfig: &tls.Config{
-				InsecureSkipVerify: c.GlobalBool("insecure"),
+				InsecureSkipVerify: true,
+			},
+			DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
+				return net.Dial(cfg.Network(), cfg.Address())
 			},
 		},
 	}
-	client := APIClient{
-		httpClient: httpClient,
-		endpoint:   endpoint,
-		apikey:     c.GlobalString("apikey"),
-		username:   c.GlobalString("username"),
-		password:   c.GlobalString("password"),
+	return &APIClient{
+		Client: httpClient,
+		cfg:    cfg,
+		apikey: cfg.APIKey,
 	}
+}
 
-	if client.apikey == "" {
-		request, err := http.NewRequest("GET", client.endpoint, nil)
-		die(err)
-		response := client.handleRequest(request)
-		client.id = response.Header.Get("X-Syncthing-ID")
-		if client.id == "" {
-			die("Failed to get device ID")
-		}
-		for _, item := range response.Cookies() {
-			if item.Name == "CSRF-Token-"+client.id[:5] {
-				client.csrf = item.Value
-				goto csrffound
-			}
-		}
-		die("Failed to get CSRF token")
-	csrffound:
+func (c *APIClient) Endpoint() string {
+	if c.cfg.Network() == "unix" {
+		return "http://unix/"
+	}
+	url := c.cfg.URL()
+	if !strings.HasSuffix(url, "/") {
+		url += "/"
 	}
-	instance = &client
-	return &client
+	return url
 }
 
-func (client *APIClient) handleRequest(request *http.Request) *http.Response {
-	if client.apikey != "" {
-		request.Header.Set("X-API-Key", client.apikey)
+func (c *APIClient) Do(req *http.Request) (*http.Response, error) {
+	req.Header.Set("X-API-Key", c.apikey)
+	resp, err := c.Client.Do(req)
+	if err != nil {
+		return nil, err
 	}
-	if client.username != "" || client.password != "" {
-		request.SetBasicAuth(client.username, client.password)
-	}
-	if client.csrf != "" {
-		request.Header.Set("X-CSRF-Token-"+client.id[:5], client.csrf)
+	return resp, checkResponse(resp)
+}
+
+func (c *APIClient) Get(url string) (*http.Response, error) {
+	request, err := http.NewRequest("GET", c.Endpoint()+"rest/"+url, nil)
+	if err != nil {
+		return nil, err
 	}
+	return c.Do(request)
+}
 
-	response, err := client.httpClient.Do(request)
-	die(err)
+func (c *APIClient) Post(url, body string) (*http.Response, error) {
+	request, err := http.NewRequest("POST", c.Endpoint()+"rest/"+url, bytes.NewBufferString(body))
+	if err != nil {
+		return nil, err
+	}
+	return c.Do(request)
+}
 
+func checkResponse(response *http.Response) error {
 	if response.StatusCode == 404 {
-		die("Invalid endpoint or API call")
-	} else if response.StatusCode == 401 {
-		die("Invalid username or password")
+		return fmt.Errorf("Invalid endpoint or API call")
 	} else if response.StatusCode == 403 {
-		if client.apikey == "" {
-			die("Invalid CSRF token")
-		}
-		die("Invalid API key")
+		return fmt.Errorf("Invalid API key")
 	} else if response.StatusCode != 200 {
-		body := strings.TrimSpace(string(responseToBArray(response)))
-		if body != "" {
-			die(body)
+		data, err := responseToBArray(response)
+		if err != nil {
+			return err
 		}
-		die("Unknown HTTP status returned: " + response.Status)
+		body := strings.TrimSpace(string(data))
+		return fmt.Errorf("Unexpected HTTP status returned: %s\n%s", response.Status, body)
 	}
-	return response
-}
-
-func httpGet(c *cli.Context, url string) *http.Response {
-	client := getClient(c)
-	request, err := http.NewRequest("GET", client.endpoint+"/rest/"+url, nil)
-	die(err)
-	return client.handleRequest(request)
-}
-
-func httpPost(c *cli.Context, url string, body string) *http.Response {
-	client := getClient(c)
-	request, err := http.NewRequest("POST", client.endpoint+"/rest/"+url, bytes.NewBufferString(body))
-	die(err)
-	return client.handleRequest(request)
+	return nil
 }

+ 0 - 188
cmd/stcli/cmd_devices.go

@@ -1,188 +0,0 @@
-// Copyright (C) 2014 Audrius Butkevičius
-
-package main
-
-import (
-	"fmt"
-	"strings"
-
-	"github.com/AudriusButkevicius/cli"
-	"github.com/syncthing/syncthing/lib/config"
-)
-
-func init() {
-	cliCommands = append(cliCommands, cli.Command{
-		Name:     "devices",
-		HideHelp: true,
-		Usage:    "Device command group",
-		Subcommands: []cli.Command{
-			{
-				Name:     "list",
-				Usage:    "List registered devices",
-				Requires: &cli.Requires{},
-				Action:   devicesList,
-			},
-			{
-				Name:     "add",
-				Usage:    "Add a new device",
-				Requires: &cli.Requires{"device id", "device name?"},
-				Action:   devicesAdd,
-			},
-			{
-				Name:     "remove",
-				Usage:    "Remove an existing device",
-				Requires: &cli.Requires{"device id"},
-				Action:   devicesRemove,
-			},
-			{
-				Name:     "get",
-				Usage:    "Get a property of a device",
-				Requires: &cli.Requires{"device id", "property"},
-				Action:   devicesGet,
-			},
-			{
-				Name:     "set",
-				Usage:    "Set a property of a device",
-				Requires: &cli.Requires{"device id", "property", "value..."},
-				Action:   devicesSet,
-			},
-		},
-	})
-}
-
-func devicesList(c *cli.Context) {
-	cfg := getConfig(c)
-	first := true
-	writer := newTableWriter()
-	for _, device := range cfg.Devices {
-		if !first {
-			fmt.Fprintln(writer)
-		}
-		fmt.Fprintln(writer, "ID:\t", device.DeviceID, "\t")
-		fmt.Fprintln(writer, "Name:\t", device.Name, "\t(name)")
-		fmt.Fprintln(writer, "Address:\t", strings.Join(device.Addresses, " "), "\t(address)")
-		fmt.Fprintln(writer, "Compression:\t", device.Compression, "\t(compression)")
-		fmt.Fprintln(writer, "Certificate name:\t", device.CertName, "\t(certname)")
-		fmt.Fprintln(writer, "Introducer:\t", device.Introducer, "\t(introducer)")
-		first = false
-	}
-	writer.Flush()
-}
-
-func devicesAdd(c *cli.Context) {
-	nid := c.Args()[0]
-	id := parseDeviceID(nid)
-
-	newDevice := config.DeviceConfiguration{
-		DeviceID:  id,
-		Name:      nid,
-		Addresses: []string{"dynamic"},
-	}
-
-	if len(c.Args()) > 1 {
-		newDevice.Name = c.Args()[1]
-	}
-
-	if len(c.Args()) > 2 {
-		addresses := c.Args()[2:]
-		for _, item := range addresses {
-			if item == "dynamic" {
-				continue
-			}
-			validAddress(item)
-		}
-		newDevice.Addresses = addresses
-	}
-
-	cfg := getConfig(c)
-	for _, device := range cfg.Devices {
-		if device.DeviceID == id {
-			die("Device " + nid + " already exists")
-		}
-	}
-	cfg.Devices = append(cfg.Devices, newDevice)
-	setConfig(c, cfg)
-}
-
-func devicesRemove(c *cli.Context) {
-	nid := c.Args()[0]
-	id := parseDeviceID(nid)
-	if nid == getMyID(c) {
-		die("Cannot remove yourself")
-	}
-	cfg := getConfig(c)
-	for i, device := range cfg.Devices {
-		if device.DeviceID == id {
-			last := len(cfg.Devices) - 1
-			cfg.Devices[i] = cfg.Devices[last]
-			cfg.Devices = cfg.Devices[:last]
-			setConfig(c, cfg)
-			return
-		}
-	}
-	die("Device " + nid + " not found")
-}
-
-func devicesGet(c *cli.Context) {
-	nid := c.Args()[0]
-	id := parseDeviceID(nid)
-	arg := c.Args()[1]
-	cfg := getConfig(c)
-	for _, device := range cfg.Devices {
-		if device.DeviceID != id {
-			continue
-		}
-		switch strings.ToLower(arg) {
-		case "name":
-			fmt.Println(device.Name)
-		case "address":
-			fmt.Println(strings.Join(device.Addresses, "\n"))
-		case "compression":
-			fmt.Println(device.Compression.String())
-		case "certname":
-			fmt.Println(device.CertName)
-		case "introducer":
-			fmt.Println(device.Introducer)
-		default:
-			die("Invalid property: " + arg + "\nAvailable properties: name, address, compression, certname, introducer")
-		}
-		return
-	}
-	die("Device " + nid + " not found")
-}
-
-func devicesSet(c *cli.Context) {
-	nid := c.Args()[0]
-	id := parseDeviceID(nid)
-	arg := c.Args()[1]
-	config := getConfig(c)
-	for i, device := range config.Devices {
-		if device.DeviceID != id {
-			continue
-		}
-		switch strings.ToLower(arg) {
-		case "name":
-			config.Devices[i].Name = strings.Join(c.Args()[2:], " ")
-		case "address":
-			for _, item := range c.Args()[2:] {
-				if item == "dynamic" {
-					continue
-				}
-				validAddress(item)
-			}
-			config.Devices[i].Addresses = c.Args()[2:]
-		case "compression":
-			err := config.Devices[i].Compression.UnmarshalText([]byte(c.Args()[2]))
-			die(err)
-		case "certname":
-			config.Devices[i].CertName = strings.Join(c.Args()[2:], " ")
-		case "introducer":
-			config.Devices[i].Introducer = parseBool(c.Args()[2])
-		default:
-			die("Invalid property: " + arg + "\nAvailable properties: name, address, compression, certname, introducer")
-		}
-		setConfig(c, config)
-		return
-	}
-	die("Device " + nid + " not found")
-}

+ 0 - 67
cmd/stcli/cmd_errors.go

@@ -1,67 +0,0 @@
-// Copyright (C) 2014 Audrius Butkevičius
-
-package main
-
-import (
-	"encoding/json"
-	"fmt"
-	"strings"
-
-	"github.com/AudriusButkevicius/cli"
-)
-
-func init() {
-	cliCommands = append(cliCommands, cli.Command{
-		Name:     "errors",
-		HideHelp: true,
-		Usage:    "Error command group",
-		Subcommands: []cli.Command{
-			{
-				Name:     "show",
-				Usage:    "Show pending errors",
-				Requires: &cli.Requires{},
-				Action:   errorsShow,
-			},
-			{
-				Name:     "push",
-				Usage:    "Push an error to active clients",
-				Requires: &cli.Requires{"error message..."},
-				Action:   errorsPush,
-			},
-			{
-				Name:     "clear",
-				Usage:    "Clear pending errors",
-				Requires: &cli.Requires{},
-				Action:   wrappedHTTPPost("system/error/clear"),
-			},
-		},
-	})
-}
-
-func errorsShow(c *cli.Context) {
-	response := httpGet(c, "system/error")
-	var data map[string][]map[string]interface{}
-	json.Unmarshal(responseToBArray(response), &data)
-	writer := newTableWriter()
-	for _, item := range data["errors"] {
-		time := item["when"].(string)[:19]
-		time = strings.Replace(time, "T", " ", 1)
-		err := item["message"].(string)
-		err = strings.TrimSpace(err)
-		fmt.Fprintln(writer, time+":\t"+err)
-	}
-	writer.Flush()
-}
-
-func errorsPush(c *cli.Context) {
-	err := strings.Join(c.Args(), " ")
-	response := httpPost(c, "system/error", strings.TrimSpace(err))
-	if response.StatusCode != 200 {
-		err = fmt.Sprint("Failed to push error\nStatus code: ", response.StatusCode)
-		body := string(responseToBArray(response))
-		if body != "" {
-			err += "\nBody: " + body
-		}
-		die(err)
-	}
-}

+ 0 - 361
cmd/stcli/cmd_folders.go

@@ -1,361 +0,0 @@
-// Copyright (C) 2014 Audrius Butkevičius
-
-package main
-
-import (
-	"fmt"
-	"path/filepath"
-	"strings"
-
-	"github.com/AudriusButkevicius/cli"
-	"github.com/syncthing/syncthing/lib/config"
-	"github.com/syncthing/syncthing/lib/fs"
-)
-
-func init() {
-	cliCommands = append(cliCommands, cli.Command{
-		Name:     "folders",
-		HideHelp: true,
-		Usage:    "Folder command group",
-		Subcommands: []cli.Command{
-			{
-				Name:     "list",
-				Usage:    "List available folders",
-				Requires: &cli.Requires{},
-				Action:   foldersList,
-			},
-			{
-				Name:     "add",
-				Usage:    "Add a new folder",
-				Requires: &cli.Requires{"folder id", "directory"},
-				Action:   foldersAdd,
-			},
-			{
-				Name:     "remove",
-				Usage:    "Remove an existing folder",
-				Requires: &cli.Requires{"folder id"},
-				Action:   foldersRemove,
-			},
-			{
-				Name:     "override",
-				Usage:    "Override changes from other nodes for a master folder",
-				Requires: &cli.Requires{"folder id"},
-				Action:   foldersOverride,
-			},
-			{
-				Name:     "get",
-				Usage:    "Get a property of a folder",
-				Requires: &cli.Requires{"folder id", "property"},
-				Action:   foldersGet,
-			},
-			{
-				Name:     "set",
-				Usage:    "Set a property of a folder",
-				Requires: &cli.Requires{"folder id", "property", "value..."},
-				Action:   foldersSet,
-			},
-			{
-				Name:     "unset",
-				Usage:    "Unset a property of a folder",
-				Requires: &cli.Requires{"folder id", "property"},
-				Action:   foldersUnset,
-			},
-			{
-				Name:     "devices",
-				Usage:    "Folder devices command group",
-				HideHelp: true,
-				Subcommands: []cli.Command{
-					{
-						Name:     "list",
-						Usage:    "List of devices which the folder is shared with",
-						Requires: &cli.Requires{"folder id"},
-						Action:   foldersDevicesList,
-					},
-					{
-						Name:     "add",
-						Usage:    "Share a folder with a device",
-						Requires: &cli.Requires{"folder id", "device id"},
-						Action:   foldersDevicesAdd,
-					},
-					{
-						Name:     "remove",
-						Usage:    "Unshare a folder with a device",
-						Requires: &cli.Requires{"folder id", "device id"},
-						Action:   foldersDevicesRemove,
-					},
-					{
-						Name:     "clear",
-						Usage:    "Unshare a folder with all devices",
-						Requires: &cli.Requires{"folder id"},
-						Action:   foldersDevicesClear,
-					},
-				},
-			},
-		},
-	})
-}
-
-func foldersList(c *cli.Context) {
-	cfg := getConfig(c)
-	first := true
-	writer := newTableWriter()
-	for _, folder := range cfg.Folders {
-		if !first {
-			fmt.Fprintln(writer)
-		}
-		fs := folder.Filesystem()
-		fmt.Fprintln(writer, "ID:\t", folder.ID, "\t")
-		fmt.Fprintln(writer, "Path:\t", fs.URI(), "\t(directory)")
-		fmt.Fprintln(writer, "Path type:\t", fs.Type(), "\t(directory-type)")
-		fmt.Fprintln(writer, "Folder type:\t", folder.Type, "\t(type)")
-		fmt.Fprintln(writer, "Ignore permissions:\t", folder.IgnorePerms, "\t(permissions)")
-		fmt.Fprintln(writer, "Rescan interval in seconds:\t", folder.RescanIntervalS, "\t(rescan)")
-
-		if folder.Versioning.Type != "" {
-			fmt.Fprintln(writer, "Versioning:\t", folder.Versioning.Type, "\t(versioning)")
-			for key, value := range folder.Versioning.Params {
-				fmt.Fprintf(writer, "Versioning %s:\t %s \t(versioning-%s)\n", key, value, key)
-			}
-		}
-		first = false
-	}
-	writer.Flush()
-}
-
-func foldersAdd(c *cli.Context) {
-	cfg := getConfig(c)
-	abs, err := filepath.Abs(c.Args()[1])
-	die(err)
-	folder := config.FolderConfiguration{
-		ID:             c.Args()[0],
-		Path:           filepath.Clean(abs),
-		FilesystemType: fs.FilesystemTypeBasic,
-	}
-	cfg.Folders = append(cfg.Folders, folder)
-	setConfig(c, cfg)
-}
-
-func foldersRemove(c *cli.Context) {
-	cfg := getConfig(c)
-	rid := c.Args()[0]
-	for i, folder := range cfg.Folders {
-		if folder.ID == rid {
-			last := len(cfg.Folders) - 1
-			cfg.Folders[i] = cfg.Folders[last]
-			cfg.Folders = cfg.Folders[:last]
-			setConfig(c, cfg)
-			return
-		}
-	}
-	die("Folder " + rid + " not found")
-}
-
-func foldersOverride(c *cli.Context) {
-	cfg := getConfig(c)
-	rid := c.Args()[0]
-	for _, folder := range cfg.Folders {
-		if folder.ID == rid && folder.Type == config.FolderTypeSendOnly {
-			response := httpPost(c, "db/override", "")
-			if response.StatusCode != 200 {
-				err := fmt.Sprint("Failed to override changes\nStatus code: ", response.StatusCode)
-				body := string(responseToBArray(response))
-				if body != "" {
-					err += "\nBody: " + body
-				}
-				die(err)
-			}
-			return
-		}
-	}
-	die("Folder " + rid + " not found or folder not master")
-}
-
-func foldersGet(c *cli.Context) {
-	cfg := getConfig(c)
-	rid := c.Args()[0]
-	arg := strings.ToLower(c.Args()[1])
-	for _, folder := range cfg.Folders {
-		if folder.ID != rid {
-			continue
-		}
-		if strings.HasPrefix(arg, "versioning-") {
-			arg = arg[11:]
-			value, ok := folder.Versioning.Params[arg]
-			if ok {
-				fmt.Println(value)
-				return
-			}
-			die("Versioning property " + c.Args()[1][11:] + " not found")
-		}
-		switch arg {
-		case "directory":
-			fmt.Println(folder.Filesystem().URI())
-		case "directory-type":
-			fmt.Println(folder.Filesystem().Type())
-		case "type":
-			fmt.Println(folder.Type)
-		case "permissions":
-			fmt.Println(folder.IgnorePerms)
-		case "rescan":
-			fmt.Println(folder.RescanIntervalS)
-		case "versioning":
-			if folder.Versioning.Type != "" {
-				fmt.Println(folder.Versioning.Type)
-			}
-		default:
-			die("Invalid property: " + c.Args()[1] + "\nAvailable properties: directory, directory-type, type, permissions, versioning, versioning-<key>")
-		}
-		return
-	}
-	die("Folder " + rid + " not found")
-}
-
-func foldersSet(c *cli.Context) {
-	rid := c.Args()[0]
-	arg := strings.ToLower(c.Args()[1])
-	val := strings.Join(c.Args()[2:], " ")
-	cfg := getConfig(c)
-	for i, folder := range cfg.Folders {
-		if folder.ID != rid {
-			continue
-		}
-		if strings.HasPrefix(arg, "versioning-") {
-			cfg.Folders[i].Versioning.Params[arg[11:]] = val
-			setConfig(c, cfg)
-			return
-		}
-		switch arg {
-		case "directory":
-			cfg.Folders[i].Path = val
-		case "directory-type":
-			var fsType fs.FilesystemType
-			fsType.UnmarshalText([]byte(val))
-			cfg.Folders[i].FilesystemType = fsType
-		case "type":
-			var t config.FolderType
-			if err := t.UnmarshalText([]byte(val)); err != nil {
-				die("Invalid folder type: " + err.Error())
-			}
-			cfg.Folders[i].Type = t
-		case "permissions":
-			cfg.Folders[i].IgnorePerms = parseBool(val)
-		case "rescan":
-			cfg.Folders[i].RescanIntervalS = parseInt(val)
-		case "versioning":
-			cfg.Folders[i].Versioning.Type = val
-		default:
-			die("Invalid property: " + c.Args()[1] + "\nAvailable properties: directory, master, permissions, versioning, versioning-<key>")
-		}
-		setConfig(c, cfg)
-		return
-	}
-	die("Folder " + rid + " not found")
-}
-
-func foldersUnset(c *cli.Context) {
-	rid := c.Args()[0]
-	arg := strings.ToLower(c.Args()[1])
-	cfg := getConfig(c)
-	for i, folder := range cfg.Folders {
-		if folder.ID != rid {
-			continue
-		}
-		if strings.HasPrefix(arg, "versioning-") {
-			arg = arg[11:]
-			if _, ok := folder.Versioning.Params[arg]; ok {
-				delete(cfg.Folders[i].Versioning.Params, arg)
-				setConfig(c, cfg)
-				return
-			}
-			die("Versioning property " + c.Args()[1][11:] + " not found")
-		}
-		switch arg {
-		case "versioning":
-			cfg.Folders[i].Versioning.Type = ""
-			cfg.Folders[i].Versioning.Params = make(map[string]string)
-		default:
-			die("Invalid property: " + c.Args()[1] + "\nAvailable properties: versioning, versioning-<key>")
-		}
-		setConfig(c, cfg)
-		return
-	}
-	die("Folder " + rid + " not found")
-}
-
-func foldersDevicesList(c *cli.Context) {
-	rid := c.Args()[0]
-	cfg := getConfig(c)
-	for _, folder := range cfg.Folders {
-		if folder.ID != rid {
-			continue
-		}
-		for _, device := range folder.Devices {
-			fmt.Println(device.DeviceID)
-		}
-		return
-	}
-	die("Folder " + rid + " not found")
-}
-
-func foldersDevicesAdd(c *cli.Context) {
-	rid := c.Args()[0]
-	nid := parseDeviceID(c.Args()[1])
-	cfg := getConfig(c)
-	for i, folder := range cfg.Folders {
-		if folder.ID != rid {
-			continue
-		}
-		for _, device := range folder.Devices {
-			if device.DeviceID == nid {
-				die("Device " + c.Args()[1] + " is already part of this folder")
-			}
-		}
-		for _, device := range cfg.Devices {
-			if device.DeviceID == nid {
-				cfg.Folders[i].Devices = append(folder.Devices, config.FolderDeviceConfiguration{
-					DeviceID: device.DeviceID,
-				})
-				setConfig(c, cfg)
-				return
-			}
-		}
-		die("Device " + c.Args()[1] + " not found in device list")
-	}
-	die("Folder " + rid + " not found")
-}
-
-func foldersDevicesRemove(c *cli.Context) {
-	rid := c.Args()[0]
-	nid := parseDeviceID(c.Args()[1])
-	cfg := getConfig(c)
-	for ri, folder := range cfg.Folders {
-		if folder.ID != rid {
-			continue
-		}
-		for ni, device := range folder.Devices {
-			if device.DeviceID == nid {
-				last := len(folder.Devices) - 1
-				cfg.Folders[ri].Devices[ni] = folder.Devices[last]
-				cfg.Folders[ri].Devices = cfg.Folders[ri].Devices[:last]
-				setConfig(c, cfg)
-				return
-			}
-		}
-		die("Device " + c.Args()[1] + " not found")
-	}
-	die("Folder " + rid + " not found")
-}
-
-func foldersDevicesClear(c *cli.Context) {
-	rid := c.Args()[0]
-	cfg := getConfig(c)
-	for i, folder := range cfg.Folders {
-		if folder.ID != rid {
-			continue
-		}
-		cfg.Folders[i].Devices = []config.FolderDeviceConfiguration{}
-		setConfig(c, cfg)
-		return
-	}
-	die("Folder " + rid + " not found")
-}

+ 0 - 94
cmd/stcli/cmd_general.go

@@ -1,94 +0,0 @@
-// Copyright (C) 2014 Audrius Butkevičius
-
-package main
-
-import (
-	"encoding/json"
-	"fmt"
-	"os"
-
-	"github.com/AudriusButkevicius/cli"
-)
-
-func init() {
-	cliCommands = append(cliCommands, []cli.Command{
-		{
-			Name:     "id",
-			Usage:    "Get ID of the Syncthing client",
-			Requires: &cli.Requires{},
-			Action:   generalID,
-		},
-		{
-			Name:     "status",
-			Usage:    "Configuration status, whether or not a restart is required for changes to take effect",
-			Requires: &cli.Requires{},
-			Action:   generalStatus,
-		},
-		{
-			Name:     "config",
-			Usage:    "Configuration",
-			Requires: &cli.Requires{},
-			Action:   generalConfiguration,
-		},
-		{
-			Name:     "restart",
-			Usage:    "Restart syncthing",
-			Requires: &cli.Requires{},
-			Action:   wrappedHTTPPost("system/restart"),
-		},
-		{
-			Name:     "shutdown",
-			Usage:    "Shutdown syncthing",
-			Requires: &cli.Requires{},
-			Action:   wrappedHTTPPost("system/shutdown"),
-		},
-		{
-			Name:     "reset",
-			Usage:    "Reset syncthing deleting all folders and devices",
-			Requires: &cli.Requires{},
-			Action:   wrappedHTTPPost("system/reset"),
-		},
-		{
-			Name:     "upgrade",
-			Usage:    "Upgrade syncthing (if a newer version is available)",
-			Requires: &cli.Requires{},
-			Action:   wrappedHTTPPost("system/upgrade"),
-		},
-		{
-			Name:     "version",
-			Usage:    "Syncthing client version",
-			Requires: &cli.Requires{},
-			Action:   generalVersion,
-		},
-	}...)
-}
-
-func generalID(c *cli.Context) {
-	fmt.Println(getMyID(c))
-}
-
-func generalStatus(c *cli.Context) {
-	response := httpGet(c, "system/config/insync")
-	var status struct{ ConfigInSync bool }
-	json.Unmarshal(responseToBArray(response), &status)
-	if !status.ConfigInSync {
-		die("Config out of sync")
-	}
-	fmt.Println("Config in sync")
-}
-
-func generalConfiguration(c *cli.Context) {
-	response := httpGet(c, "system/config")
-	var jsResponse interface{}
-	json.Unmarshal(responseToBArray(response), &jsResponse)
-	enc := json.NewEncoder(os.Stdout)
-	enc.SetIndent("", "  ")
-	enc.Encode(jsResponse)
-}
-
-func generalVersion(c *cli.Context) {
-	response := httpGet(c, "system/version")
-	version := make(map[string]interface{})
-	json.Unmarshal(responseToBArray(response), &version)
-	prettyPrintJSON(version)
-}

+ 0 - 127
cmd/stcli/cmd_gui.go

@@ -1,127 +0,0 @@
-// Copyright (C) 2014 Audrius Butkevičius
-
-package main
-
-import (
-	"fmt"
-	"strings"
-
-	"github.com/AudriusButkevicius/cli"
-)
-
-func init() {
-	cliCommands = append(cliCommands, cli.Command{
-		Name:     "gui",
-		HideHelp: true,
-		Usage:    "GUI command group",
-		Subcommands: []cli.Command{
-			{
-				Name:     "dump",
-				Usage:    "Show all GUI configuration settings",
-				Requires: &cli.Requires{},
-				Action:   guiDump,
-			},
-			{
-				Name:     "get",
-				Usage:    "Get a GUI configuration setting",
-				Requires: &cli.Requires{"setting"},
-				Action:   guiGet,
-			},
-			{
-				Name:     "set",
-				Usage:    "Set a GUI configuration setting",
-				Requires: &cli.Requires{"setting", "value"},
-				Action:   guiSet,
-			},
-			{
-				Name:     "unset",
-				Usage:    "Unset a GUI configuration setting",
-				Requires: &cli.Requires{"setting"},
-				Action:   guiUnset,
-			},
-		},
-	})
-}
-
-func guiDump(c *cli.Context) {
-	cfg := getConfig(c).GUI
-	writer := newTableWriter()
-	fmt.Fprintln(writer, "Enabled:\t", cfg.Enabled, "\t(enabled)")
-	fmt.Fprintln(writer, "Use HTTPS:\t", cfg.UseTLS(), "\t(tls)")
-	fmt.Fprintln(writer, "Listen Addresses:\t", cfg.Address(), "\t(address)")
-	if cfg.User != "" {
-		fmt.Fprintln(writer, "Authentication User:\t", cfg.User, "\t(username)")
-		fmt.Fprintln(writer, "Authentication Password:\t", cfg.Password, "\t(password)")
-	}
-	if cfg.APIKey != "" {
-		fmt.Fprintln(writer, "API Key:\t", cfg.APIKey, "\t(apikey)")
-	}
-	writer.Flush()
-}
-
-func guiGet(c *cli.Context) {
-	cfg := getConfig(c).GUI
-	arg := c.Args()[0]
-	switch strings.ToLower(arg) {
-	case "enabled":
-		fmt.Println(cfg.Enabled)
-	case "tls":
-		fmt.Println(cfg.UseTLS())
-	case "address":
-		fmt.Println(cfg.Address())
-	case "user":
-		if cfg.User != "" {
-			fmt.Println(cfg.User)
-		}
-	case "password":
-		if cfg.User != "" {
-			fmt.Println(cfg.Password)
-		}
-	case "apikey":
-		if cfg.APIKey != "" {
-			fmt.Println(cfg.APIKey)
-		}
-	default:
-		die("Invalid setting: " + arg + "\nAvailable settings: enabled, tls, address, user, password, apikey")
-	}
-}
-
-func guiSet(c *cli.Context) {
-	cfg := getConfig(c)
-	arg := c.Args()[0]
-	val := c.Args()[1]
-	switch strings.ToLower(arg) {
-	case "enabled":
-		cfg.GUI.Enabled = parseBool(val)
-	case "tls":
-		cfg.GUI.RawUseTLS = parseBool(val)
-	case "address":
-		validAddress(val)
-		cfg.GUI.RawAddress = val
-	case "user":
-		cfg.GUI.User = val
-	case "password":
-		cfg.GUI.Password = val
-	case "apikey":
-		cfg.GUI.APIKey = val
-	default:
-		die("Invalid setting: " + arg + "\nAvailable settings: enabled, tls, address, user, password, apikey")
-	}
-	setConfig(c, cfg)
-}
-
-func guiUnset(c *cli.Context) {
-	cfg := getConfig(c)
-	arg := c.Args()[0]
-	switch strings.ToLower(arg) {
-	case "user":
-		cfg.GUI.User = ""
-	case "password":
-		cfg.GUI.Password = ""
-	case "apikey":
-		cfg.GUI.APIKey = ""
-	default:
-		die("Invalid setting: " + arg + "\nAvailable settings: user, password, apikey")
-	}
-	setConfig(c, cfg)
-}

+ 0 - 173
cmd/stcli/cmd_options.go

@@ -1,173 +0,0 @@
-// Copyright (C) 2014 Audrius Butkevičius
-
-package main
-
-import (
-	"fmt"
-	"strings"
-
-	"github.com/AudriusButkevicius/cli"
-)
-
-func init() {
-	cliCommands = append(cliCommands, cli.Command{
-		Name:     "options",
-		HideHelp: true,
-		Usage:    "Options command group",
-		Subcommands: []cli.Command{
-			{
-				Name:     "dump",
-				Usage:    "Show all Syncthing option settings",
-				Requires: &cli.Requires{},
-				Action:   optionsDump,
-			},
-			{
-				Name:     "get",
-				Usage:    "Get a Syncthing option setting",
-				Requires: &cli.Requires{"setting"},
-				Action:   optionsGet,
-			},
-			{
-				Name:     "set",
-				Usage:    "Set a Syncthing option setting",
-				Requires: &cli.Requires{"setting", "value..."},
-				Action:   optionsSet,
-			},
-		},
-	})
-}
-
-func optionsDump(c *cli.Context) {
-	cfg := getConfig(c).Options
-	writer := newTableWriter()
-
-	fmt.Fprintln(writer, "Sync protocol listen addresses:\t", strings.Join(cfg.ListenAddresses, " "), "\t(addresses)")
-	fmt.Fprintln(writer, "Global discovery enabled:\t", cfg.GlobalAnnEnabled, "\t(globalannenabled)")
-	fmt.Fprintln(writer, "Global discovery servers:\t", strings.Join(cfg.GlobalAnnServers, " "), "\t(globalannserver)")
-
-	fmt.Fprintln(writer, "Local discovery enabled:\t", cfg.LocalAnnEnabled, "\t(localannenabled)")
-	fmt.Fprintln(writer, "Local discovery port:\t", cfg.LocalAnnPort, "\t(localannport)")
-
-	fmt.Fprintln(writer, "Outgoing rate limit in KiB/s:\t", cfg.MaxSendKbps, "\t(maxsend)")
-	fmt.Fprintln(writer, "Incoming rate limit in KiB/s:\t", cfg.MaxRecvKbps, "\t(maxrecv)")
-	fmt.Fprintln(writer, "Reconnect interval in seconds:\t", cfg.ReconnectIntervalS, "\t(reconnect)")
-	fmt.Fprintln(writer, "Start browser:\t", cfg.StartBrowser, "\t(browser)")
-	fmt.Fprintln(writer, "Enable UPnP:\t", cfg.NATEnabled, "\t(nat)")
-	fmt.Fprintln(writer, "UPnP Lease in minutes:\t", cfg.NATLeaseM, "\t(natlease)")
-	fmt.Fprintln(writer, "UPnP Renewal period in minutes:\t", cfg.NATRenewalM, "\t(natrenew)")
-	fmt.Fprintln(writer, "Restart on Wake Up:\t", cfg.RestartOnWakeup, "\t(wake)")
-
-	reporting := "unrecognized value"
-	switch cfg.URAccepted {
-	case -1:
-		reporting = "false"
-	case 0:
-		reporting = "undecided/false"
-	case 1:
-		reporting = "true"
-	}
-	fmt.Fprintln(writer, "Anonymous usage reporting:\t", reporting, "\t(reporting)")
-
-	writer.Flush()
-}
-
-func optionsGet(c *cli.Context) {
-	cfg := getConfig(c).Options
-	arg := c.Args()[0]
-	switch strings.ToLower(arg) {
-	case "address":
-		fmt.Println(strings.Join(cfg.ListenAddresses, "\n"))
-	case "globalannenabled":
-		fmt.Println(cfg.GlobalAnnEnabled)
-	case "globalannservers":
-		fmt.Println(strings.Join(cfg.GlobalAnnServers, "\n"))
-	case "localannenabled":
-		fmt.Println(cfg.LocalAnnEnabled)
-	case "localannport":
-		fmt.Println(cfg.LocalAnnPort)
-	case "maxsend":
-		fmt.Println(cfg.MaxSendKbps)
-	case "maxrecv":
-		fmt.Println(cfg.MaxRecvKbps)
-	case "reconnect":
-		fmt.Println(cfg.ReconnectIntervalS)
-	case "browser":
-		fmt.Println(cfg.StartBrowser)
-	case "nat":
-		fmt.Println(cfg.NATEnabled)
-	case "natlease":
-		fmt.Println(cfg.NATLeaseM)
-	case "natrenew":
-		fmt.Println(cfg.NATRenewalM)
-	case "reporting":
-		switch cfg.URAccepted {
-		case -1:
-			fmt.Println("false")
-		case 0:
-			fmt.Println("undecided/false")
-		case 1:
-			fmt.Println("true")
-		default:
-			fmt.Println("unknown")
-		}
-	case "wake":
-		fmt.Println(cfg.RestartOnWakeup)
-	default:
-		die("Invalid setting: " + arg + "\nAvailable settings: address, globalannenabled, globalannserver, localannenabled, localannport, maxsend, maxrecv, reconnect, browser, upnp, upnplease, upnprenew, reporting, wake")
-	}
-}
-
-func optionsSet(c *cli.Context) {
-	config := getConfig(c)
-	arg := c.Args()[0]
-	val := c.Args()[1]
-	switch strings.ToLower(arg) {
-	case "address":
-		for _, item := range c.Args().Tail() {
-			validAddress(item)
-		}
-		config.Options.ListenAddresses = c.Args().Tail()
-	case "globalannenabled":
-		config.Options.GlobalAnnEnabled = parseBool(val)
-	case "globalannserver":
-		for _, item := range c.Args().Tail() {
-			validAddress(item)
-		}
-		config.Options.GlobalAnnServers = c.Args().Tail()
-	case "localannenabled":
-		config.Options.LocalAnnEnabled = parseBool(val)
-	case "localannport":
-		config.Options.LocalAnnPort = parsePort(val)
-	case "maxsend":
-		config.Options.MaxSendKbps = parseUint(val)
-	case "maxrecv":
-		config.Options.MaxRecvKbps = parseUint(val)
-	case "reconnect":
-		config.Options.ReconnectIntervalS = parseUint(val)
-	case "browser":
-		config.Options.StartBrowser = parseBool(val)
-	case "nat":
-		config.Options.NATEnabled = parseBool(val)
-	case "natlease":
-		config.Options.NATLeaseM = parseUint(val)
-	case "natrenew":
-		config.Options.NATRenewalM = parseUint(val)
-	case "reporting":
-		switch strings.ToLower(val) {
-		case "u", "undecided", "unset":
-			config.Options.URAccepted = 0
-		default:
-			boolvalue := parseBool(val)
-			if boolvalue {
-				config.Options.URAccepted = 1
-			} else {
-				config.Options.URAccepted = -1
-			}
-		}
-	case "wake":
-		config.Options.RestartOnWakeup = parseBool(val)
-	default:
-		die("Invalid setting: " + arg + "\nAvailable settings: address, globalannenabled, globalannserver, localannenabled, localannport, maxsend, maxrecv, reconnect, browser, upnp, upnplease, upnprenew, reporting, wake")
-	}
-	setConfig(c, config)
-}

+ 0 - 72
cmd/stcli/cmd_report.go

@@ -1,72 +0,0 @@
-// Copyright (C) 2014 Audrius Butkevičius
-
-package main
-
-import (
-	"encoding/json"
-	"fmt"
-
-	"github.com/AudriusButkevicius/cli"
-)
-
-func init() {
-	cliCommands = append(cliCommands, cli.Command{
-		Name:     "report",
-		HideHelp: true,
-		Usage:    "Reporting command group",
-		Subcommands: []cli.Command{
-			{
-				Name:     "system",
-				Usage:    "Report system state",
-				Requires: &cli.Requires{},
-				Action:   reportSystem,
-			},
-			{
-				Name:     "connections",
-				Usage:    "Report about connections to other devices",
-				Requires: &cli.Requires{},
-				Action:   reportConnections,
-			},
-			{
-				Name:     "usage",
-				Usage:    "Usage report",
-				Requires: &cli.Requires{},
-				Action:   reportUsage,
-			},
-		},
-	})
-}
-
-func reportSystem(c *cli.Context) {
-	response := httpGet(c, "system/status")
-	data := make(map[string]interface{})
-	json.Unmarshal(responseToBArray(response), &data)
-	prettyPrintJSON(data)
-}
-
-func reportConnections(c *cli.Context) {
-	response := httpGet(c, "system/connections")
-	data := make(map[string]map[string]interface{})
-	json.Unmarshal(responseToBArray(response), &data)
-	var overall map[string]interface{}
-	for key, value := range data {
-		if key == "total" {
-			overall = value
-			continue
-		}
-		value["Device ID"] = key
-		prettyPrintJSON(value)
-		fmt.Println()
-	}
-	if overall != nil {
-		fmt.Println("=== Overall statistics ===")
-		prettyPrintJSON(overall)
-	}
-}
-
-func reportUsage(c *cli.Context) {
-	response := httpGet(c, "svc/report")
-	report := make(map[string]interface{})
-	json.Unmarshal(responseToBArray(response), &report)
-	prettyPrintJSON(report)
-}

+ 60 - 0
cmd/stcli/errors.go

@@ -0,0 +1,60 @@
+// Copyright (C) 2019 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 main
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/urfave/cli"
+)
+
+var errorsCommand = cli.Command{
+	Name:     "errors",
+	HideHelp: true,
+	Usage:    "Error command group",
+	Subcommands: []cli.Command{
+		{
+			Name:   "show",
+			Usage:  "Show pending errors",
+			Action: expects(0, dumpOutput("system/error")),
+		},
+		{
+			Name:      "push",
+			Usage:     "Push an error to active clients",
+			ArgsUsage: "[error message]",
+			Action:    expects(1, errorsPush),
+		},
+		{
+			Name:   "clear",
+			Usage:  "Clear pending errors",
+			Action: expects(0, emptyPost("system/error/clear")),
+		},
+	},
+}
+
+func errorsPush(c *cli.Context) error {
+	client := c.App.Metadata["client"].(*APIClient)
+	errStr := strings.Join(c.Args(), " ")
+	response, err := client.Post("system/error", strings.TrimSpace(errStr))
+	if err != nil {
+		return err
+	}
+	if response.StatusCode != 200 {
+		errStr = fmt.Sprint("Failed to push error\nStatus code: ", response.StatusCode)
+		bytes, err := responseToBArray(response)
+		if err != nil {
+			return err
+		}
+		body := string(bytes)
+		if body != "" {
+			errStr += "\nBody: " + body
+		}
+		return fmt.Errorf(errStr)
+	}
+	return nil
+}

+ 0 - 31
cmd/stcli/labels.go

@@ -1,31 +0,0 @@
-// Copyright (C) 2014 Audrius Butkevičius
-
-package main
-
-var jsonAttributeLabels = map[string]string{
-	"folderMaxMiB":   "Largest folder size in MiB",
-	"folderMaxFiles": "Largest folder file count",
-	"longVersion":    "Long version",
-	"totMiB":         "Total size in MiB",
-	"totFiles":       "Total files",
-	"uniqueID":       "Unique ID",
-	"numFolders":     "Folder count",
-	"numDevices":     "Device count",
-	"memoryUsageMiB": "Memory usage in MiB",
-	"memorySize":     "Total memory in MiB",
-	"sha256Perf":     "SHA256 Benchmark",
-	"At":             "Last contacted",
-	"Completion":     "Percent complete",
-	"InBytesTotal":   "Total bytes received",
-	"OutBytesTotal":  "Total bytes sent",
-	"ClientVersion":  "Client version",
-	"alloc":          "Memory allocated in bytes",
-	"sys":            "Memory using in bytes",
-	"cpuPercent":     "CPU load in percent",
-	"extAnnounceOK":  "External announcments working",
-	"goroutines":     "Number of Go routines",
-	"myID":           "Client ID",
-	"tilde":          "Tilde expands to",
-	"arch":           "Architecture",
-	"os":             "OS",
-}

+ 173 - 44
cmd/stcli/main.go

@@ -1,63 +1,192 @@
-// Copyright (C) 2014 Audrius Butkevičius
+// Copyright (C) 2019 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 main
 
 import (
-	"sort"
+	"bufio"
+	"crypto/tls"
+	"encoding/json"
+	"flag"
+	"log"
+	"os"
+	"reflect"
+	"strings"
 
-	"github.com/AudriusButkevicius/cli"
+	"github.com/AudriusButkevicius/recli"
+	"github.com/flynn-archive/go-shlex"
+	"github.com/mattn/go-isatty"
+	"github.com/pkg/errors"
+	"github.com/syncthing/syncthing/lib/build"
+	"github.com/syncthing/syncthing/lib/config"
+	"github.com/syncthing/syncthing/lib/locations"
+	"github.com/syncthing/syncthing/lib/protocol"
+	"github.com/urfave/cli"
 )
 
-type ByAlphabet []cli.Command
-
-func (a ByAlphabet) Len() int           { return len(a) }
-func (a ByAlphabet) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }
-func (a ByAlphabet) Less(i, j int) bool { return a[i].Name < a[j].Name }
-
-var cliCommands []cli.Command
-
 func main() {
-	app := cli.NewApp()
-	app.Name = "syncthing-cli"
-	app.Author = "Audrius Butkevičius"
-	app.Email = "[email protected]"
-	app.Usage = "Syncthing command line interface"
-	app.Version = "0.1"
-	app.HideHelp = true
+	// This is somewhat a hack around a chicken and egg problem.
+	// We need to set the home directory and potentially other flags to know where the syncthing instance is running
+	// in order to get it's config ... which we then use to construct the actual CLI ... at which point it's too late
+	// to add flags there...
+	homeBaseDir := locations.GetBaseDir(locations.ConfigBaseDir)
+	guiCfg := config.GUIConfiguration{}
 
-	app.Flags = []cli.Flag{
-		cli.StringFlag{
-			Name:   "endpoint, e",
-			Value:  "http://127.0.0.1:8384",
-			Usage:  "End point to connect to",
-			EnvVar: "STENDPOINT",
-		},
+	flags := flag.NewFlagSet("", flag.ContinueOnError)
+	flags.StringVar(&guiCfg.RawAddress, "gui-address", guiCfg.RawAddress, "Override GUI address (e.g. \"http://192.0.2.42:8443\")")
+	flags.StringVar(&guiCfg.APIKey, "gui-apikey", guiCfg.APIKey, "Override GUI API key")
+	flags.StringVar(&homeBaseDir, "home", homeBaseDir, "Set configuration directory")
+
+	// Implement the same flags at the lower CLI, with the same default values (pre-parse), but do nothing with them.
+	// This is so that we could reuse os.Args
+	fakeFlags := []cli.Flag{
 		cli.StringFlag{
-			Name:   "apikey, k",
-			Value:  "",
-			Usage:  "API Key",
-			EnvVar: "STAPIKEY",
+			Name:  "gui-address",
+			Value: guiCfg.RawAddress,
+			Usage: "Override GUI address (e.g. \"http://192.0.2.42:8443\")",
 		},
 		cli.StringFlag{
-			Name:   "username, u",
-			Value:  "",
-			Usage:  "Username",
-			EnvVar: "STUSERNAME",
+			Name:  "gui-apikey",
+			Value: guiCfg.APIKey,
+			Usage: "Override GUI API key",
 		},
 		cli.StringFlag{
-			Name:   "password, p",
-			Value:  "",
-			Usage:  "Password",
-			EnvVar: "STPASSWORD",
+			Name:  "home",
+			Value: homeBaseDir,
+			Usage: "Set configuration directory",
 		},
-		cli.BoolFlag{
-			Name:   "insecure, i",
-			Usage:  "Do not verify SSL certificate",
-			EnvVar: "STINSECURE",
+	}
+
+	// Do not print usage of these flags, and ignore errors as this can't understand plenty of things
+	flags.Usage = func() {}
+	_ = flags.Parse(os.Args[1:])
+
+	// Now if the API key and address is not provided (we are not connecting to a remote instance),
+	// try to rip it out of the config.
+	if guiCfg.RawAddress == "" && guiCfg.APIKey == "" {
+		// Update the base directory
+		err := locations.SetBaseDir(locations.ConfigBaseDir, homeBaseDir)
+		if err != nil {
+			log.Fatal(errors.Wrap(err, "setting home"))
+		}
+
+		// Load the certs and get the ID
+		cert, err := tls.LoadX509KeyPair(
+			locations.Get(locations.CertFile),
+			locations.Get(locations.KeyFile),
+		)
+		if err != nil {
+			log.Fatal(errors.Wrap(err, "reading device ID"))
+		}
+
+		myID := protocol.NewDeviceID(cert.Certificate[0])
+
+		// Load the config
+		cfg, err := config.Load(locations.Get(locations.ConfigFile), myID)
+		if err != nil {
+			log.Fatalln(errors.Wrap(err, "loading config"))
+		}
+
+		guiCfg = cfg.GUI()
+	} else if guiCfg.Address() == "" || guiCfg.APIKey == "" {
+		log.Fatalln("Both -gui-address and -gui-apikey should be specified")
+	}
+
+	if guiCfg.Address() == "" {
+		log.Fatalln("Could not find GUI Address")
+	}
+
+	if guiCfg.APIKey == "" {
+		log.Fatalln("Could not find GUI API key")
+	}
+
+	client := getClient(guiCfg)
+
+	cfg, err := getConfig(client)
+	original := cfg.Copy()
+	if err != nil {
+		log.Fatalln(errors.Wrap(err, "getting config"))
+	}
+
+	// Copy the config and set the default flags
+	recliCfg := recli.DefaultConfig
+	recliCfg.IDTag.Name = "xml"
+	recliCfg.SkipTag.Name = "json"
+
+	commands, err := recli.New(recliCfg).Construct(&cfg)
+	if err != nil {
+		log.Fatalln(errors.Wrap(err, "config reflect"))
+	}
+
+	// Construct the actual CLI
+	app := cli.NewApp()
+	app.Name = "stcli"
+	app.HelpName = app.Name
+	app.Author = "The Syncthing Authors"
+	app.Usage = "Syncthing command line interface"
+	app.Version = strings.Replace(build.LongVersion, "syncthing", app.Name, 1)
+	app.Flags = fakeFlags
+	app.Metadata = map[string]interface{}{
+		"client": client,
+	}
+	app.Commands = []cli.Command{
+		{
+			Name:        "config",
+			HideHelp:    true,
+			Usage:       "Configuration modification command group",
+			Subcommands: commands,
 		},
+		showCommand,
+		operationCommand,
+		errorsCommand,
+	}
+
+	tty := isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd())
+	if !tty {
+		// Not a TTY, consume from stdin
+		scanner := bufio.NewScanner(os.Stdin)
+		for scanner.Scan() {
+			input, err := shlex.Split(scanner.Text())
+			if err != nil {
+				log.Fatalln(errors.Wrap(err, "parsing input"))
+			}
+			if len(input) == 0 {
+				continue
+			}
+			err = app.Run(append(os.Args, input...))
+			if err != nil {
+				log.Fatalln(err)
+			}
+		}
+		err = scanner.Err()
+		if err != nil {
+			log.Fatalln(err)
+		}
+	} else {
+		err = app.Run(os.Args)
+		if err != nil {
+			log.Fatalln(err)
+		}
 	}
 
-	sort.Sort(ByAlphabet(cliCommands))
-	app.Commands = cliCommands
-	app.RunAndExitOnError()
+	if !reflect.DeepEqual(cfg, original) {
+		body, err := json.MarshalIndent(cfg, "", "  ")
+		if err != nil {
+			log.Fatalln(err)
+		}
+		resp, err := client.Post("system/config", string(body))
+		if err != nil {
+			log.Fatalln(err)
+		}
+		if resp.StatusCode != 200 {
+			body, err := responseToBArray(resp)
+			if err != nil {
+				log.Fatalln(err)
+			}
+			log.Fatalln(string(body))
+		}
+	}
 }

+ 78 - 0
cmd/stcli/operations.go

@@ -0,0 +1,78 @@
+// Copyright (C) 2019 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 main
+
+import (
+	"fmt"
+
+	"github.com/urfave/cli"
+)
+
+var operationCommand = cli.Command{
+	Name:     "operations",
+	HideHelp: true,
+	Usage:    "Operation command group",
+	Subcommands: []cli.Command{
+		{
+			Name:   "restart",
+			Usage:  "Restart syncthing",
+			Action: expects(0, emptyPost("system/restart")),
+		},
+		{
+			Name:   "shutdown",
+			Usage:  "Shutdown syncthing",
+			Action: expects(0, emptyPost("system/shutdown")),
+		},
+		{
+			Name:   "reset",
+			Usage:  "Reset syncthing deleting all folders and devices",
+			Action: expects(0, emptyPost("system/reset")),
+		},
+		{
+			Name:   "upgrade",
+			Usage:  "Upgrade syncthing (if a newer version is available)",
+			Action: expects(0, emptyPost("system/upgrade")),
+		},
+		{
+			Name:      "folder-override",
+			Usage:     "Override changes on folder (remote for sendonly, local for receiveonly)",
+			ArgsUsage: "[folder id]",
+			Action:    expects(1, foldersOverride),
+		},
+	},
+}
+
+func foldersOverride(c *cli.Context) error {
+	client := c.App.Metadata["client"].(*APIClient)
+	cfg, err := getConfig(client)
+	if err != nil {
+		return err
+	}
+	rid := c.Args()[0]
+	for _, folder := range cfg.Folders {
+		if folder.ID == rid {
+			response, err := client.Post("db/override", "")
+			if err != nil {
+				return err
+			}
+			if response.StatusCode != 200 {
+				errStr := fmt.Sprint("Failed to override changes\nStatus code: ", response.StatusCode)
+				bytes, err := responseToBArray(response)
+				if err != nil {
+					return err
+				}
+				body := string(bytes)
+				if body != "" {
+					errStr += "\nBody: " + body
+				}
+				return fmt.Errorf(errStr)
+			}
+			return nil
+		}
+	}
+	return fmt.Errorf("Folder " + rid + " not found")
+}

+ 44 - 0
cmd/stcli/show.go

@@ -0,0 +1,44 @@
+// Copyright (C) 2019 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 main
+
+import (
+	"github.com/urfave/cli"
+)
+
+var showCommand = cli.Command{
+	Name:     "show",
+	HideHelp: true,
+	Usage:    "Show command group",
+	Subcommands: []cli.Command{
+		{
+			Name:   "version",
+			Usage:  "Show syncthing client version",
+			Action: expects(0, dumpOutput("system/version")),
+		},
+		{
+			Name:   "config-status",
+			Usage:  "Show configuration status, whether or not a restart is required for changes to take effect",
+			Action: expects(0, dumpOutput("system/config/insync")),
+		},
+		{
+			Name:   "system",
+			Usage:  "Show system status",
+			Action: expects(0, dumpOutput("system/status")),
+		},
+		{
+			Name:   "connections",
+			Usage:  "Report about connections to other devices",
+			Action: expects(0, dumpOutput("system/connections")),
+		},
+		{
+			Name:   "usage",
+			Usage:  "Show usage report",
+			Action: expects(0, dumpOutput("svc/report")),
+		},
+	},
+}

+ 54 - 118
cmd/stcli/utils.go

@@ -1,4 +1,8 @@
-// Copyright (C) 2014 Audrius Butkevičius
+// Copyright (C) 2019 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 main
 
@@ -8,78 +12,37 @@ import (
 	"io/ioutil"
 	"net/http"
 	"os"
-	"regexp"
-	"sort"
-	"strconv"
-	"strings"
 	"text/tabwriter"
-	"unicode"
 
-	"github.com/AudriusButkevicius/cli"
 	"github.com/syncthing/syncthing/lib/config"
-	"github.com/syncthing/syncthing/lib/protocol"
+	"github.com/urfave/cli"
 )
 
-func responseToBArray(response *http.Response) []byte {
-	defer response.Body.Close()
+func responseToBArray(response *http.Response) ([]byte, error) {
 	bytes, err := ioutil.ReadAll(response.Body)
 	if err != nil {
-		die(err)
+		return nil, err
 	}
-	return bytes
+	return bytes, response.Body.Close()
 }
 
-func die(vals ...interface{}) {
-	if len(vals) > 1 || vals[0] != nil {
-		os.Stderr.WriteString(fmt.Sprintln(vals...))
-		os.Exit(1)
+func emptyPost(url string) cli.ActionFunc {
+	return func(c *cli.Context) error {
+		client := c.App.Metadata["client"].(*APIClient)
+		_, err := client.Post(url, "")
+		return err
 	}
 }
 
-func wrappedHTTPPost(url string) func(c *cli.Context) {
-	return func(c *cli.Context) {
-		httpPost(c, url, "")
-	}
-}
-
-func prettyPrintJSON(json map[string]interface{}) {
-	writer := newTableWriter()
-	remap := make(map[string]interface{})
-	for k, v := range json {
-		key, ok := jsonAttributeLabels[k]
-		if !ok {
-			key = firstUpper(k)
-		}
-		remap[key] = v
-	}
-
-	jsonKeys := make([]string, 0, len(remap))
-	for key := range remap {
-		jsonKeys = append(jsonKeys, key)
-	}
-	sort.Strings(jsonKeys)
-	for _, k := range jsonKeys {
-		var value string
-		rvalue := remap[k]
-		switch rvalue.(type) {
-		case int, int16, int32, int64, uint, uint16, uint32, uint64, float32, float64:
-			value = fmt.Sprintf("%.0f", rvalue)
-		default:
-			value = fmt.Sprint(rvalue)
+func dumpOutput(url string) cli.ActionFunc {
+	return func(c *cli.Context) error {
+		client := c.App.Metadata["client"].(*APIClient)
+		response, err := client.Get(url)
+		if err != nil {
+			return err
 		}
-		if value == "" {
-			continue
-		}
-		fmt.Fprintln(writer, k+":\t"+value)
-	}
-	writer.Flush()
-}
-
-func firstUpper(str string) string {
-	for i, v := range str {
-		return string(unicode.ToUpper(v)) + str[i+1:]
+		return prettyPrintResponse(c, response)
 	}
-	return ""
 }
 
 func newTableWriter() *tabwriter.Writer {
@@ -88,78 +51,51 @@ func newTableWriter() *tabwriter.Writer {
 	return writer
 }
 
-func getMyID(c *cli.Context) string {
-	response := httpGet(c, "system/status")
-	data := make(map[string]interface{})
-	json.Unmarshal(responseToBArray(response), &data)
-	return data["myID"].(string)
-}
-
-func getConfig(c *cli.Context) config.Configuration {
-	response := httpGet(c, "system/config")
-	config := config.Configuration{}
-	json.Unmarshal(responseToBArray(response), &config)
-	return config
-}
-
-func setConfig(c *cli.Context, cfg config.Configuration) {
-	body, err := json.Marshal(cfg)
-	die(err)
-	response := httpPost(c, "system/config", string(body))
-	if response.StatusCode != 200 {
-		die("Unexpected status code", response.StatusCode)
-	}
-}
-
-func parseBool(input string) bool {
-	val, err := strconv.ParseBool(input)
+func getConfig(c *APIClient) (config.Configuration, error) {
+	cfg := config.Configuration{}
+	response, err := c.Get("system/config")
 	if err != nil {
-		die(input + " is not a valid value for a boolean")
+		return cfg, err
 	}
-	return val
-}
-
-func parseInt(input string) int {
-	val, err := strconv.ParseInt(input, 0, 64)
+	bytes, err := responseToBArray(response)
 	if err != nil {
-		die(input + " is not a valid value for an integer")
+		return cfg, err
 	}
-	return int(val)
-}
-
-func parseUint(input string) int {
-	val, err := strconv.ParseUint(input, 0, 64)
-	if err != nil {
-		die(input + " is not a valid value for an unsigned integer")
+	err = json.Unmarshal(bytes, &cfg)
+	if err == nil {
+		return cfg, err
 	}
-	return int(val)
+	return cfg, nil
 }
 
-func parsePort(input string) int {
-	port := parseUint(input)
-	if port < 1 || port > 65535 {
-		die(input + " is not a valid port\nExpected value between 1 and 65535")
+func expects(n int, actionFunc cli.ActionFunc) cli.ActionFunc {
+	return func(ctx *cli.Context) error {
+		if ctx.NArg() != n {
+			plural := ""
+			if n != 1 {
+				plural = "s"
+			}
+			return fmt.Errorf("expected %d argument%s, got %d", n, plural, ctx.NArg())
+		}
+		return actionFunc(ctx)
 	}
-	return port
 }
 
-func validAddress(input string) {
-	tokens := strings.Split(input, ":")
-	if len(tokens) != 2 {
-		die(input + " is not a valid value for an address\nExpected format <ip or hostname>:<port>")
-	}
-	matched, err := regexp.MatchString("^[a-zA-Z0-9]+([-a-zA-Z0-9.]+[-a-zA-Z0-9]+)?$", tokens[0])
-	die(err)
-	if !matched {
-		die(input + " is not a valid value for an address\nExpected format <ip or hostname>:<port>")
-	}
-	parsePort(tokens[1])
+func prettyPrintJSON(data interface{}) error {
+	enc := json.NewEncoder(os.Stdout)
+	enc.SetIndent("", "  ")
+	return enc.Encode(data)
 }
 
-func parseDeviceID(input string) protocol.DeviceID {
-	device, err := protocol.DeviceIDFromString(input)
+func prettyPrintResponse(c *cli.Context, response *http.Response) error {
+	bytes, err := responseToBArray(response)
 	if err != nil {
-		die(input + " is not a valid device id")
+		return err
+	}
+	var data interface{}
+	if err := json.Unmarshal(bytes, &data); err != nil {
+		return err
 	}
-	return device
+	// TODO: Check flag for pretty print format
+	return prettyPrintJSON(data)
 }

+ 25 - 23
cmd/syncthing/gui.go

@@ -28,12 +28,14 @@ import (
 	"time"
 
 	metrics "github.com/rcrowley/go-metrics"
+	"github.com/syncthing/syncthing/lib/build"
 	"github.com/syncthing/syncthing/lib/config"
 	"github.com/syncthing/syncthing/lib/connections"
 	"github.com/syncthing/syncthing/lib/db"
 	"github.com/syncthing/syncthing/lib/discover"
 	"github.com/syncthing/syncthing/lib/events"
 	"github.com/syncthing/syncthing/lib/fs"
+	"github.com/syncthing/syncthing/lib/locations"
 	"github.com/syncthing/syncthing/lib/logger"
 	"github.com/syncthing/syncthing/lib/model"
 	"github.com/syncthing/syncthing/lib/protocol"
@@ -567,7 +569,7 @@ func noCacheMiddleware(h http.Handler) http.Handler {
 
 func withDetailsMiddleware(id protocol.DeviceID, h http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		w.Header().Set("X-Syncthing-Version", Version)
+		w.Header().Set("X-Syncthing-Version", build.Version)
 		w.Header().Set("X-Syncthing-ID", id.String())
 		h.ServeHTTP(w, r)
 	})
@@ -609,14 +611,14 @@ func (s *apiService) getJSMetadata(w http.ResponseWriter, r *http.Request) {
 
 func (s *apiService) getSystemVersion(w http.ResponseWriter, r *http.Request) {
 	sendJSON(w, map[string]interface{}{
-		"version":     Version,
-		"codename":    Codename,
-		"longVersion": LongVersion,
+		"version":     build.Version,
+		"codename":    build.Codename,
+		"longVersion": build.LongVersion,
 		"os":          runtime.GOOS,
 		"arch":        runtime.GOARCH,
-		"isBeta":      IsBeta,
-		"isCandidate": IsCandidate,
-		"isRelease":   IsRelease,
+		"isBeta":      build.IsBeta,
+		"isCandidate": build.IsCandidate,
+		"isRelease":   build.IsRelease,
 	})
 }
 
@@ -1080,7 +1082,7 @@ func (s *apiService) getSupportBundle(w http.ResponseWriter, r *http.Request) {
 	}
 
 	// Panic files
-	if panicFiles, err := filepath.Glob(filepath.Join(baseDirs["config"], "panic*")); err == nil {
+	if panicFiles, err := filepath.Glob(filepath.Join(locations.GetBaseDir(locations.ConfigBaseDir), "panic*")); err == nil {
 		for _, f := range panicFiles {
 			if panicFile, err := ioutil.ReadFile(f); err != nil {
 				l.Warnf("Support bundle: failed to load %s: %s", filepath.Base(f), err)
@@ -1091,16 +1093,16 @@ func (s *apiService) getSupportBundle(w http.ResponseWriter, r *http.Request) {
 	}
 
 	// Archived log (default on Windows)
-	if logFile, err := ioutil.ReadFile(locations[locLogFile]); err == nil {
+	if logFile, err := ioutil.ReadFile(locations.Get(locations.LogFile)); err == nil {
 		files = append(files, fileEntry{name: "log-ondisk.txt", data: logFile})
 	}
 
 	// Version and platform information as a JSON
 	if versionPlatform, err := json.MarshalIndent(map[string]string{
 		"now":         time.Now().Format(time.RFC3339),
-		"version":     Version,
-		"codename":    Codename,
-		"longVersion": LongVersion,
+		"version":     build.Version,
+		"codename":    build.Codename,
+		"longVersion": build.LongVersion,
 		"os":          runtime.GOOS,
 		"arch":        runtime.GOARCH,
 	}, "", "  "); err == nil {
@@ -1118,14 +1120,14 @@ func (s *apiService) getSupportBundle(w http.ResponseWriter, r *http.Request) {
 
 	// Heap and CPU Proofs as a pprof extension
 	var heapBuffer, cpuBuffer bytes.Buffer
-	filename := fmt.Sprintf("syncthing-heap-%s-%s-%s-%s.pprof", runtime.GOOS, runtime.GOARCH, Version, time.Now().Format("150405")) // hhmmss
+	filename := fmt.Sprintf("syncthing-heap-%s-%s-%s-%s.pprof", runtime.GOOS, runtime.GOARCH, build.Version, time.Now().Format("150405")) // hhmmss
 	runtime.GC()
 	if err := pprof.WriteHeapProfile(&heapBuffer); err == nil {
 		files = append(files, fileEntry{name: filename, data: heapBuffer.Bytes()})
 	}
 
 	const duration = 4 * time.Second
-	filename = fmt.Sprintf("syncthing-cpu-%s-%s-%s-%s.pprof", runtime.GOOS, runtime.GOARCH, Version, time.Now().Format("150405")) // hhmmss
+	filename = fmt.Sprintf("syncthing-cpu-%s-%s-%s-%s.pprof", runtime.GOOS, runtime.GOARCH, build.Version, time.Now().Format("150405")) // hhmmss
 	if err := pprof.StartCPUProfile(&cpuBuffer); err == nil {
 		time.Sleep(duration)
 		pprof.StopCPUProfile()
@@ -1142,7 +1144,7 @@ func (s *apiService) getSupportBundle(w http.ResponseWriter, r *http.Request) {
 
 	// Set zip file name and path
 	zipFileName := fmt.Sprintf("support-bundle-%s-%s.zip", s.id.Short().String(), time.Now().Format("2006-01-02T150405"))
-	zipFilePath := filepath.Join(baseDirs["config"], zipFileName)
+	zipFilePath := filepath.Join(locations.GetBaseDir(locations.ConfigBaseDir), zipFileName)
 
 	// Write buffer zip to local zip file (back up)
 	if err := ioutil.WriteFile(zipFilePath, zipFilesBuffer.Bytes(), 0600); err != nil {
@@ -1323,16 +1325,16 @@ func (s *apiService) getSystemUpgrade(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 	opts := s.cfg.Options()
-	rel, err := upgrade.LatestRelease(opts.ReleasesURL, Version, opts.UpgradeToPreReleases)
+	rel, err := upgrade.LatestRelease(opts.ReleasesURL, build.Version, opts.UpgradeToPreReleases)
 	if err != nil {
 		http.Error(w, err.Error(), 500)
 		return
 	}
 	res := make(map[string]interface{})
-	res["running"] = Version
+	res["running"] = build.Version
 	res["latest"] = rel.Tag
-	res["newer"] = upgrade.CompareVersions(rel.Tag, Version) == upgrade.Newer
-	res["majorNewer"] = upgrade.CompareVersions(rel.Tag, Version) == upgrade.MajorNewer
+	res["newer"] = upgrade.CompareVersions(rel.Tag, build.Version) == upgrade.Newer
+	res["majorNewer"] = upgrade.CompareVersions(rel.Tag, build.Version) == upgrade.MajorNewer
 
 	sendJSON(w, res)
 }
@@ -1365,14 +1367,14 @@ func (s *apiService) getLang(w http.ResponseWriter, r *http.Request) {
 
 func (s *apiService) postSystemUpgrade(w http.ResponseWriter, r *http.Request) {
 	opts := s.cfg.Options()
-	rel, err := upgrade.LatestRelease(opts.ReleasesURL, Version, opts.UpgradeToPreReleases)
+	rel, err := upgrade.LatestRelease(opts.ReleasesURL, build.Version, opts.UpgradeToPreReleases)
 	if err != nil {
 		l.Warnln("getting latest release:", err)
 		http.Error(w, err.Error(), 500)
 		return
 	}
 
-	if upgrade.CompareVersions(rel.Tag, Version) > upgrade.Equal {
+	if upgrade.CompareVersions(rel.Tag, build.Version) > upgrade.Equal {
 		err = upgrade.To(rel)
 		if err != nil {
 			l.Warnln("upgrading:", err)
@@ -1641,7 +1643,7 @@ func (s *apiService) getCPUProf(w http.ResponseWriter, r *http.Request) {
 		duration = 30 * time.Second
 	}
 
-	filename := fmt.Sprintf("syncthing-cpu-%s-%s-%s-%s.pprof", runtime.GOOS, runtime.GOARCH, Version, time.Now().Format("150405")) // hhmmss
+	filename := fmt.Sprintf("syncthing-cpu-%s-%s-%s-%s.pprof", runtime.GOOS, runtime.GOARCH, build.Version, time.Now().Format("150405")) // hhmmss
 
 	w.Header().Set("Content-Type", "application/octet-stream")
 	w.Header().Set("Content-Disposition", "attachment; filename="+filename)
@@ -1653,7 +1655,7 @@ func (s *apiService) getCPUProf(w http.ResponseWriter, r *http.Request) {
 }
 
 func (s *apiService) getHeapProf(w http.ResponseWriter, r *http.Request) {
-	filename := fmt.Sprintf("syncthing-heap-%s-%s-%s-%s.pprof", runtime.GOOS, runtime.GOARCH, Version, time.Now().Format("150405")) // hhmmss
+	filename := fmt.Sprintf("syncthing-heap-%s-%s-%s-%s.pprof", runtime.GOOS, runtime.GOARCH, build.Version, time.Now().Format("150405")) // hhmmss
 
 	w.Header().Set("Content-Type", "application/octet-stream")
 	w.Header().Set("Content-Disposition", "attachment; filename="+filename)

+ 3 - 2
cmd/syncthing/gui_csrf.go

@@ -14,6 +14,7 @@ import (
 	"strings"
 
 	"github.com/syncthing/syncthing/lib/config"
+	"github.com/syncthing/syncthing/lib/locations"
 	"github.com/syncthing/syncthing/lib/osutil"
 	"github.com/syncthing/syncthing/lib/rand"
 	"github.com/syncthing/syncthing/lib/sync"
@@ -115,7 +116,7 @@ func saveCsrfTokens() {
 	// We're ignoring errors in here. It's not super critical and there's
 	// nothing relevant we can do about them anyway...
 
-	name := locations[locCsrfTokens]
+	name := locations.Get(locations.CsrfTokens)
 	f, err := osutil.CreateAtomic(name)
 	if err != nil {
 		return
@@ -129,7 +130,7 @@ func saveCsrfTokens() {
 }
 
 func loadCsrfTokens() {
-	f, err := os.Open(locations[locCsrfTokens])
+	f, err := os.Open(locations.Get(locations.CsrfTokens))
 	if err != nil {
 		return
 	}

+ 0 - 125
cmd/syncthing/locations.go

@@ -1,125 +0,0 @@
-// Copyright (C) 2015 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 main
-
-import (
-	"os"
-	"path/filepath"
-	"runtime"
-	"strings"
-	"time"
-
-	"github.com/syncthing/syncthing/lib/fs"
-)
-
-type locationEnum string
-
-// Use strings as keys to make printout and serialization of the locations map
-// more meaningful.
-const (
-	locConfigFile    locationEnum = "config"
-	locCertFile      locationEnum = "certFile"
-	locKeyFile       locationEnum = "keyFile"
-	locHTTPSCertFile locationEnum = "httpsCertFile"
-	locHTTPSKeyFile  locationEnum = "httpsKeyFile"
-	locDatabase      locationEnum = "database"
-	locLogFile       locationEnum = "logFile"
-	locCsrfTokens    locationEnum = "csrfTokens"
-	locPanicLog      locationEnum = "panicLog"
-	locAuditLog      locationEnum = "auditLog"
-	locGUIAssets     locationEnum = "GUIAssets"
-	locDefFolder     locationEnum = "defFolder"
-)
-
-// Platform dependent directories
-var baseDirs = map[string]string{
-	"config": defaultConfigDir(), // Overridden by -home flag
-	"home":   homeDir(),          // User's home directory, *not* -home flag
-}
-
-// Use the variables from baseDirs here
-var locations = map[locationEnum]string{
-	locConfigFile:    "${config}/config.xml",
-	locCertFile:      "${config}/cert.pem",
-	locKeyFile:       "${config}/key.pem",
-	locHTTPSCertFile: "${config}/https-cert.pem",
-	locHTTPSKeyFile:  "${config}/https-key.pem",
-	locDatabase:      "${config}/index-v0.14.0.db",
-	locLogFile:       "${config}/syncthing.log", // -logfile on Windows
-	locCsrfTokens:    "${config}/csrftokens.txt",
-	locPanicLog:      "${config}/panic-${timestamp}.log",
-	locAuditLog:      "${config}/audit-${timestamp}.log",
-	locGUIAssets:     "${config}/gui",
-	locDefFolder:     "${home}/Sync",
-}
-
-// expandLocations replaces the variables in the location map with actual
-// directory locations.
-func expandLocations() error {
-	for key, dir := range locations {
-		for varName, value := range baseDirs {
-			dir = strings.Replace(dir, "${"+varName+"}", value, -1)
-		}
-		var err error
-		dir, err = fs.ExpandTilde(dir)
-		if err != nil {
-			return err
-		}
-		locations[key] = dir
-	}
-	return nil
-}
-
-// defaultConfigDir returns the default configuration directory, as figured
-// out by various the environment variables present on each platform, or dies
-// trying.
-func defaultConfigDir() string {
-	switch runtime.GOOS {
-	case "windows":
-		if p := os.Getenv("LocalAppData"); p != "" {
-			return filepath.Join(p, "Syncthing")
-		}
-		return filepath.Join(os.Getenv("AppData"), "Syncthing")
-
-	case "darwin":
-		dir, err := fs.ExpandTilde("~/Library/Application Support/Syncthing")
-		if err != nil {
-			l.Fatalln(err)
-		}
-		return dir
-
-	default:
-		if xdgCfg := os.Getenv("XDG_CONFIG_HOME"); xdgCfg != "" {
-			return filepath.Join(xdgCfg, "syncthing")
-		}
-		dir, err := fs.ExpandTilde("~/.config/syncthing")
-		if err != nil {
-			l.Fatalln(err)
-		}
-		return dir
-	}
-}
-
-// homeDir returns the user's home directory, or dies trying.
-func homeDir() string {
-	home, err := fs.ExpandTilde("~")
-	if err != nil {
-		l.Fatalln(err)
-	}
-	return home
-}
-
-func timestampedLoc(key locationEnum) string {
-	// We take the roundtrip via "${timestamp}" instead of passing the path
-	// directly through time.Format() to avoid issues when the path we are
-	// expanding contains numbers; otherwise for example
-	// /home/user2006/.../panic-20060102-150405.log would get both instances of
-	// 2006 replaced by 2015...
-	tpl := locations[key]
-	now := time.Now().Format("20060102-150405")
-	return strings.Replace(tpl, "${timestamp}", now, -1)
-}

+ 59 - 108
cmd/syncthing/main.go

@@ -16,12 +16,12 @@ import (
 	"io/ioutil"
 	"log"
 	"net/http"
+	_ "net/http/pprof" // Need to import this to support STPROFILER.
 	"net/url"
 	"os"
 	"os/signal"
 	"path"
 	"path/filepath"
-	"regexp"
 	"runtime"
 	"runtime/pprof"
 	"sort"
@@ -30,6 +30,7 @@ import (
 	"syscall"
 	"time"
 
+	"github.com/syncthing/syncthing/lib/build"
 	"github.com/syncthing/syncthing/lib/config"
 	"github.com/syncthing/syncthing/lib/connections"
 	"github.com/syncthing/syncthing/lib/db"
@@ -37,6 +38,7 @@ import (
 	"github.com/syncthing/syncthing/lib/discover"
 	"github.com/syncthing/syncthing/lib/events"
 	"github.com/syncthing/syncthing/lib/fs"
+	"github.com/syncthing/syncthing/lib/locations"
 	"github.com/syncthing/syncthing/lib/logger"
 	"github.com/syncthing/syncthing/lib/model"
 	"github.com/syncthing/syncthing/lib/osutil"
@@ -47,23 +49,6 @@ import (
 	"github.com/syncthing/syncthing/lib/upgrade"
 
 	"github.com/thejerf/suture"
-
-	_ "net/http/pprof" // Need to import this to support STPROFILER.
-)
-
-var (
-	Version           = "unknown-dev"
-	Codename          = "Erbium Earthworm"
-	BuildStamp        = "0"
-	BuildDate         time.Time
-	BuildHost         = "unknown"
-	BuildUser         = "unknown"
-	IsRelease         bool
-	IsCandidate       bool
-	IsBeta            bool
-	LongVersion       string
-	BuildTags         []string
-	allowedVersionExp = regexp.MustCompile(`^v\d+\.\d+\.\d+(-[a-z0-9]+)*(\.\d+)*(\+\d+-g[0-9a-f]+)?(-[^\s]+)?$`)
 )
 
 const (
@@ -83,46 +68,6 @@ const (
 	maxSystemLog         = 250
 )
 
-func init() {
-	if Version != "unknown-dev" {
-		// If not a generic dev build, version string should come from git describe
-		if !allowedVersionExp.MatchString(Version) {
-			l.Fatalf("Invalid version string %q;\n\tdoes not match regexp %v", Version, allowedVersionExp)
-		}
-	}
-}
-
-func setBuildMetadata() {
-	// Check for a clean release build. A release is something like
-	// "v0.1.2", with an optional suffix of letters and dot separated
-	// numbers like "-beta3.47". If there's more stuff, like a plus sign and
-	// a commit hash and so on, then it's not a release. If it has a dash in
-	// it, it's some sort of beta, release candidate or special build. If it
-	// has "-rc." in it, like "v0.14.35-rc.42", then it's a candidate build.
-	//
-	// So, every build that is not a stable release build has IsBeta = true.
-	// This is used to enable some extra debugging (the deadlock detector).
-	//
-	// Release candidate builds are also "betas" from this point of view and
-	// will have that debugging enabled. In addition, some features are
-	// forced for release candidates - auto upgrade, and usage reporting.
-
-	exp := regexp.MustCompile(`^v\d+\.\d+\.\d+(-[a-z]+[\d\.]+)?$`)
-	IsRelease = exp.MatchString(Version)
-	IsCandidate = strings.Contains(Version, "-rc.")
-	IsBeta = strings.Contains(Version, "-")
-
-	stamp, _ := strconv.Atoi(BuildStamp)
-	BuildDate = time.Unix(int64(stamp), 0)
-
-	date := BuildDate.UTC().Format("2006-01-02 15:04:05 MST")
-	LongVersion = fmt.Sprintf(`syncthing %s "%s" (%s %s-%s) %s@%s %s`, Version, Codename, runtime.Version(), runtime.GOOS, runtime.GOARCH, BuildUser, BuildHost, date)
-
-	if len(BuildTags) > 0 {
-		LongVersion = fmt.Sprintf("%s [%s]", LongVersion, strings.Join(BuildTags, ", "))
-	}
-}
-
 var (
 	myID protocol.DeviceID
 	stop = make(chan int)
@@ -320,8 +265,6 @@ func parseCommandLineOptions() RuntimeOptions {
 }
 
 func main() {
-	setBuildMetadata()
-
 	options := parseCommandLineOptions()
 	l.SetFlags(options.logFlags)
 
@@ -355,27 +298,25 @@ func main() {
 				l.Fatalln(err)
 			}
 		}
-		baseDirs["config"] = options.confDir
-	}
-
-	if err := expandLocations(); err != nil {
-		l.Fatalln(err)
+		if err := locations.SetBaseDir(locations.ConfigBaseDir, options.confDir); err != nil {
+			l.Fatalln(err)
+		}
 	}
 
 	if options.logFile == "" {
 		// Blank means use the default logfile location. We must set this
 		// *after* expandLocations above.
-		options.logFile = locations[locLogFile]
+		options.logFile = locations.Get(locations.LogFile)
 	}
 
 	if options.assetDir == "" {
 		// The asset dir is blank if STGUIASSETS wasn't set, in which case we
 		// should look for extra assets in the default place.
-		options.assetDir = locations[locGUIAssets]
+		options.assetDir = locations.Get(locations.GUIAssets)
 	}
 
 	if options.showVersion {
-		fmt.Println(LongVersion)
+		fmt.Println(build.LongVersion)
 		return
 	}
 
@@ -390,7 +331,10 @@ func main() {
 	}
 
 	if options.showDeviceId {
-		cert, err := tls.LoadX509KeyPair(locations[locCertFile], locations[locKeyFile])
+		cert, err := tls.LoadX509KeyPair(
+			locations.Get(locations.CertFile),
+			locations.Get(locations.KeyFile),
+		)
 		if err != nil {
 			l.Fatalln("Error reading device ID:", err)
 		}
@@ -411,7 +355,7 @@ func main() {
 	}
 
 	// Ensure that our home directory exists.
-	ensureDir(baseDirs["config"], 0700)
+	ensureDir(locations.GetBaseDir(locations.ConfigBaseDir), 0700)
 
 	if options.upgradeTo != "" {
 		err := upgrade.ToURL(options.upgradeTo)
@@ -521,24 +465,24 @@ func debugFacilities() string {
 func checkUpgrade() upgrade.Release {
 	cfg, _ := loadOrDefaultConfig()
 	opts := cfg.Options()
-	release, err := upgrade.LatestRelease(opts.ReleasesURL, Version, opts.UpgradeToPreReleases)
+	release, err := upgrade.LatestRelease(opts.ReleasesURL, build.Version, opts.UpgradeToPreReleases)
 	if err != nil {
 		l.Fatalln("Upgrade:", err)
 	}
 
-	if upgrade.CompareVersions(release.Tag, Version) <= 0 {
+	if upgrade.CompareVersions(release.Tag, build.Version) <= 0 {
 		noUpgradeMessage := "No upgrade available (current %q >= latest %q)."
-		l.Infof(noUpgradeMessage, Version, release.Tag)
+		l.Infof(noUpgradeMessage, build.Version, release.Tag)
 		os.Exit(exitNoUpgradeAvailable)
 	}
 
-	l.Infof("Upgrade available (current %q < latest %q)", Version, release.Tag)
+	l.Infof("Upgrade available (current %q < latest %q)", build.Version, release.Tag)
 	return release
 }
 
 func performUpgrade(release upgrade.Release) {
 	// Use leveldb database locks to protect against concurrent upgrades
-	_, err := db.Open(locations[locDatabase])
+	_, err := db.Open(locations.Get(locations.Database))
 	if err == nil {
 		err = upgrade.To(release)
 		if err != nil {
@@ -636,10 +580,17 @@ func syncthingMain(runtimeOptions RuntimeOptions) {
 	osutil.MaximizeOpenFileLimit()
 
 	// Ensure that we have a certificate and key.
-	cert, err := tls.LoadX509KeyPair(locations[locCertFile], locations[locKeyFile])
+	cert, err := tls.LoadX509KeyPair(
+		locations.Get(locations.CertFile),
+		locations.Get(locations.KeyFile),
+	)
 	if err != nil {
 		l.Infof("Generating ECDSA key and certificate for %s...", tlsDefaultCommonName)
-		cert, err = tlsutil.NewCertificate(locations[locCertFile], locations[locKeyFile], tlsDefaultCommonName)
+		cert, err = tlsutil.NewCertificate(
+			locations.Get(locations.CertFile),
+			locations.Get(locations.KeyFile),
+			tlsDefaultCommonName,
+		)
 		if err != nil {
 			l.Fatalln(err)
 		}
@@ -648,7 +599,7 @@ func syncthingMain(runtimeOptions RuntimeOptions) {
 	myID = protocol.NewDeviceID(cert.Certificate[0])
 	l.SetPrefix(fmt.Sprintf("[%s] ", myID.String()[:5]))
 
-	l.Infoln(LongVersion)
+	l.Infoln(build.LongVersion)
 	l.Infoln("My ID:", myID)
 
 	// Select SHA256 implementation and report. Affected by the
@@ -659,7 +610,7 @@ func syncthingMain(runtimeOptions RuntimeOptions) {
 	// Emit the Starting event, now that we know who we are.
 
 	events.Default.Log(events.Starting, map[string]string{
-		"home": baseDirs["config"],
+		"home": locations.GetBaseDir(locations.ConfigBaseDir),
 		"myID": myID.String(),
 	})
 
@@ -683,7 +634,7 @@ func syncthingMain(runtimeOptions RuntimeOptions) {
 	perf := cpuBench(3, 150*time.Millisecond, true)
 	l.Infof("Hashing performance is %.02f MB/s", perf)
 
-	dbFile := locations[locDatabase]
+	dbFile := locations.Get(locations.Database)
 	ldb, err := db.Open(dbFile)
 	if err != nil {
 		l.Fatalln("Error opening database:", err)
@@ -698,10 +649,10 @@ func syncthingMain(runtimeOptions RuntimeOptions) {
 	}
 
 	protectedFiles := []string{
-		locations[locDatabase],
-		locations[locConfigFile],
-		locations[locCertFile],
-		locations[locKeyFile],
+		locations.Get(locations.Database),
+		locations.Get(locations.ConfigFile),
+		locations.Get(locations.CertFile),
+		locations.Get(locations.KeyFile),
 	}
 
 	// Remove database entries for folders that no longer exist in the config
@@ -723,10 +674,10 @@ func syncthingMain(runtimeOptions RuntimeOptions) {
 	// 0.14.45-pineapple is not.
 
 	prevParts := strings.Split(prevVersion, "-")
-	curParts := strings.Split(Version, "-")
+	curParts := strings.Split(build.Version, "-")
 	if prevParts[0] != curParts[0] {
 		if prevVersion != "" {
-			l.Infoln("Detected upgrade from", prevVersion, "to", Version)
+			l.Infoln("Detected upgrade from", prevVersion, "to", build.Version)
 		}
 
 		// Drop delta indexes in case we've changed random stuff we
@@ -734,16 +685,16 @@ func syncthingMain(runtimeOptions RuntimeOptions) {
 		db.DropDeltaIndexIDs(ldb)
 
 		// Remember the new version.
-		miscDB.PutString("prevVersion", Version)
+		miscDB.PutString("prevVersion", build.Version)
 	}
 
-	m := model.NewModel(cfg, myID, "syncthing", Version, ldb, protectedFiles)
+	m := model.NewModel(cfg, myID, "syncthing", build.Version, ldb, protectedFiles)
 
 	if t := os.Getenv("STDEADLOCKTIMEOUT"); t != "" {
 		if secs, _ := strconv.Atoi(t); secs > 0 {
 			m.StartDeadlockDetector(time.Duration(secs) * time.Second)
 		}
-	} else if !IsRelease || IsBeta {
+	} else if !build.IsRelease || build.IsBeta {
 		m.StartDeadlockDetector(20 * time.Minute)
 	}
 
@@ -842,7 +793,7 @@ func syncthingMain(runtimeOptions RuntimeOptions) {
 
 	// Candidate builds always run with usage reporting.
 
-	if opts := cfg.Options(); IsCandidate {
+	if opts := cfg.Options(); build.IsCandidate {
 		l.Infoln("Anonymous usage reporting is always enabled for candidate releases.")
 		if opts.URAccepted != usageReportVersion {
 			opts.URAccepted = usageReportVersion
@@ -870,7 +821,7 @@ func syncthingMain(runtimeOptions RuntimeOptions) {
 	// unless we are in a build where it's disabled or the STNOUPGRADE
 	// environment variable is set.
 
-	if IsCandidate && !upgrade.DisabledByCompilation && !noUpgradeFromEnv {
+	if build.IsCandidate && !upgrade.DisabledByCompilation && !noUpgradeFromEnv {
 		l.Infoln("Automatic upgrade is always enabled for candidate releases.")
 		if opts := cfg.Options(); opts.AutoUpgradeIntervalH == 0 || opts.AutoUpgradeIntervalH > 24 {
 			opts.AutoUpgradeIntervalH = 12
@@ -943,7 +894,7 @@ func setupSignalHandling() {
 }
 
 func loadOrDefaultConfig() (*config.Wrapper, error) {
-	cfgFile := locations[locConfigFile]
+	cfgFile := locations.Get(locations.ConfigFile)
 	cfg, err := config.Load(cfgFile, myID)
 
 	if err != nil {
@@ -954,7 +905,7 @@ func loadOrDefaultConfig() (*config.Wrapper, error) {
 }
 
 func loadConfigAtStartup() *config.Wrapper {
-	cfgFile := locations[locConfigFile]
+	cfgFile := locations.Get(locations.ConfigFile)
 	cfg, err := config.Load(cfgFile, myID)
 	if os.IsNotExist(err) {
 		cfg = defaultConfig(cfgFile)
@@ -1018,7 +969,7 @@ func startAuditing(mainService *suture.Supervisor, auditFile string) {
 		auditDest = "stderr"
 	} else {
 		if auditFile == "" {
-			auditFile = timestampedLoc(locAuditLog)
+			auditFile = locations.GetTimestamped(locations.AuditLog)
 			auditFlags = os.O_WRONLY | os.O_CREATE | os.O_EXCL
 		} else {
 			auditFlags = os.O_WRONLY | os.O_CREATE | os.O_APPEND
@@ -1054,7 +1005,7 @@ func setupGUI(mainService *suture.Supervisor, cfg *config.Wrapper, m *model.Mode
 	cpu := newCPUService()
 	mainService.Add(cpu)
 
-	api := newAPIService(myID, cfg, locations[locHTTPSCertFile], locations[locHTTPSKeyFile], runtimeOptions.assetDir, m, defaultSub, diskSub, discoverer, connectionsService, errors, systemLog, cpu)
+	api := newAPIService(myID, cfg, locations.Get(locations.HTTPSCertFile), locations.Get(locations.HTTPSKeyFile), runtimeOptions.assetDir, m, defaultSub, diskSub, discoverer, connectionsService, errors, systemLog, cpu)
 	cfg.Subscribe(api)
 	mainService.Add(api)
 
@@ -1074,13 +1025,13 @@ func defaultConfig(cfgFile string) *config.Wrapper {
 		return config.Wrap(cfgFile, newCfg)
 	}
 
-	newCfg.Folders = append(newCfg.Folders, config.NewFolderConfiguration(myID, "default", "Default Folder", fs.FilesystemTypeBasic, locations[locDefFolder]))
+	newCfg.Folders = append(newCfg.Folders, config.NewFolderConfiguration(myID, "default", "Default Folder", fs.FilesystemTypeBasic, locations.Get(locations.DefFolder)))
 	l.Infoln("Default folder created and/or linked to new config")
 	return config.Wrap(cfgFile, newCfg)
 }
 
 func resetDB() error {
-	return os.RemoveAll(locations[locDatabase])
+	return os.RemoveAll(locations.Get(locations.Database))
 }
 
 func restart() {
@@ -1142,10 +1093,10 @@ func autoUpgrade(cfg *config.Wrapper) {
 		select {
 		case event := <-sub.C():
 			data, ok := event.Data.(map[string]string)
-			if !ok || data["clientName"] != "syncthing" || upgrade.CompareVersions(data["clientVersion"], Version) != upgrade.Newer {
+			if !ok || data["clientName"] != "syncthing" || upgrade.CompareVersions(data["clientVersion"], build.Version) != upgrade.Newer {
 				continue
 			}
-			l.Infof("Connected to device %s with a newer version (current %q < remote %q). Checking for upgrades.", data["id"], Version, data["clientVersion"])
+			l.Infof("Connected to device %s with a newer version (current %q < remote %q). Checking for upgrades.", data["id"], build.Version, data["clientVersion"])
 		case <-timer.C:
 		}
 
@@ -1157,7 +1108,7 @@ func autoUpgrade(cfg *config.Wrapper) {
 			checkInterval = time.Hour
 		}
 
-		rel, err := upgrade.LatestRelease(opts.ReleasesURL, Version, opts.UpgradeToPreReleases)
+		rel, err := upgrade.LatestRelease(opts.ReleasesURL, build.Version, opts.UpgradeToPreReleases)
 		if err == upgrade.ErrUpgradeUnsupported {
 			events.Default.Unsubscribe(sub)
 			return
@@ -1170,13 +1121,13 @@ func autoUpgrade(cfg *config.Wrapper) {
 			continue
 		}
 
-		if upgrade.CompareVersions(rel.Tag, Version) != upgrade.Newer {
+		if upgrade.CompareVersions(rel.Tag, build.Version) != upgrade.Newer {
 			// Skip equal, older or majorly newer (incompatible) versions
 			timer.Reset(checkInterval)
 			continue
 		}
 
-		l.Infof("Automatic upgrade (current %q < latest %q)", Version, rel.Tag)
+		l.Infof("Automatic upgrade (current %q < latest %q)", build.Version, rel.Tag)
 		err = upgrade.To(rel)
 		if err != nil {
 			l.Warnln("Automatic upgrade:", err)
@@ -1209,7 +1160,7 @@ func cleanConfigDirectory() {
 	}
 
 	for pat, dur := range patterns {
-		fs := fs.NewFilesystem(fs.FilesystemTypeBasic, baseDirs["config"])
+		fs := fs.NewFilesystem(fs.FilesystemTypeBasic, locations.GetBaseDir(locations.ConfigBaseDir))
 		files, err := fs.Glob(pat)
 		if err != nil {
 			l.Infoln("Cleaning:", err)
@@ -1250,13 +1201,13 @@ func checkShortIDs(cfg *config.Wrapper) error {
 }
 
 func showPaths(options RuntimeOptions) {
-	fmt.Printf("Configuration file:\n\t%s\n\n", locations[locConfigFile])
-	fmt.Printf("Database directory:\n\t%s\n\n", locations[locDatabase])
-	fmt.Printf("Device private key & certificate files:\n\t%s\n\t%s\n\n", locations[locKeyFile], locations[locCertFile])
-	fmt.Printf("HTTPS private key & certificate files:\n\t%s\n\t%s\n\n", locations[locHTTPSKeyFile], locations[locHTTPSCertFile])
+	fmt.Printf("Configuration file:\n\t%s\n\n", locations.Get(locations.ConfigFile))
+	fmt.Printf("Database directory:\n\t%s\n\n", locations.Get(locations.Database))
+	fmt.Printf("Device private key & certificate files:\n\t%s\n\t%s\n\n", locations.Get(locations.KeyFile), locations.Get(locations.CertFile))
+	fmt.Printf("HTTPS private key & certificate files:\n\t%s\n\t%s\n\n", locations.Get(locations.HTTPSKeyFile), locations.Get(locations.HTTPSCertFile))
 	fmt.Printf("Log file:\n\t%s\n\n", options.logFile)
 	fmt.Printf("GUI override directory:\n\t%s\n\n", options.assetDir)
-	fmt.Printf("Default sync folder directory:\n\t%s\n\n", locations[locDefFolder])
+	fmt.Printf("Default sync folder directory:\n\t%s\n\n", locations.Get(locations.DefFolder))
 }
 
 func setPauseState(cfg *config.Wrapper, paused bool) {

+ 0 - 26
cmd/syncthing/main_test.go

@@ -36,29 +36,3 @@ func TestShortIDCheck(t *testing.T) {
 		t.Error("Should have gotten an error")
 	}
 }
-
-func TestAllowedVersions(t *testing.T) {
-	testcases := []struct {
-		ver     string
-		allowed bool
-	}{
-		{"v0.13.0", true},
-		{"v0.12.11+22-gabcdef0", true},
-		{"v0.13.0-beta0", true},
-		{"v0.13.0-beta47", true},
-		{"v0.13.0-beta47+1-gabcdef0", true},
-		{"v0.13.0-beta.0", true},
-		{"v0.13.0-beta.47", true},
-		{"v0.13.0-beta.0+1-gabcdef0", true},
-		{"v0.13.0-beta.47+1-gabcdef0", true},
-		{"v0.13.0-some-weird-but-allowed-tag", true},
-		{"v0.13.0-allowed.to.do.this", true},
-		{"v0.13.0+not.allowed.to.do.this", false},
-	}
-
-	for i, c := range testcases {
-		if allowed := allowedVersionExp.MatchString(c.ver); allowed != c.allowed {
-			t.Errorf("%d: incorrect result %v != %v for %q", i, allowed, c.allowed, c.ver)
-		}
-	}
-}

+ 2 - 1
cmd/syncthing/monitor.go

@@ -17,6 +17,7 @@ import (
 	"syscall"
 	"time"
 
+	"github.com/syncthing/syncthing/lib/locations"
 	"github.com/syncthing/syncthing/lib/osutil"
 	"github.com/syncthing/syncthing/lib/sync"
 )
@@ -198,7 +199,7 @@ func copyStderr(stderr io.Reader, dst io.Writer) {
 			}
 
 			if strings.HasPrefix(line, "panic:") || strings.HasPrefix(line, "fatal error:") {
-				panicFd, err = os.Create(timestampedLoc(locPanicLog))
+				panicFd, err = os.Create(locations.GetTimestamped(locations.PanicLog))
 				if err != nil {
 					l.Warnln("Create panic log:", err)
 					continue

+ 3 - 2
cmd/syncthing/usage_report.go

@@ -20,6 +20,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/syncthing/syncthing/lib/build"
 	"github.com/syncthing/syncthing/lib/config"
 	"github.com/syncthing/syncthing/lib/connections"
 	"github.com/syncthing/syncthing/lib/dialer"
@@ -41,8 +42,8 @@ func reportData(cfg configIntf, m modelIntf, connectionsService connectionsIntf,
 	res := make(map[string]interface{})
 	res["urVersion"] = version
 	res["uniqueID"] = opts.URUniqueID
-	res["version"] = Version
-	res["longVersion"] = LongVersion
+	res["version"] = build.Version
+	res["longVersion"] = build.LongVersion
 	res["platform"] = runtime.GOOS + "-" + runtime.GOARCH
 	res["numFolders"] = len(cfg.Folders())
 	res["numDevices"] = len(cfg.Devices())

+ 5 - 2
go.mod

@@ -1,14 +1,15 @@
 module github.com/syncthing/syncthing
 
 require (
-	github.com/AudriusButkevicius/cli v0.0.0-20140727204646-7f561c78b5a4
 	github.com/AudriusButkevicius/go-nat-pmp v0.0.0-20160522074932-452c97607362
+	github.com/AudriusButkevicius/recli v0.0.5
 	github.com/bkaradzic/go-lz4 v0.0.0-20160924222819-7224d8d8f27e
 	github.com/calmh/du v1.0.1
 	github.com/calmh/xdr v1.1.0
 	github.com/chmduquesne/rollinghash v0.0.0-20180912150627-a60f8e7142b5
 	github.com/d4l3k/messagediff v1.2.1
 	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/flynn-archive/go-shlex v0.0.0-20150515145356-3f9db97f8568
 	github.com/gobwas/glob v0.0.0-20170212200151-51eb1ee00b6d
 	github.com/gogo/protobuf v1.2.0
 	github.com/golang/groupcache v0.0.0-20171101203131-84a468cf14b4
@@ -17,13 +18,14 @@ require (
 	github.com/kballard/go-shellquote v0.0.0-20170619183022-cd60e84ee657
 	github.com/kr/pretty v0.1.0 // indirect
 	github.com/lib/pq v1.0.0
+	github.com/mattn/go-isatty v0.0.4
 	github.com/minio/sha256-simd v0.0.0-20190117184323-cc1980cb0338
 	github.com/onsi/ginkgo v0.0.0-20171221013426-6c46eb8334b3 // indirect
 	github.com/onsi/gomega v0.0.0-20171227184521-ba3724c94e4d // indirect
 	github.com/oschwald/geoip2-golang v1.1.0
 	github.com/oschwald/maxminddb-golang v0.0.0-20170901134056-26fe5ace1c70 // indirect
 	github.com/petermattis/goid v0.0.0-20170816195418-3db12ebb2a59 // indirect
-	github.com/pkg/errors v0.0.0-20171216070316-e881fd58d78e
+	github.com/pkg/errors v0.8.1
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/prometheus/client_golang v0.9.2
 	github.com/rcrowley/go-metrics v0.0.0-20171128170426-e181e095bae9
@@ -32,6 +34,7 @@ require (
 	github.com/syncthing/notify v0.0.0-20181107104724-4e389ea6c0d8
 	github.com/syndtr/goleveldb v0.0.0-20171214120811-34011bf325bc
 	github.com/thejerf/suture v3.0.2+incompatible
+	github.com/urfave/cli v1.20.0
 	github.com/vitrun/qart v0.0.0-20160531060029-bf64b92db6b0
 	golang.org/x/crypto v0.0.0-20171231215028-0fcca4842a8d
 	golang.org/x/net v0.0.0-20181201002055-351d144fa1fc

+ 10 - 4
go.sum

@@ -1,7 +1,7 @@
-github.com/AudriusButkevicius/cli v0.0.0-20140727204646-7f561c78b5a4 h1:Cy4N5BdzSyWRnkNyzkIMKPSuzENT4AGxC+YFo0OOcCI=
-github.com/AudriusButkevicius/cli v0.0.0-20140727204646-7f561c78b5a4/go.mod h1:mK5FQv1k6rd64lZeDQ+JgG5hSERyVEYeC3qXrbN+2nw=
 github.com/AudriusButkevicius/go-nat-pmp v0.0.0-20160522074932-452c97607362 h1:l4qGIzSY0WhdXdR74XMYAtfc0Ri/RJVM4p6x/E/+WkA=
 github.com/AudriusButkevicius/go-nat-pmp v0.0.0-20160522074932-452c97607362/go.mod h1:CEaBhA5lh1spxbPOELh5wNLKGsVQoahjUhVrJViVK8s=
+github.com/AudriusButkevicius/recli v0.0.5 h1:xUa55PvWTHBm17T6RvjElRO3y5tALpdceH86vhzQ5wg=
+github.com/AudriusButkevicius/recli v0.0.5/go.mod h1:Q2E26yc6RvWWEz/TJ/goUp6yXvipYdJI096hpoaqsNs=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 github.com/bkaradzic/go-lz4 v0.0.0-20160924222819-7224d8d8f27e h1:2augTYh6E+XoNrrivZJBadpThP/dsvYKj0nzqfQ8tM4=
@@ -16,6 +16,8 @@ github.com/d4l3k/messagediff v1.2.1 h1:ZcAIMYsUg0EAp9X+tt8/enBE/Q8Yd5kzPynLyKptt
 github.com/d4l3k/messagediff v1.2.1/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/flynn-archive/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BMXYYRWTLOJKlh+lOBt6nUQgXAfB7oVIQt5cNreqSLI=
+github.com/flynn-archive/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:rZfgFAXFS/z/lEd6LJmf9HVZ1LkgYiHx5pHhV5DR16M=
 github.com/gobwas/glob v0.0.0-20170212200151-51eb1ee00b6d h1:IngNQgbqr5ZOU0exk395Szrvkzes9Ilk1fmJfkw7d+M=
 github.com/gobwas/glob v0.0.0-20170212200151-51eb1ee00b6d/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
 github.com/gogo/protobuf v1.2.0 h1:xU6/SpYbvkNYiptHJYEDRseDLvYE7wSqhYYNy0QSUzI=
@@ -37,6 +39,8 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=
 github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
+github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs=
+github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
 github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
 github.com/minio/sha256-simd v0.0.0-20190117184323-cc1980cb0338 h1:USW1+zAUkUSvk097CAX/i8KR3r6f+DHNhk6Xe025Oyw=
@@ -51,8 +55,8 @@ github.com/oschwald/maxminddb-golang v0.0.0-20170901134056-26fe5ace1c70 h1:XGLYU
 github.com/oschwald/maxminddb-golang v0.0.0-20170901134056-26fe5ace1c70/go.mod h1:3jhIUymTJ5VREKyIhWm66LJiQt04F0UCDdodShpjWsY=
 github.com/petermattis/goid v0.0.0-20170816195418-3db12ebb2a59 h1:2pHcLyJYXivxVvpoCc29uo3GDU1qFfJ1ggXKGYMrM0E=
 github.com/petermattis/goid v0.0.0-20170816195418-3db12ebb2a59/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o=
-github.com/pkg/errors v0.0.0-20171216070316-e881fd58d78e h1:+RHxT/gm0O3UF7nLJbdNzAmULvCFt4XfXHWzh3XI/zs=
-github.com/pkg/errors v0.0.0-20171216070316-e881fd58d78e/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/prometheus/client_golang v0.9.2 h1:awm861/B8OKDd2I/6o1dy3ra4BamzKhYOiGItCeZ740=
@@ -75,6 +79,8 @@ github.com/syndtr/goleveldb v0.0.0-20171214120811-34011bf325bc h1:yhWARKbbDg8UBR
 github.com/syndtr/goleveldb v0.0.0-20171214120811-34011bf325bc/go.mod h1:Z4AUp2Km+PwemOoO/VB5AOx9XSsIItzFjoJlOSiYmn0=
 github.com/thejerf/suture v3.0.2+incompatible h1:GtMydYcnK4zBJ0KL6Lx9vLzl6Oozb65wh252FTBxrvM=
 github.com/thejerf/suture v3.0.2+incompatible/go.mod h1:ibKwrVj+Uzf3XZdAiNWUouPaAbSoemxOHLmJmwheEMc=
+github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw=
+github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
 github.com/vitrun/qart v0.0.0-20160531060029-bf64b92db6b0 h1:okhMind4q9H1OxF44gNegWkiP4H/gsTFLalHFa4OOUI=
 github.com/vitrun/qart v0.0.0-20160531060029-bf64b92db6b0/go.mod h1:TTbGUfE+cXXceWtbTHq6lqcTvYPBKLNejBEbnUsQJtU=
 golang.org/x/crypto v0.0.0-20171231215028-0fcca4842a8d h1:GrqEEc3+MtHKTsZrdIGVoYDgLpbSRzW1EF+nLu0PcHE=

+ 81 - 0
lib/build/build.go

@@ -0,0 +1,81 @@
+// Copyright (C) 2019 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 build
+
+import (
+	"fmt"
+	"log"
+	"regexp"
+	"runtime"
+	"strconv"
+	"strings"
+	"time"
+)
+
+var (
+	// Injected by build script
+	Version = "unknown-dev"
+	Host    = "unknown" // Set by build script
+	User    = "unknown" // Set by build script
+	Stamp   = "0"       // Set by build script
+
+	// Static
+	Codename = "Erbium Earthworm"
+
+	// Set by init()
+	Date        time.Time
+	IsRelease   bool
+	IsCandidate bool
+	IsBeta      bool
+	LongVersion string
+
+	// Set by Go build tags
+	Tags []string
+
+	allowedVersionExp = regexp.MustCompile(`^v\d+\.\d+\.\d+(-[a-z0-9]+)*(\.\d+)*(\+\d+-g[0-9a-f]+)?(-[^\s]+)?$`)
+)
+
+func init() {
+	if Version != "unknown-dev" {
+		// If not a generic dev build, version string should come from git describe
+		if !allowedVersionExp.MatchString(Version) {
+			log.Fatalf("Invalid version string %q;\n\tdoes not match regexp %v", Version, allowedVersionExp)
+		}
+	}
+	setBuildData()
+}
+
+func setBuildData() {
+	// Check for a clean release build. A release is something like
+	// "v0.1.2", with an optional suffix of letters and dot separated
+	// numbers like "-beta3.47". If there's more stuff, like a plus sign and
+	// a commit hash and so on, then it's not a release. If it has a dash in
+	// it, it's some sort of beta, release candidate or special build. If it
+	// has "-rc." in it, like "v0.14.35-rc.42", then it's a candidate build.
+	//
+	// So, every build that is not a stable release build has IsBeta = true.
+	// This is used to enable some extra debugging (the deadlock detector).
+	//
+	// Release candidate builds are also "betas" from this point of view and
+	// will have that debugging enabled. In addition, some features are
+	// forced for release candidates - auto upgrade, and usage reporting.
+
+	exp := regexp.MustCompile(`^v\d+\.\d+\.\d+(-[a-z]+[\d\.]+)?$`)
+	IsRelease = exp.MatchString(Version)
+	IsCandidate = strings.Contains(Version, "-rc.")
+	IsBeta = strings.Contains(Version, "-")
+
+	stamp, _ := strconv.Atoi(Stamp)
+	Date = time.Unix(int64(stamp), 0)
+
+	date := Date.UTC().Format("2006-01-02 15:04:05 MST")
+	LongVersion = fmt.Sprintf(`syncthing %s "%s" (%s %s-%s) %s@%s %s`, Version, Codename, runtime.Version(), runtime.GOOS, runtime.GOARCH, User, Host, date)
+
+	if len(Tags) > 0 {
+		LongVersion = fmt.Sprintf("%s [%s]", LongVersion, strings.Join(Tags, ", "))
+	}
+}

+ 37 - 0
lib/build/build_test.go

@@ -0,0 +1,37 @@
+// Copyright (C) 2019 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 build
+
+import (
+	"testing"
+)
+
+func TestAllowedVersions(t *testing.T) {
+	testcases := []struct {
+		ver     string
+		allowed bool
+	}{
+		{"v0.13.0", true},
+		{"v0.12.11+22-gabcdef0", true},
+		{"v0.13.0-beta0", true},
+		{"v0.13.0-beta47", true},
+		{"v0.13.0-beta47+1-gabcdef0", true},
+		{"v0.13.0-beta.0", true},
+		{"v0.13.0-beta.47", true},
+		{"v0.13.0-beta.0+1-gabcdef0", true},
+		{"v0.13.0-beta.47+1-gabcdef0", true},
+		{"v0.13.0-some-weird-but-allowed-tag", true},
+		{"v0.13.0-allowed.to.do.this", true},
+		{"v0.13.0+not.allowed.to.do.this", false},
+	}
+
+	for i, c := range testcases {
+		if allowed := allowedVersionExp.MatchString(c.ver); allowed != c.allowed {
+			t.Errorf("%d: incorrect result %v != %v for %q", i, allowed, c.allowed, c.ver)
+		}
+	}
+}

+ 2 - 2
cmd/syncthing/buildtag_noupgrade.go → lib/build/tags_noupgrade.go

@@ -6,8 +6,8 @@
 
 //+build noupgrade
 
-package main
+package build
 
 func init() {
-	BuildTags = append(BuildTags, "noupgrade")
+	Tags = append(Tags, "noupgrade")
 }

+ 2 - 2
cmd/syncthing/buildtag_race.go → lib/build/tags_race.go

@@ -6,8 +6,8 @@
 
 //+build race
 
-package main
+package build
 
 func init() {
-	BuildTags = append(BuildTags, "race")
+	Tags = append(Tags, "race")
 }

+ 5 - 1
lib/config/deviceconfiguration.go

@@ -10,12 +10,13 @@ import (
 	"sort"
 
 	"github.com/syncthing/syncthing/lib/protocol"
+	"github.com/syncthing/syncthing/lib/util"
 )
 
 type DeviceConfiguration struct {
 	DeviceID                 protocol.DeviceID    `xml:"id,attr" json:"deviceID"`
 	Name                     string               `xml:"name,attr,omitempty" json:"name"`
-	Addresses                []string             `xml:"address,omitempty" json:"addresses"`
+	Addresses                []string             `xml:"address,omitempty" json:"addresses" default:"dynamic"`
 	Compression              protocol.Compression `xml:"compression,attr" json:"compression"`
 	CertName                 string               `xml:"certName,attr,omitempty" json:"certName"`
 	Introducer               bool                 `xml:"introducer,attr" json:"introducer"`
@@ -36,6 +37,9 @@ func NewDeviceConfiguration(id protocol.DeviceID, name string) DeviceConfigurati
 		DeviceID: id,
 		Name:     name,
 	}
+
+	util.SetDefaults(&d)
+
 	d.prepare(nil)
 	return d
 }

+ 14 - 17
lib/config/folderconfiguration.go

@@ -32,12 +32,12 @@ type FolderConfiguration struct {
 	Path                    string                      `xml:"path,attr" json:"path"`
 	Type                    FolderType                  `xml:"type,attr" json:"type"`
 	Devices                 []FolderDeviceConfiguration `xml:"device" json:"devices"`
-	RescanIntervalS         int                         `xml:"rescanIntervalS,attr" json:"rescanIntervalS"`
-	FSWatcherEnabled        bool                        `xml:"fsWatcherEnabled,attr" json:"fsWatcherEnabled"`
-	FSWatcherDelayS         int                         `xml:"fsWatcherDelayS,attr" json:"fsWatcherDelayS"`
+	RescanIntervalS         int                         `xml:"rescanIntervalS,attr" json:"rescanIntervalS" default:"3600"`
+	FSWatcherEnabled        bool                        `xml:"fsWatcherEnabled,attr" json:"fsWatcherEnabled" default:"true"`
+	FSWatcherDelayS         int                         `xml:"fsWatcherDelayS,attr" json:"fsWatcherDelayS" default:"10"`
 	IgnorePerms             bool                        `xml:"ignorePerms,attr" json:"ignorePerms"`
-	AutoNormalize           bool                        `xml:"autoNormalize,attr" json:"autoNormalize"`
-	MinDiskFree             Size                        `xml:"minDiskFree" json:"minDiskFree"`
+	AutoNormalize           bool                        `xml:"autoNormalize,attr" json:"autoNormalize" default:"true"`
+	MinDiskFree             Size                        `xml:"minDiskFree" json:"minDiskFree" default:"1%"`
 	Versioning              VersioningConfiguration     `xml:"versioning" json:"versioning"`
 	Copiers                 int                         `xml:"copiers" json:"copiers"` // This defines how many files are handled concurrently.
 	PullerMaxPendingKiB     int                         `xml:"pullerMaxPendingKiB" json:"pullerMaxPendingKiB"`
@@ -46,7 +46,7 @@ type FolderConfiguration struct {
 	IgnoreDelete            bool                        `xml:"ignoreDelete" json:"ignoreDelete"`
 	ScanProgressIntervalS   int                         `xml:"scanProgressIntervalS" json:"scanProgressIntervalS"` // Set to a negative value to disable. Value of 0 will get replaced with value of 2 (default value)
 	PullerPauseS            int                         `xml:"pullerPauseS" json:"pullerPauseS"`
-	MaxConflicts            int                         `xml:"maxConflicts" json:"maxConflicts"`
+	MaxConflicts            int                         `xml:"maxConflicts" json:"maxConflicts" default:"-1"`
 	DisableSparseFiles      bool                        `xml:"disableSparseFiles" json:"disableSparseFiles"`
 	DisableTempIndexes      bool                        `xml:"disableTempIndexes" json:"disableTempIndexes"`
 	Paused                  bool                        `xml:"paused" json:"paused"`
@@ -69,18 +69,15 @@ type FolderDeviceConfiguration struct {
 
 func NewFolderConfiguration(myID protocol.DeviceID, id, label string, fsType fs.FilesystemType, path string) FolderConfiguration {
 	f := FolderConfiguration{
-		ID:               id,
-		Label:            label,
-		RescanIntervalS:  3600,
-		FSWatcherEnabled: true,
-		FSWatcherDelayS:  10,
-		MinDiskFree:      Size{Value: 1, Unit: "%"},
-		Devices:          []FolderDeviceConfiguration{{DeviceID: myID}},
-		AutoNormalize:    true,
-		MaxConflicts:     -1,
-		FilesystemType:   fsType,
-		Path:             path,
+		ID:             id,
+		Label:          label,
+		Devices:        []FolderDeviceConfiguration{{DeviceID: myID}},
+		FilesystemType: fsType,
+		Path:           path,
 	}
+
+	util.SetDefaults(&f)
+
 	f.prepare()
 	return f
 }

+ 1 - 1
lib/config/optionsconfiguration.go

@@ -14,7 +14,7 @@ import (
 
 type OptionsConfiguration struct {
 	ListenAddresses         []string `xml:"listenAddress" json:"listenAddresses" default:"default"`
-	GlobalAnnServers        []string `xml:"globalAnnounceServer" json:"globalAnnounceServers" json:"globalAnnounceServer" default:"default" restart:"true"`
+	GlobalAnnServers        []string `xml:"globalAnnounceServer" json:"globalAnnounceServers" default:"default" restart:"true"`
 	GlobalAnnEnabled        bool     `xml:"globalAnnounceEnabled" json:"globalAnnounceEnabled" default:"true" restart:"true"`
 	LocalAnnEnabled         bool     `xml:"localAnnounceEnabled" json:"localAnnounceEnabled" default:"true" restart:"true"`
 	LocalAnnPort            int      `xml:"localAnnouncePort" json:"localAnnouncePort" default:"21027" restart:"true"`

+ 4 - 2
lib/config/size.go

@@ -72,8 +72,10 @@ func (s Size) String() string {
 	return fmt.Sprintf("%v %s", s.Value, s.Unit)
 }
 
-func (Size) ParseDefault(s string) (interface{}, error) {
-	return ParseSize(s)
+func (s *Size) ParseDefault(str string) error {
+	sz, err := ParseSize(str)
+	*s = sz
+	return err
 }
 
 func checkFreeSpace(req Size, usage fs.Usage) error {

+ 22 - 1
lib/config/size_test.go

@@ -6,7 +6,28 @@
 
 package config
 
-import "testing"
+import (
+	"testing"
+
+	"github.com/syncthing/syncthing/lib/util"
+)
+
+type TestStruct struct {
+	Size Size `default:"10%"`
+}
+
+func TestSizeDefaults(t *testing.T) {
+	x := &TestStruct{}
+
+	util.SetDefaults(x)
+
+	if !x.Size.Percentage() {
+		t.Error("not percentage")
+	}
+	if x.Size.Value != 10 {
+		t.Error("not ten")
+	}
+}
 
 func TestParseSize(t *testing.T) {
 	cases := []struct {

+ 161 - 0
lib/locations/locations.go

@@ -0,0 +1,161 @@
+// Copyright (C) 2019 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 locations
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"time"
+
+	"github.com/syncthing/syncthing/lib/fs"
+)
+
+type LocationEnum string
+
+// Use strings as keys to make printout and serialization of the locations map
+// more meaningful.
+const (
+	ConfigFile    LocationEnum = "config"
+	CertFile      LocationEnum = "certFile"
+	KeyFile       LocationEnum = "keyFile"
+	HTTPSCertFile LocationEnum = "httpsCertFile"
+	HTTPSKeyFile  LocationEnum = "httpsKeyFile"
+	Database      LocationEnum = "database"
+	LogFile       LocationEnum = "logFile"
+	CsrfTokens    LocationEnum = "csrfTokens"
+	PanicLog      LocationEnum = "panicLog"
+	AuditLog      LocationEnum = "auditLog"
+	GUIAssets     LocationEnum = "GUIAssets"
+	DefFolder     LocationEnum = "defFolder"
+)
+
+type BaseDirEnum string
+
+const (
+	ConfigBaseDir BaseDirEnum = "config"
+	HomeBaseDir   BaseDirEnum = "home"
+)
+
+func init() {
+	err := expandLocations()
+	if err != nil {
+		panic(err)
+	}
+}
+
+func SetBaseDir(baseDirName BaseDirEnum, path string) error {
+	_, ok := baseDirs[baseDirName]
+	if !ok {
+		return fmt.Errorf("unknown base dir: %s", baseDirName)
+	}
+	baseDirs[baseDirName] = filepath.Clean(path)
+	return expandLocations()
+}
+
+func Get(location LocationEnum) string {
+	return locations[location]
+}
+
+func GetBaseDir(baseDir BaseDirEnum) string {
+	return baseDirs[baseDir]
+}
+
+// Platform dependent directories
+var baseDirs = map[BaseDirEnum]string{
+	ConfigBaseDir: defaultConfigDir(), // Overridden by -home flag
+	HomeBaseDir:   homeDir(),          // User's home directory, *not* -home flag
+}
+
+// Use the variables from baseDirs here
+var locationTemplates = map[LocationEnum]string{
+	ConfigFile:    "${config}/config.xml",
+	CertFile:      "${config}/cert.pem",
+	KeyFile:       "${config}/key.pem",
+	HTTPSCertFile: "${config}/https-cert.pem",
+	HTTPSKeyFile:  "${config}/https-key.pem",
+	Database:      "${config}/index-v0.14.0.db",
+	LogFile:       "${config}/syncthing.log", // -logfile on Windows
+	CsrfTokens:    "${config}/csrftokens.txt",
+	PanicLog:      "${config}/panic-${timestamp}.log",
+	AuditLog:      "${config}/audit-${timestamp}.log",
+	GUIAssets:     "${config}/gui",
+	DefFolder:     "${home}/Sync",
+}
+
+var locations = make(map[LocationEnum]string)
+
+// expandLocations replaces the variables in the locations map with actual
+// directory locations.
+func expandLocations() error {
+	newLocations := make(map[LocationEnum]string)
+	for key, dir := range locationTemplates {
+		for varName, value := range baseDirs {
+			dir = strings.Replace(dir, "${"+string(varName)+"}", value, -1)
+		}
+		var err error
+		dir, err = fs.ExpandTilde(dir)
+		if err != nil {
+			return err
+		}
+		newLocations[key] = filepath.Clean(dir)
+	}
+	locations = newLocations
+	return nil
+}
+
+// defaultConfigDir returns the default configuration directory, as figured
+// out by various the environment variables present on each platform, or dies
+// trying.
+func defaultConfigDir() string {
+	switch runtime.GOOS {
+	case "windows":
+		if p := os.Getenv("LocalAppData"); p != "" {
+			return filepath.Join(p, "Syncthing")
+		}
+		return filepath.Join(os.Getenv("AppData"), "Syncthing")
+
+	case "darwin":
+		dir, err := fs.ExpandTilde("~/Library/Application Support/Syncthing")
+		if err != nil {
+			panic(err)
+		}
+		return dir
+
+	default:
+		if xdgCfg := os.Getenv("XDG_CONFIG_HOME"); xdgCfg != "" {
+			return filepath.Join(xdgCfg, "syncthing")
+		}
+		dir, err := fs.ExpandTilde("~/.config/syncthing")
+		if err != nil {
+			panic(err)
+		}
+		return dir
+	}
+}
+
+// homeDir returns the user's home directory, or dies trying.
+func homeDir() string {
+	home, err := fs.ExpandTilde("~")
+	if err != nil {
+		panic(err)
+	}
+	return home
+}
+
+func GetTimestamped(key LocationEnum) string {
+	// We take the roundtrip via "${timestamp}" instead of passing the path
+	// directly through time.Format() to avoid issues when the path we are
+	// expanding contains numbers; otherwise for example
+	// /home/user2006/.../panic-20060102-150405.log would get both instances of
+	// 2006 replaced by 2015...
+	tpl := locations[key]
+	now := time.Now().Format("20060102-150405")
+	return strings.Replace(tpl, "${timestamp}", now, -1)
+}

+ 22 - 12
lib/util/utils.go

@@ -15,8 +15,12 @@ import (
 	"strings"
 )
 
+type defaultParser interface {
+	ParseDefault(string) error
+}
+
 // SetDefaults sets default values on a struct, based on the default annotation.
-func SetDefaults(data interface{}) error {
+func SetDefaults(data interface{}) {
 	s := reflect.ValueOf(data).Elem()
 	t := s.Type()
 
@@ -26,15 +30,22 @@ func SetDefaults(data interface{}) error {
 
 		v := tag.Get("default")
 		if len(v) > 0 {
-			if parser, ok := f.Interface().(interface {
-				ParseDefault(string) (interface{}, error)
-			}); ok {
-				val, err := parser.ParseDefault(v)
-				if err != nil {
-					panic(err)
+			if f.CanInterface() {
+				if parser, ok := f.Interface().(defaultParser); ok {
+					if err := parser.ParseDefault(v); err != nil {
+						panic(err)
+					}
+					continue
+				}
+			}
+
+			if f.CanAddr() && f.Addr().CanInterface() {
+				if parser, ok := f.Addr().Interface().(defaultParser); ok {
+					if err := parser.ParseDefault(v); err != nil {
+						panic(err)
+					}
+					continue
 				}
-				f.Set(reflect.ValueOf(val))
-				continue
 			}
 
 			switch f.Interface().(type) {
@@ -44,14 +55,14 @@ func SetDefaults(data interface{}) error {
 			case int:
 				i, err := strconv.ParseInt(v, 10, 64)
 				if err != nil {
-					return err
+					panic(err)
 				}
 				f.SetInt(i)
 
 			case float64:
 				i, err := strconv.ParseFloat(v, 64)
 				if err != nil {
-					return err
+					panic(err)
 				}
 				f.SetFloat(i)
 
@@ -68,7 +79,6 @@ func SetDefaults(data interface{}) error {
 			}
 		}
 	}
-	return nil
 }
 
 // CopyMatchingTag copies fields tagged tag:"value" from "from" struct onto "to" struct.

+ 4 - 5
lib/util/utils_test.go

@@ -12,8 +12,9 @@ type Defaulter struct {
 	Value string
 }
 
-func (Defaulter) ParseDefault(v string) (interface{}, error) {
-	return Defaulter{Value: v}, nil
+func (d *Defaulter) ParseDefault(v string) error {
+	*d = Defaulter{Value: v}
+	return nil
 }
 
 func TestSetDefaults(t *testing.T) {
@@ -37,9 +38,7 @@ func TestSetDefaults(t *testing.T) {
 		t.Errorf("defaulter failed")
 	}
 
-	if err := SetDefaults(x); err != nil {
-		t.Error(err)
-	}
+	SetDefaults(x)
 
 	if x.A != "string" {
 		t.Error("string failed")