Просмотр исходного кода

cmd/stcli: Import from syncthing-cli repository

Audrius Butkevičius 9 лет назад
Родитель
Сommit
d8d3f05164

+ 19 - 0
cmd/stcli/LICENSE

@@ -0,0 +1,19 @@
+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.

+ 10 - 0
cmd/stcli/README.md

@@ -0,0 +1,10 @@
+syncthing-cli
+=============
+
+[![Latest Build](http://img.shields.io/jenkins/s/http/build.syncthing.net/syncthing-cli.svg?style=flat-square)](http://build.syncthing.net/job/syncthing-cli/lastBuild/)
+
+A CLI that talks to the Syncthing REST interface.
+
+`go get github.com/syncthing/syncthing-cli`
+
+Or download the [latest build](http://build.syncthing.net/job/syncthing-cli/lastSuccessfulBuild/artifact/).

+ 113 - 0
cmd/stcli/client.go

@@ -0,0 +1,113 @@
+package main
+
+import (
+	"bytes"
+	"crypto/tls"
+	"net/http"
+	"strings"
+
+	"github.com/AudriusButkevicius/cli"
+)
+
+type APIClient struct {
+	httpClient http.Client
+	endpoint   string
+	apikey     string
+	username   string
+	password   string
+	id         string
+	csrf       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
+	}
+	httpClient := http.Client{
+		Transport: &http.Transport{
+			TLSClientConfig: &tls.Config{
+				InsecureSkipVerify: c.GlobalBool("insecure"),
+			},
+		},
+	}
+	client := APIClient{
+		httpClient: httpClient,
+		endpoint:   endpoint,
+		apikey:     c.GlobalString("apikey"),
+		username:   c.GlobalString("username"),
+		password:   c.GlobalString("password"),
+	}
+
+	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:
+	}
+	instance = &client
+	return &client
+}
+
+func (client *APIClient) handleRequest(request *http.Request) *http.Response {
+	if client.apikey != "" {
+		request.Header.Set("X-API-Key", client.apikey)
+	}
+	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)
+	}
+
+	response, err := client.httpClient.Do(request)
+	die(err)
+
+	if response.StatusCode == 404 {
+		die("Invalid endpoint or API call")
+	} else if response.StatusCode == 401 {
+		die("Invalid username or password")
+	} else if response.StatusCode == 403 {
+		if client.apikey == "" {
+			die("Invalid CSRF token")
+		}
+		die("Invalid API key")
+	} else if response.StatusCode != 200 {
+		body := strings.TrimSpace(string(responseToBArray(response)))
+		if body != "" {
+			die(body)
+		}
+		die("Unknown HTTP status returned: " + response.Status)
+	}
+	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)
+}

+ 186 - 0
cmd/stcli/cmd_devices.go

@@ -0,0 +1,186 @@
+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")
+}

+ 65 - 0
cmd/stcli/cmd_errors.go

@@ -0,0 +1,65 @@
+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["time"].(string)[:19]
+		time = strings.Replace(time, "T", " ", 1)
+		err := item["error"].(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)
+	}
+}

+ 348 - 0
cmd/stcli/cmd_folders.go

@@ -0,0 +1,348 @@
+package main
+
+import (
+	"fmt"
+	"path/filepath"
+	"strings"
+
+	"github.com/AudriusButkevicius/cli"
+	"github.com/syncthing/syncthing/lib/config"
+)
+
+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)
+		}
+		fmt.Fprintln(writer, "ID:\t", folder.ID, "\t")
+		fmt.Fprintln(writer, "Path:\t", folder.RawPath, "\t(directory)")
+		fmt.Fprintln(writer, "Folder master:\t", folder.ReadOnly, "\t(master)")
+		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)
+			}
+		}
+		if folder.Invalid != "" {
+			fmt.Fprintln(writer, "Invalid:\t", folder.Invalid, "\t")
+		}
+		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],
+		RawPath: filepath.Clean(abs),
+	}
+	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.ReadOnly {
+			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.RawPath)
+		case "master":
+			fmt.Println(folder.ReadOnly)
+		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, master, 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].RawPath = val
+		case "master":
+			cfg.Folders[i].ReadOnly = parseBool(val)
+		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")
+}

+ 76 - 0
cmd/stcli/cmd_general.go

@@ -0,0 +1,76 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+
+	"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:     "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")
+	status := make(map[string]interface{})
+	json.Unmarshal(responseToBArray(response), &status)
+	if status["configInSync"] != true {
+		die("Config out of sync")
+	}
+	fmt.Println("Config in sync")
+}
+
+func generalVersion(c *cli.Context) {
+	response := httpGet(c, "system/version")
+	version := make(map[string]interface{})
+	json.Unmarshal(responseToBArray(response), &version)
+	prettyPrintJson(version)
+}

