diff --git a/cmd/stcli/LICENSE b/cmd/stcli/LICENSE new file mode 100644 index 000000000..a127d219a --- /dev/null +++ b/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. diff --git a/cmd/stcli/README.md b/cmd/stcli/README.md new file mode 100644 index 000000000..7caa2206a --- /dev/null +++ b/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/). diff --git a/cmd/stcli/client.go b/cmd/stcli/client.go new file mode 100644 index 000000000..022eebf71 --- /dev/null +++ b/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) +} diff --git a/cmd/stcli/cmd_devices.go b/cmd/stcli/cmd_devices.go new file mode 100644 index 000000000..a988486fc --- /dev/null +++ b/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") +} diff --git a/cmd/stcli/cmd_errors.go b/cmd/stcli/cmd_errors.go new file mode 100644 index 000000000..1d2c59e60 --- /dev/null +++ b/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) + } +} diff --git a/cmd/stcli/cmd_folders.go b/cmd/stcli/cmd_folders.go new file mode 100644 index 000000000..710cd657b --- /dev/null +++ b/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-") + } + 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-") + } + 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-") + } + 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") +} diff --git a/cmd/stcli/cmd_general.go b/cmd/stcli/cmd_general.go new file mode 100644 index 000000000..b81fba250 --- /dev/null +++ b/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) +} diff --git a/cmd/stcli/cmd_gui.go b/cmd/stcli/cmd_gui.go new file mode 100644 index 000000000..e4149be95 --- /dev/null +++ b/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) +} diff --git a/cmd/stcli/cmd_options.go b/cmd/stcli/cmd_options.go new file mode 100644 index 000000000..03ef4816d --- /dev/null +++ b/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) +} diff --git a/cmd/stcli/cmd_report.go b/cmd/stcli/cmd_report.go new file mode 100644 index 000000000..87d8e3828 --- /dev/null +++ b/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) +} diff --git a/cmd/stcli/labels.go b/cmd/stcli/labels.go new file mode 100644 index 000000000..137026865 --- /dev/null +++ b/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", +} diff --git a/cmd/stcli/main.go b/cmd/stcli/main.go new file mode 100644 index 000000000..c280e9473 --- /dev/null +++ b/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 = "audrius.butkevicius@gmail.com" + 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() +} diff --git a/cmd/stcli/utils.go b/cmd/stcli/utils.go new file mode 100644 index 000000000..436a882e0 --- /dev/null +++ b/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 :") + } + 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 :") + } + 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 +}