+ 125 - 0
cmd/stcli/cmd_gui.go

@@ -0,0 +1,125 @@
+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.UseTLS = parseBool(val)
+	case "address":
+		validAddress(val)
+		cfg.GUI.Address = 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)
+}

+ 171 - 0
cmd/stcli/cmd_options.go

@@ -0,0 +1,171 @@
+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.ListenAddress, " "), "\t(address)")
+	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.UPnPEnabled, "\t(upnp)")
+	fmt.Fprintln(writer, "UPnP Lease in minutes:\t", cfg.UPnPLeaseM, "\t(upnplease)")
+	fmt.Fprintln(writer, "UPnP Renewal period in minutes:\t", cfg.UPnPRenewalM, "\t(upnprenew)")
+	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.ListenAddress, "\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 "upnp":
+		fmt.Println(cfg.UPnPEnabled)
+	case "upnplease":
+		fmt.Println(cfg.UPnPLeaseM)
+	case "upnprenew":
+		fmt.Println(cfg.UPnPRenewalM)
+	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.ListenAddress = 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 "upnp":
+		config.Options.UPnPEnabled = parseBool(val)
+	case "upnplease":
+		config.Options.UPnPLeaseM = parseUint(val)
+	case "upnprenew":
+		config.Options.UPnPRenewalM = 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)
+}

+ 70 - 0
cmd/stcli/cmd_report.go

@@ -0,0 +1,70 @@
+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)
+}

+ 29 - 0
cmd/stcli/labels.go

@@ -0,0 +1,29 @@
+package main
+
+var jsonAttributeLabels map[string]string = 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",
+}

+ 61 - 0
cmd/stcli/main.go

@@ -0,0 +1,61 @@
+package main
+
+import (
+	"sort"
+
+	"github.com/AudriusButkevicius/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
+
+	app.Flags = []cli.Flag{
+		cli.StringFlag{
+			Name:   "endpoint, e",
+			Value:  "http://127.0.0.1:8384",
+			Usage:  "End point to connect to",
+			EnvVar: "STENDPOINT",
+		},
+		cli.StringFlag{
+			Name:   "apikey, k",
+			Value:  "",
+			Usage:  "API Key",
+			EnvVar: "STAPIKEY",
+		},
+		cli.StringFlag{
+			Name:   "username, u",
+			Value:  "",
+			Usage:  "Username",
+			EnvVar: "STUSERNAME",
+		},
+		cli.StringFlag{
+			Name:   "password, p",
+			Value:  "",
+			Usage:  "Password",
+			EnvVar: "STPASSWORD",
+		},
+		cli.BoolFlag{
+			Name:   "insecure, i",
+			Usage:  "Do not verify SSL certificate",
+			EnvVar: "STINSECURE",
+		},
+	}
+
+	sort.Sort(ByAlphabet(cliCommands))
+	app.Commands = cliCommands
+	app.RunAndExitOnError()
+}

+ 163 - 0
cmd/stcli/utils.go

@@ -0,0 +1,163 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"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"
+)
+
+func responseToBArray(response *http.Response) []byte {
+	defer response.Body.Close()
+	bytes, err := ioutil.ReadAll(response.Body)
+	if err != nil {
+		die(err)
+	}
+	return bytes
+}
+
+func die(vals ...interface{}) {
+	if len(vals) > 1 || vals[0] != nil {
+		os.Stderr.WriteString(fmt.Sprintln(vals...))
+		os.Exit(1)
+	}
+}
+
+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
+	}
+
+	json_keys := make([]string, 0, len(remap))
+	for key := range remap {
+		json_keys = append(json_keys, key)
+	}
+	sort.Strings(json_keys)
+	for _, k := range json_keys {
+		value := ""
+		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)
+		}
+		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 ""
+}
+
+func newTableWriter() *tabwriter.Writer {
+	writer := new(tabwriter.Writer)
+	writer.Init(os.Stdout, 0, 8, 0, '\t', 0)
+	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)
+	if err != nil {
+		die(input + " is not a valid value for a boolean")
+	}
+	return val
+}
+
+func parseInt(input string) int {
+	val, err := strconv.ParseInt(input, 0, 64)
+	if err != nil {
+		die(input + " is not a valid value for an integer")
+	}
+	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")
+	}
+	return int(val)
+}
+
+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")
+	}
+	return int(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 parseDeviceID(input string) protocol.DeviceID {
+	device, err := protocol.DeviceIDFromString(input)
+	if err != nil {
+		die(input + " is not a valid device id")
+	}
+	return device
+}