diff --git a/README.md b/README.md index 0e9d4661..eed6e7b5 100644 --- a/README.md +++ b/README.md @@ -20,4 +20,265 @@ The Arduino Connector is tied to a specific device registered within the Arduino Make sure you have an Arduino Account and you are able to log at: https://auth.arduino.cc/login -Please write us at auth@arduino.cc if you encounter any issue loggin in and you need support. \ No newline at end of file +Please write us at auth@arduino.cc if you encounter any issue loggin in and you need support. + + +## Compile +``` +go get github.com/arduino/arduino-connector +go build -ldflags "-X main.version=$VERSION" github.com/arduino/arduino-connector +``` + +## Autoupdate +``` +go get github.com/sanbornm/go-selfupdate +./bin/go-selfupdate arduino-connector $VERSION +# scp -r public/* user@server:/var/www/files/arduino-connector +``` + +### API + +To control the arduino-connector you must have: + +- the ID of the device in which the arduino-connector has been installed (eg `username:0002251d-4e19-4cc8-a4a9-1de215bfb502`) +- a working mqtt connection + +Send messages to the topic ending with /post, Receive the answer from the topic ending with /. Errors are sent to the same endpoint. + +You can distinguish between errors and non-errors because of the INFO: or ERROR: prefix of the message + +#### Status + +Retrieve the status of the connector +``` +{} +--> $aws/things/{{id}}/status/post + +INFO: { + "sketches": { + "4c1f3a9d-ed78-4ae4-94c8-bcfa2e94c692": { + "name":"sketch_oct31a", + "id":"4c1f3a9d-ed78-4ae4-94c8-bcfa2e94c692", + "pid":31343, + "status":"RUNNING", + "endpoints":null + } + } +} +<-- $aws/things/{{id}}/status +``` + +Upload a sketch on the connector +``` +{ + "token": "toUZDUNTcooVlyqAUwooBGAEtgr8iPzp017RhcST8gM.bDBgrxVzKKySBX-kBPMRqFRqlP3j_cwlgt9qPh_Ct2Y", + "url": "https://api-builder.arduino.cc/builder/v1/compile/sketch_oct31a.bin", + "name": "sketch_oct31a", + "id": "4c1f3a9d-ed78-4ae4-94c8-bcfa2e94c692" +} +--> $aws/things/{{id}}/upload/post + +INFO: Sketch started with PID 570 +<-- $aws/things/{{id}}/upload +``` + +Update the arduino-connector (doesn't return anything) +``` +{ + "url": "https://downloads.arduino.cc/tools/arduino-connector", +} +--> $aws/things/{{id}}/update/post + +<-- $aws/things/{{id}}/sketch +``` + +Update the arduino-connector +``` +{ + "url": "https://downloads.arduino.cc/tools/arduino-connector", +} +--> $aws/things/{{id}}/update/post + +<-- $aws/things/{{id}}/sketch +``` + +Retrieve the stats of the machine (memory, disk, networks) +``` +{} +--> $aws/things/{{id}}/stats/post + +INFO: { + "memory":{ + "FreeMem":1317964, + "TotalMem":15859984, + "AvailableMem":8184204, + "Buffers":757412, + "Cached":6569888, + "FreeSwapMem":0, + "TotalSwapMem":0 + }, + "disk":[ + { + "Device":"sysfs", + "Type":"sysfs", + "MountPoint":"/sys", + "FreeSpace":0, + "AvailableSpace":0, + "DiskSize":0 + }, + ], + "network":{ + "Devices":[ + { + "AccessPoints":[ + { + "Flags":1, + "Frequency":2437, + "HWAddress":"58:6D:8F:8F:FD:F3", + "MaxBitrate":54000, + "Mode":"Nm80211ModeInfra", + "RSNFlags":392, + "SSID":"ssid-2g", + "Strength":80, + "WPAFlags":0 + } + ], + "AvailableConnections":[ + { + "802-11-wireless":{ + "mac-address":"QOIwy+Ef", + "mac-address-blacklist":[], + "mode":"infrastructure", + "security":"802-11-wireless-security", + "seen-bssids":[ + "58:6D:8F:8F:FD:F3" + ], + "ssid":"QkNNSWxhYnMtMmc=" + }, + "802-11-wireless-security":{ + "auth-alg":"open", + "group":[], + "key-mgmt":"wpa-psk", + "pairwise":[], + "proto":[] + }, + "connection":{ + "id":"ssid-2g", + "permissions":[], + "secondaries":[], + "timestamp":1513953989, + "type":"802-11-wireless", + "uuid":"b5dd1024-db02-4e0f-ad3b-c41c375f750a" + }, + "ipv4":{ + "address-data":[], + "addresses":[], + "dns":[], + "dns-search":[], + "method":"auto", + "route-data":[], + "routes":[] + }, + "ipv6":{ + "address-data":[], + "addresses":[], + "dns":[], + "dns-search":[], + "method":"auto", + "route-data":[], + "routes":[] + } + } + ], + "DeviceType":"NmDeviceTypeWifi", + "IP4Config":{ + "Addresses":[ + { + "Address":"10.130.22.132", + "Prefix":24, + "Gateway":"10.130.22.1" + } + ], + "Domains":[], + "Nameservers":[ + "10.130.22.1" + ], + "Routes":[] + }, + "Interface":"wlp4s0", + "State":"NmDeviceStateActivated" + } + ], + "Status":"NmStateConnectedGlobal" + } +} +<-- $aws/things/{{id}}/stats +``` + +Configure the wifi (doesn't return anything) +``` +{ + "ssid": "ssid-2g", + "password": "passwordssid" +} +--> $aws/things/{{id}}/stats/post + +<-- $aws/things/{{id}}/stats +``` + +#### Package Management + +Retrieve a list of the upgradable packages + +``` +{} +--> $aws/things/{{id}}/apt/list/post + +INFO: {"packages":[ + {"Name":"firefox","Status":"upgradable","Architecture":"amd64","Version":"57.0.3+build1-0ubuntu0.17.10.1"}, + {"Name":"firefox-locale-en","Status":"upgradable","Architecture":"amd64","Version":"57.0.3+build1-0ubuntu0.17.10.1"} + ], + "page":0,"pages":1} +<-- $aws/things/{{id}}/apt/list +``` + +Search for installed/installable/upgradable packages + +``` +{"search": "linux"} +--> $aws/things/{{id}}/apt/list/post + +INFO: {"packages":[ + {"Name":"binutils-x86-64-linux-gnu","Status":"installed","Architecture":"amd64","Version":"2.29.1-4ubuntu1"}, + {"Name":"firmware-linux","Status":"not-installed","Architecture":"","Version":""}, + ... + ],"page":0,"pages":6} +<-- $aws/things/{{id}}/apt/list +``` + +Navigate pages + +``` +{"search": "linux", "page": 2} +--> $aws/things/{{id}}/apt/list/post + +INFO: {"packages":[ + {"Name":"linux-image-4.10.0-30-generic","Status":"config-files","Architecture":"amd64","Version":"4.10.0-30.34"}, + {"Name":"linux-image-4.13.0-21-generic","Status":"installed","Architecture":"amd64","Version":"4.13.0-21.24"}, + ... + ],"page":2,"pages":6} +<-- $aws/things/{{id}}/apt/list +``` + +TODO: +``` + mqttClient.Subscribe("$aws/things/"+id+"/apt/install/post", 1, status.AptInstallEvent) + mqttClient.Subscribe("$aws/things/"+id+"/apt/update/post", 1, status.AptUpdateEvent) + mqttClient.Subscribe("$aws/things/"+id+"/apt/upgrade/post", 1, status.AptUpgradeEvent) + mqttClient.Subscribe("$aws/things/"+id+"/apt/remove/post", 1, status.AptRemoveEvent) + + mqttClient.Subscribe("$aws/things/"+id+"/apt/repos/list/post", 1, status.AptRepositoryListEvent) + mqttClient.Subscribe("$aws/things/"+id+"/apt/repos/add/post", 1, status.AptRepositoryAddEvent) + mqttClient.Subscribe("$aws/things/"+id+"/apt/repos/remove/post", 1, status.AptRepositoryRemoveEvent) + mqttClient.Subscribe("$aws/things/"+id+"/apt/repos/edit/post", 1, status.AptRepositoryEditEvent) +``` diff --git a/handler_apt_packages.go b/handler_apt_packages.go new file mode 100644 index 00000000..39168a24 --- /dev/null +++ b/handler_apt_packages.go @@ -0,0 +1,179 @@ +// +// This file is part of arduino-connector +// +// Copyright (C) 2017 Arduino AG (http://www.arduino.cc/) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package main + +import ( + "encoding/json" + "fmt" + + apt "github.com/arduino/go-apt-client" + mqtt "github.com/eclipse/paho.mqtt.golang" +) + +// AptListEvent sends a list of available packages and their status +func (s *Status) AptListEvent(client mqtt.Client, msg mqtt.Message) { + const itemsPerPage = 30 + + var params struct { + Search string `json:"search"` + Page int `json:"page"` + } + err := json.Unmarshal(msg.Payload(), ¶ms) + if err != nil { + s.Error("/apt/list", fmt.Errorf("Unmarshal '%s': %s", msg.Payload(), err)) + return + } + + // Get packages from system + var all []*apt.Package + if params.Search == "" { + all, err = apt.ListUpgradable() + } else { + all, err = apt.Search(params.Search) + } + + if err != nil { + s.Error("/apt/list", fmt.Errorf("Retrieving packages: %s", err)) + return + } + + // Paginate data + pages := (len(all)-1)/itemsPerPage + 1 + first := params.Page * itemsPerPage + last := first + itemsPerPage + if first >= len(all) { + all = all[0:0] + } else if last >= len(all) { + all = all[first:] + } else { + all = all[first:last] + } + + // On upgradable packages set the status to "upgradable" + allUpdates, err := apt.ListUpgradable() + if err != nil { + s.Error("/apt/list", fmt.Errorf("Retrieving packages: %s", err)) + return + } + + for _, update := range allUpdates { + for i := range all { + if update.Name == all[i].Name { + all[i].Status = "upgradable" + break + } + } + } + + // Prepare response payload + type response struct { + Packages []*apt.Package `json:"packages"` + Page int `json:"page"` + Pages int `json:"pages"` + } + info := response{ + Packages: all, + Page: params.Page, + Pages: pages, + } + + // Send result + data, err := json.Marshal(info) + if err != nil { + s.Error("/apt/list", fmt.Errorf("Json marshal result: %s", err)) + return + } + + //var out bytes.Buffer + //json.Indent(&out, data, "", " ") + //fmt.Println(string(out.Bytes())) + + s.Info("/apt/list", string(data)+"\n") +} + +// AptInstallEvent installs new packages +func (s *Status) AptInstallEvent(client mqtt.Client, msg mqtt.Message) { + var params struct { + Packages []string `json:"packages"` + } + err := json.Unmarshal(msg.Payload(), ¶ms) + if err != nil { + s.Error("/apt/install", fmt.Errorf("Unmarshal '%s': %s", msg.Payload(), err)) + return + } + + toInstall := []*apt.Package{} + for _, p := range params.Packages { + toInstall = append(toInstall, &apt.Package{Name: p}) + } + out, err := apt.Install(toInstall...) + s.InfoCommandOutput("/apt/install", out, err) +} + +// AptUpdateEvent checks repositories for updates on installed packages +func (s *Status) AptUpdateEvent(client mqtt.Client, msg mqtt.Message) { + out, err := apt.CheckForUpdates() + s.InfoCommandOutput("/apt/update", out, err) +} + +// AptUpgradeEvent installs upgrade for specified packages (or for all +// upgradable packages if none are specified) +func (s *Status) AptUpgradeEvent(client mqtt.Client, msg mqtt.Message) { + var params struct { + Packages []string `json:"packages"` + } + err := json.Unmarshal(msg.Payload(), ¶ms) + if err != nil { + s.Error("/apt/upgrade", fmt.Errorf("Unmarshal '%s': %s", msg.Payload(), err)) + return + } + + toUpgrade := []*apt.Package{} + for _, p := range params.Packages { + toUpgrade = append(toUpgrade, &apt.Package{Name: p}) + } + + if len(toUpgrade) == 0 { + out, err := apt.UpgradeAll() + s.InfoCommandOutput("/apt/upgrade", out, err) + } else { + out, err := apt.Upgrade(toUpgrade...) + s.InfoCommandOutput("/apt/upgrade", out, err) + } +} + +// AptRemoveEvent deinstall the specified packages +func (s *Status) AptRemoveEvent(client mqtt.Client, msg mqtt.Message) { + var params struct { + Packages []string `json:"packages"` + } + err := json.Unmarshal(msg.Payload(), ¶ms) + if err != nil { + s.Error("/apt/remove", fmt.Errorf("Unmarshal '%s': %s", msg.Payload(), err)) + return + } + + toRemove := []*apt.Package{} + for _, p := range params.Packages { + toRemove = append(toRemove, &apt.Package{Name: p}) + } + + out, err := apt.Remove(toRemove...) + s.InfoCommandOutput("/apt/remove", out, err) +} diff --git a/handler_apt_repositories.go b/handler_apt_repositories.go new file mode 100644 index 00000000..32744457 --- /dev/null +++ b/handler_apt_repositories.go @@ -0,0 +1,111 @@ +// +// This file is part of arduino-connector +// +// Copyright (C) 2017 Arduino AG (http://www.arduino.cc/) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package main + +import ( + "encoding/json" + "fmt" + + apt "github.com/arduino/go-apt-client" + mqtt "github.com/eclipse/paho.mqtt.golang" +) + +// AptRepositoryListEvent sends a list of available repositories +func (s *Status) AptRepositoryListEvent(client mqtt.Client, msg mqtt.Message) { + // Get packages from system + all, err := apt.ParseAPTConfigFolder("/etc/apt") + if err != nil { + s.Error("/apt/repos/list", fmt.Errorf("Retrieving repositories: %s", err)) + return + } + + // Send result + data, err := json.Marshal(all) + if err != nil { + s.Error("/apt/repos/list", fmt.Errorf("Json marshal result: %s", err)) + return + } + + //var out bytes.Buffer + //json.Indent(&out, data, "", " ") + //fmt.Println(string(out.Bytes())) + + s.Info("/apt/repos/list", string(data)+"\n") +} + +// AptRepositoryAddEvent adds a repository to the apt configuration +func (s *Status) AptRepositoryAddEvent(client mqtt.Client, msg mqtt.Message) { + var params struct { + Repository *apt.Repository `json:"repository"` + } + err := json.Unmarshal(msg.Payload(), ¶ms) + if err != nil { + s.Error("/apt/repos/add", fmt.Errorf("Unmarshal '%s': %s", msg.Payload(), err)) + return + } + + err = apt.AddRepository(params.Repository, "/etc/apt") + if err != nil { + s.Error("/apt/repos/add", fmt.Errorf("Adding repository '%s': %s", msg.Payload(), err)) + return + } + + s.InfoCommandOutput("/apt/repos/add", []byte("OK"), nil) +} + +// AptRepositoryRemoveEvent removes a repository from the apt configuration +func (s *Status) AptRepositoryRemoveEvent(client mqtt.Client, msg mqtt.Message) { + var params struct { + Repository *apt.Repository `json:"repository"` + } + err := json.Unmarshal(msg.Payload(), ¶ms) + if err != nil { + s.Error("/apt/repos/remove", fmt.Errorf("Unmarshal '%s': %s", msg.Payload(), err)) + return + } + + err = apt.RemoveRepository(params.Repository, "/etc/apt") + if err != nil { + s.Error("/apt/repos/remove", fmt.Errorf("Removing repository '%s': %s", msg.Payload(), err)) + return + } + + s.InfoCommandOutput("/apt/repos/remove", []byte("OK"), nil) +} + +// AptRepositoryEditEvent modifies a repository definition in the apt configuration +func (s *Status) AptRepositoryEditEvent(client mqtt.Client, msg mqtt.Message) { + var params struct { + OldRepository *apt.Repository `json:"old_repository"` + NewRepository *apt.Repository `json:"new_repository"` + } + err := json.Unmarshal(msg.Payload(), ¶ms) + if err != nil { + s.Error("/apt/repos/edit", fmt.Errorf("Unmarshal '%s': %s", msg.Payload(), err)) + return + } + + err = apt.EditRepository(params.OldRepository, params.NewRepository, "/etc/apt") + if err != nil { + s.Error("/apt/repos/edit", fmt.Errorf("Changing repository '%s': %s", msg.Payload(), err)) + return + } + + s.InfoCommandOutput("/apt/repos/edit", []byte("OK"), nil) +} diff --git a/handler_update.go b/handler_update.go new file mode 100644 index 00000000..269be64b --- /dev/null +++ b/handler_update.go @@ -0,0 +1,73 @@ +package main + +import ( + "fmt" + "log" + "os/exec" + "strings" + + "github.com/arduino/arduino-connector/updater" + "github.com/kardianos/osext" +) + +func (s *Status) Update(config Config) { + + path, err := osext.Executable() + if err != nil { + //c.JSON(500, gin.H{"error": err.Error()}) + return + } + + var up = &updater.Updater{ + CurrentVersion: version, + APIURL: config.updateUrl, + BinURL: config.updateUrl, + DiffURL: "", + Dir: "update/", + CmdName: config.appName, + } + + err = up.BackgroundRun() + + if err != nil { + return + } + + //c.JSON(200, gin.H{"success": "Please wait a moment while the agent reboots itself"}) + go restart(path) +} + +func restart(path string) { + log.Println("called restart", path) + // relaunch ourself and exit + // the relaunch works because we pass a cmdline in + // that has serial-port-json-server only initialize 5 seconds later + // which gives us time to exit and unbind from serial ports and TCP/IP + // sockets like :8989 + log.Println("Starting new spjs process") + + // figure out current path of executable so we know how to restart + // this process using osext + exePath, err3 := osext.Executable() + if err3 != nil { + log.Printf("Error getting exe path using osext lib. err: %v\n", err3) + } + + if path == "" { + log.Printf("exePath using osext: %v\n", exePath) + } else { + exePath = path + } + + exePath = strings.Trim(exePath, "\n") + + cmd := exec.Command(exePath) + + fmt.Println(cmd) + + err := cmd.Start() + if err != nil { + log.Printf("Got err restarting spjs: %v\n", err) + } + log.Fatal("Exited current spjs for restart") +} diff --git a/handlers.go b/handlers.go index 5ca95259..675f0e1e 100644 --- a/handlers.go +++ b/handlers.go @@ -42,11 +42,9 @@ import ( "golang.org/x/crypto/ssh/terminal" ) -// StatusCB replies with the current status of the arduino-connector -func StatusCB(status *Status) mqtt.MessageHandler { - return func(client mqtt.Client, msg mqtt.Message) { - status.Publish() - } +// StatusEvent replies with the current status of the arduino-connector +func (status *Status) StatusEvent(client mqtt.Client, msg mqtt.Message) { + status.Publish() } type UpdatePayload struct { @@ -55,50 +53,48 @@ type UpdatePayload struct { Token string `json:"token"` } -// UpdateCB handles the connector autoupdate +// UpdateEvent handles the connector autoupdate // Any URL must be signed with Arduino private key -func UpdateCB(status *Status) mqtt.MessageHandler { - return func(client mqtt.Client, msg mqtt.Message) { - var info UpdatePayload - err := json.Unmarshal(msg.Payload(), &info) - if err != nil { - status.Error("/update", errors.Wrapf(err, "unmarshal %s", msg.Payload())) - return - } - executablePath, _ := os.Executable() - name := filepath.Join(os.TempDir(), filepath.Base(executablePath)) - err = downloadFile(name, info.URL, info.Token) - err = downloadFile(name+".sig", info.URL+".sig", info.Token) - if err != nil { - status.Error("/update", errors.Wrap(err, "no signature file "+info.URL+".sig")) - return - } - // check the signature - err = checkGPGSig(name, name+".sig") - if err != nil { - status.Error("/update", errors.Wrap(err, "wrong signature "+info.URL+".sig")) - return - } - // chmod it - err = os.Chmod(name, 0744) - if err != nil { - status.Error("/update", errors.Wrapf(err, "chmod 744 %s", name)) - return - } - os.Rename(executablePath, executablePath+".old") - // copy it over existing binary - err = copyFileAndRemoveOriginal(name, executablePath) - if err != nil { - // rollback - os.Rename(executablePath+".old", executablePath) - status.Error("/update", errors.Wrap(err, "error copying itself from "+name+" to "+executablePath)) - return - } - os.Chmod(executablePath, 0744) - os.Remove(executablePath + ".old") - // leap of faith: kill itself, systemd should respawn the process - os.Exit(0) +func (status *Status) UpdateEvent(client mqtt.Client, msg mqtt.Message) { + var info UpdatePayload + err := json.Unmarshal(msg.Payload(), &info) + if err != nil { + status.Error("/update", errors.Wrapf(err, "unmarshal %s", msg.Payload())) + return + } + executablePath, _ := os.Executable() + name := filepath.Join(os.TempDir(), filepath.Base(executablePath)) + err = downloadFile(name, info.URL, info.Token) + err = downloadFile(name+".sig", info.URL+".sig", info.Token) + if err != nil { + status.Error("/update", errors.Wrap(err, "no signature file "+info.URL+".sig")) + return + } + // check the signature + err = checkGPGSig(name, name+".sig") + if err != nil { + status.Error("/update", errors.Wrap(err, "wrong signature "+info.URL+".sig")) + return + } + // chmod it + err = os.Chmod(name, 0744) + if err != nil { + status.Error("/update", errors.Wrapf(err, "chmod 744 %s", name)) + return + } + os.Rename(executablePath, executablePath+".old") + // copy it over existing binary + err = copyFileAndRemoveOriginal(name, executablePath) + if err != nil { + // rollback + os.Rename(executablePath+".old", executablePath) + status.Error("/update", errors.Wrap(err, "error copying itself from "+name+" to "+executablePath)) + return } + os.Chmod(executablePath, 0744) + os.Remove(executablePath + ".old") + // leap of faith: kill itself, systemd should respawn the process + os.Exit(0) } // UploadPayload contains the name and url of the sketch to upload on the device @@ -109,91 +105,89 @@ type UploadPayload struct { Token string `json:"token"` } -// UploadCB receives the url and name of the sketch binary, then it +// UploadEvent receives the url and name of the sketch binary, then it // - downloads the binary, // - chmods +x it // - executes redirecting stdout and sterr to a proper logger -func UploadCB(status *Status) mqtt.MessageHandler { - return func(client mqtt.Client, msg mqtt.Message) { - // unmarshal - var info UploadPayload - var sketch SketchStatus - err := json.Unmarshal(msg.Payload(), &info) - if err != nil { - status.Error("/upload", errors.Wrapf(err, "unmarshal %s", msg.Payload())) - return - } - - if info.ID == "" { - info.ID = info.Name - } - - // Stop and delete if existing - if sketch, ok := status.Sketches[info.ID]; ok { - err = applyAction(sketch, "STOP", status) - if err != nil { - status.Error("/upload", errors.Wrapf(err, "stop pid %d", sketch.PID)) - return - } +func (status *Status) UploadEvent(client mqtt.Client, msg mqtt.Message) { + // unmarshal + var info UploadPayload + var sketch SketchStatus + err := json.Unmarshal(msg.Payload(), &info) + if err != nil { + status.Error("/upload", errors.Wrapf(err, "unmarshal %s", msg.Payload())) + return + } - sketchFolder, err := GetSketchFolder() - err = os.Remove(filepath.Join(sketchFolder, sketch.Name)) - if err != nil { - status.Error("/upload", errors.Wrapf(err, "remove %d", sketch.Name)) - return - } - } + if info.ID == "" { + info.ID = info.Name + } - folder, err := GetSketchFolder() + // Stop and delete if existing + if sketch, ok := status.Sketches[info.ID]; ok { + err = applyAction(sketch, "STOP", status) if err != nil { - status.Error("/upload", errors.Wrapf(err, "create sketch folder %s", info.ID)) + status.Error("/upload", errors.Wrapf(err, "stop pid %d", sketch.PID)) return } - // download the binary - name := filepath.Join(folder, info.Name) - err = downloadFile(name, info.URL, info.Token) + sketchFolder, err := GetSketchFolder() + err = os.Remove(filepath.Join(sketchFolder, sketch.Name)) if err != nil { - status.Error("/upload", errors.Wrapf(err, "download file %s", info.URL)) + status.Error("/upload", errors.Wrapf(err, "remove %d", sketch.Name)) return } + } - // chmod it - err = os.Chmod(name, 0744) - if err != nil { - status.Error("/upload", errors.Wrapf(err, "chmod 744 %s", name)) - return - } + folder, err := GetSketchFolder() + if err != nil { + status.Error("/upload", errors.Wrapf(err, "create sketch folder %s", info.ID)) + return + } - sketch.ID = info.ID - sketch.Name = info.Name - // save ID-Name to a sort of DB - InsertSketchInDB(sketch.Name, sketch.ID) + // download the binary + name := filepath.Join(folder, info.Name) + err = downloadFile(name, info.URL, info.Token) + if err != nil { + status.Error("/upload", errors.Wrapf(err, "download file %s", info.URL)) + return + } - // spawn process - pid, _, _, err := spawnProcess(name, &sketch, status) - if err != nil { - status.Error("/upload", errors.Wrapf(err, "spawn %s", name)) - return - } + // chmod it + err = os.Chmod(name, 0744) + if err != nil { + status.Error("/upload", errors.Wrapf(err, "chmod 744 %s", name)) + return + } - status.Info("/upload", "Sketch started with PID "+strconv.Itoa(pid)) + sketch.ID = info.ID + sketch.Name = info.Name + // save ID-Name to a sort of DB + InsertSketchInDB(sketch.Name, sketch.ID) - sketch.PID = pid - sketch.Status = "RUNNING" + // spawn process + pid, _, _, err := spawnProcess(name, &sketch, status) + if err != nil { + status.Error("/upload", errors.Wrapf(err, "spawn %s", name)) + return + } - status.Set(info.ID, &sketch) - status.Publish() + status.Info("/upload", "Sketch started with PID "+strconv.Itoa(pid)) - // go func(stdout io.ReadCloser) { - // in := bufio.NewScanner(stdout) - // for { - // for in.Scan() { - // fmt.Printf(in.Text()) // write each line to your log, or anything you need - // } - // } - // }(stdout) - } + sketch.PID = pid + sketch.Status = "RUNNING" + + status.Set(info.ID, &sketch) + status.Publish() + + // go func(stdout io.ReadCloser) { + // in := bufio.NewScanner(stdout) + // for { + // for in.Scan() { + // fmt.Printf(in.Text()) // write each line to your log, or anything you need + // } + // } + // }(stdout) } func GetSketchFolder() (string, error) { @@ -272,36 +266,34 @@ type SketchActionPayload struct { Action string } -// SketchCB listens to commands to start and stop sketches -func SketchCB(status *Status) mqtt.MessageHandler { - return func(client mqtt.Client, msg mqtt.Message) { - // unmarshal - var info SketchActionPayload - err := json.Unmarshal(msg.Payload(), &info) - if err != nil { - status.Error("/sketch", errors.Wrapf(err, "unmarshal %s", msg.Payload())) - return - } - - if info.ID == "" { - info.ID = info.Name - } +// SketchEvent listens to commands to start and stop sketches +func (status *Status) SketchEvent(client mqtt.Client, msg mqtt.Message) { + // unmarshal + var info SketchActionPayload + err := json.Unmarshal(msg.Payload(), &info) + if err != nil { + status.Error("/sketch", errors.Wrapf(err, "unmarshal %s", msg.Payload())) + return + } - if sketch, ok := status.Sketches[info.ID]; ok { - err := applyAction(sketch, info.Action, status) - if err != nil { - status.Error("/sketch", errors.Wrapf(err, "applying %s to %s", info.Action, info.Name)) - return - } - status.Info("/sketch", "successfully performed "+info.Action+" on sketch "+info.ID) + if info.ID == "" { + info.ID = info.Name + } - status.Set(info.ID, sketch) - status.Publish() + if sketch, ok := status.Sketches[info.ID]; ok { + err := applyAction(sketch, info.Action, status) + if err != nil { + status.Error("/sketch", errors.Wrapf(err, "applying %s to %s", info.Action, info.Name)) return } + status.Info("/sketch", "successfully performed "+info.Action+" on sketch "+info.ID) - status.Error("/sketch", errors.New("sketch "+info.ID+" not found")) + status.Set(info.ID, sketch) + status.Publish() + return } + + status.Error("/sketch", errors.New("sketch "+info.ID+" not found")) } func NatsCloudCB(s *Status) nats.MsgHandler { diff --git a/handlers_stats.go b/handlers_stats.go new file mode 100644 index 00000000..ebb291ab --- /dev/null +++ b/handlers_stats.go @@ -0,0 +1,92 @@ +// +// This file is part of arduino-connector +// +// Copyright (C) 2017 Arduino AG (http://www.arduino.cc/) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package main + +import ( + "encoding/json" + "fmt" + + "github.com/arduino/go-system-stats/disk" + "github.com/arduino/go-system-stats/mem" + "github.com/arduino/go-system-stats/network" + mqtt "github.com/eclipse/paho.mqtt.golang" + "github.com/pkg/errors" +) + +// WiFiEvent tries to connect to the specified wifi network +func (s *Status) WiFiEvent(client mqtt.Client, msg mqtt.Message) { + // try registering a new wifi network + var info struct { + SSID string `json:"ssid"` + Password string `json:"password"` + } + err := json.Unmarshal(msg.Payload(), &info) + if err != nil { + s.Error("/wifi", errors.Wrapf(err, "unmarshal %s", msg.Payload())) + return + } + net.AddWirelessConnection(info.SSID, info.Password) +} + +// StatsEvent sends statistics about resource used in the system (RAM, Disk, Network, etc...) +func (s *Status) StatsEvent(client mqtt.Client, msg mqtt.Message) { + // Gather all system data metrics + memStats, err := mem.GetStats() + if err != nil { + s.Error("/stats", fmt.Errorf("Retrieving memory stats: %s", err)) + return + } + + diskStats, err := disk.GetStats() + if err != nil { + s.Error("/stats", fmt.Errorf("Retrieving disk stats: %s", err)) + return + } + + netStats, err := net.GetNetworkStats() + if err != nil { + s.Error("/stats", fmt.Errorf("Retrieving network stats: %s", err)) + return + } + + type StatsPayload struct { + Memory *mem.Stats `json:"memory"` + Disk []*disk.FSStats `json:"disk"` + Network *net.Stats `json:"network"` + } + + info := StatsPayload{ + Memory: memStats, + Disk: diskStats, + Network: netStats, + } + + // Send result + data, err := json.Marshal(info) + if err != nil { + s.Error("/stats", fmt.Errorf("Json marsahl result: %s", err)) + return + } + + //var out bytes.Buffer + //json.Indent(&out, data, "", " ") + //fmt.Println(string(out.Bytes())) + + s.Info("/stats", string(data)+"\n") +} diff --git a/install.go b/install.go index 6f6c1cd3..02dfeeb6 100644 --- a/install.go +++ b/install.go @@ -48,8 +48,7 @@ import ( ) const ( - rsaBits = 2048 - devicesAPI = "https://api2.arduino.cc/devices/v1" + rsaBits = 2048 ) // Install installs the program as a service @@ -65,7 +64,7 @@ func register(config Config, token string) { // Request token var err error if token == "" { - token, err = askCredentials() + token, err = askCredentials(config.AuthURL) check(err, "AskCredentials") } @@ -88,7 +87,7 @@ func register(config Config, token string) { // Request a certificate fmt.Println("Request certificate") - pem, err := requestCert(config.ID, token, csr) + pem, err := requestCert(config.APIURL, config.ID, token, csr) check(err, "requestCert") err = ioutil.WriteFile("certificate.pem", []byte(pem), 0600) @@ -96,7 +95,7 @@ func register(config Config, token string) { // Request URL fmt.Println("Request mqtt url") - config.URL, err = requestURL(token) + config.URL, err = requestURL(config.APIURL, token) check(err, "requestURL") // Write the configuration @@ -118,7 +117,7 @@ func register(config Config, token string) { fmt.Println("Setup completed") } -func askCredentials() (token string, err error) { +func askCredentials(authURL string) (token string, err error) { var user, pass string fmt.Println("Insert your arduino username") fmt.Scanln(&user) @@ -131,6 +130,8 @@ func askCredentials() (token string, err error) { pass = string(bytePassword) authClient := auth.New() + authClient.CodeURL = authURL + "/oauth2/auth" + authClient.TokenURL = authURL + "/oauth2/token" authClient.ClientID = "connector" authClient.Scopes = "iot:devices" @@ -217,7 +218,7 @@ func generateCsr(priv interface{}) ([]byte, error) { return csr, nil } -func requestCert(id, token string, csr []byte) (string, error) { +func requestCert(apiURL, id, token string, csr []byte) (string, error) { client := http.Client{ Timeout: 5 * time.Second, } @@ -227,7 +228,7 @@ func requestCert(id, token string, csr []byte) (string, error) { payload := `{"csr":"` + pemData.String() + `"}` payload = strings.Replace(payload, "\n", "\\n", -1) - req, err := http.NewRequest("POST", devicesAPI+"/"+id, strings.NewReader(payload)) + req, err := http.NewRequest("POST", apiURL+"/devices/v1/"+id, strings.NewReader(payload)) if err != nil { return "", err } @@ -240,7 +241,7 @@ func requestCert(id, token string, csr []byte) (string, error) { } if res.StatusCode != 200 { - return "", errors.New("POST " + "/" + id + ": expected 200 OK, got " + res.Status) + return "", errors.New("POST " + apiURL + "/devices/v1/" + id + ": expected 200 OK, got " + res.Status) } body, err := ioutil.ReadAll(res.Body) @@ -257,12 +258,12 @@ func requestCert(id, token string, csr []byte) (string, error) { return data.Certificate, nil } -func requestURL(token string) (string, error) { +func requestURL(apiURL, token string) (string, error) { client := http.Client{ Timeout: 5 * time.Second, } - req, err := http.NewRequest("POST", devicesAPI+"/connect", nil) + req, err := http.NewRequest("POST", apiURL+"/devices/v1/connect", nil) if err != nil { return "", err } @@ -285,7 +286,7 @@ func requestURL(token string) (string, error) { err = json.Unmarshal(body, &data) if err != nil { - return "", err + return "", errors.Wrap(err, "unmarshal "+string(body)) } return data.URL, nil diff --git a/main.go b/main.go index fc36c907..d3f054f9 100644 --- a/main.go +++ b/main.go @@ -43,6 +43,10 @@ const ( configFile = "./arduino-connector.cfg" ) +var ( + version = "x.x.x" +) + // Config holds the configuration needed by the application type Config struct { ID string @@ -50,6 +54,10 @@ type Config struct { HTTPProxy string HTTPSProxy string ALLProxy string + updateUrl string + appName string + AuthURL string + APIURL string } func (c Config) String() string { @@ -58,10 +66,15 @@ func (c Config) String() string { out += "http_proxy=" + c.HTTPProxy + "\r\n" out += "https_proxy=" + c.HTTPSProxy + "\r\n" out += "all_proxy=" + c.ALLProxy + "\r\n" + out += "authurl=" + c.AuthURL + "\r\n" + out += "apiurl=" + c.APIURL + "\r\n" return out } func main() { + fmt.Println("Version: " + version) + + // Read config config := Config{} var doLogin = flag.Bool("login", false, "Do the login and prints out a temporary token") @@ -69,12 +82,17 @@ func main() { var doRegister = flag.Bool("register", false, "Registers on the cloud") var listenFile = flag.String("listen", "", "Tail given file and report percentage") var token = flag.String("token", "", "an authentication token") + flag.StringVar(&config.updateUrl, "updateUrl", "http://downloads.arduino.cc/tools/feed/", "") + flag.StringVar(&config.appName, "appName", "arduino-connector", "") + flag.String(flag.DefaultConfigFlagname, "", "path to config file") flag.StringVar(&config.ID, "id", "", "id of the thing in aws iot") flag.StringVar(&config.URL, "url", "", "url of the thing in aws iot") flag.StringVar(&config.HTTPProxy, "http_proxy", "", "URL of HTTP proxy to use") flag.StringVar(&config.HTTPSProxy, "https_proxy", "", "URL of HTTPS proxy to use") flag.StringVar(&config.ALLProxy, "all_proxy", "", "URL of SOCKS proxy to use") + flag.StringVar(&config.AuthURL, "authurl", "https://hydra.arduino.cc", "Url of authentication server") + flag.StringVar(&config.APIURL, "apiurl", "https://api2.arduino.cc", "Url of api server") flag.Parse() @@ -83,7 +101,7 @@ func main() { check(err, "CreateService") if *doLogin { - token, err := askCredentials() + token, err := askCredentials(config.AuthURL) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) @@ -141,6 +159,7 @@ func (p program) run() { // Create global status status := NewStatus(p.Config.ID, nil) + status.Update(p.Config) // Setup MQTT connection mqttClient, err := setupMQTTConnection("certificate.pem", "certificate.key", p.Config.ID, p.Config.URL, status) @@ -207,10 +226,23 @@ func subscribeTopics(mqttClient mqtt.Client, id string, status *Status) { if status == nil { return } - mqttClient.Subscribe("$aws/things/"+id+"/status/post", 1, StatusCB(status)) - mqttClient.Subscribe("$aws/things/"+id+"/upload/post", 1, UploadCB(status)) - mqttClient.Subscribe("$aws/things/"+id+"/sketch/post", 1, SketchCB(status)) - mqttClient.Subscribe("$aws/things/"+id+"/update/post", 1, UpdateCB(status)) + mqttClient.Subscribe("$aws/things/"+id+"/status/post", 1, status.StatusEvent) + mqttClient.Subscribe("$aws/things/"+id+"/upload/post", 1, status.UploadEvent) + mqttClient.Subscribe("$aws/things/"+id+"/sketch/post", 1, status.SketchEvent) + mqttClient.Subscribe("$aws/things/"+id+"/update/post", 1, status.UpdateEvent) + mqttClient.Subscribe("$aws/things/"+id+"/stats/post", 1, status.StatsEvent) + mqttClient.Subscribe("$aws/things/"+id+"/wifi/post", 1, status.WiFiEvent) + + mqttClient.Subscribe("$aws/things/"+id+"/apt/list/post", 1, status.AptListEvent) + mqttClient.Subscribe("$aws/things/"+id+"/apt/install/post", 1, status.AptInstallEvent) + mqttClient.Subscribe("$aws/things/"+id+"/apt/update/post", 1, status.AptUpdateEvent) + mqttClient.Subscribe("$aws/things/"+id+"/apt/upgrade/post", 1, status.AptUpgradeEvent) + mqttClient.Subscribe("$aws/things/"+id+"/apt/remove/post", 1, status.AptRemoveEvent) + + mqttClient.Subscribe("$aws/things/"+id+"/apt/repos/list/post", 1, status.AptRepositoryListEvent) + mqttClient.Subscribe("$aws/things/"+id+"/apt/repos/add/post", 1, status.AptRepositoryAddEvent) + mqttClient.Subscribe("$aws/things/"+id+"/apt/repos/remove/post", 1, status.AptRepositoryRemoveEvent) + mqttClient.Subscribe("$aws/things/"+id+"/apt/repos/edit/post", 1, status.AptRepositoryEditEvent) } func addFileToSketchDB(file os.FileInfo, status *Status) *SketchStatus { diff --git a/status.go b/status.go index 171f6c4a..e891f8f6 100644 --- a/status.go +++ b/status.go @@ -141,6 +141,27 @@ func (s *Status) Raw(topic, msg string) { token.Wait() } +// InfoCommandOutput sends command output on the specified topic +func (s *Status) InfoCommandOutput(topic string, out []byte, err error) { + // Prepare response payload + type response struct { + Result string + Output string + } + info := response{ + Result: err.Error(), + Output: string(out), + } + data, err := json.Marshal(info) + if err != nil { + s.Error(topic, fmt.Errorf("Json marshal result: %s", err)) + return + } + + // Send result + s.Info(topic, string(data)+"\n") +} + // Publish sens on the /status topic a json representation of the connector func (s *Status) Publish() { data, err := json.Marshal(s) @@ -150,7 +171,7 @@ func (s *Status) Publish() { //fmt.Println(string(out.Bytes())) if err != nil { - s.Error("/status/error", errors.Wrap(err, "status request")) + s.Error("/status", errors.Wrap(err, "status request")) return } diff --git a/updater/updater.go b/updater/updater.go new file mode 100644 index 00000000..954e4dce --- /dev/null +++ b/updater/updater.go @@ -0,0 +1,257 @@ +package updater + +import ( + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "runtime" + "time" + + "github.com/blang/semver" + "github.com/kr/binarydist" + update "gopkg.in/inconshreveable/go-update.v0" + + "github.com/kardianos/osext" +) + +// Update protocol: +// +// GET hk.heroku.com/hk/linux-amd64.json +// +// 200 ok +// { +// "Version": "2", +// "Sha256": "..." // base64 +// } +// +// then +// +// GET hkpatch.s3.amazonaws.com/hk/1/2/linux-amd64 +// +// 200 ok +// [bsdiff data] +// +// or +// +// GET hkdist.s3.amazonaws.com/hk/2/linux-amd64.gz +// +// 200 ok +// [gzipped executable data] +// +// + +const ( + plat = runtime.GOOS + "-" + runtime.GOARCH +) + +const devValidTime = 7 * 24 * time.Hour + +var errHashMismatch = errors.New("new file hash mismatch after patch") +var up = update.New() + +// Updater is the configuration and runtime data for doing an update. +// +// Note that ApiURL, BinURL and DiffURL should have the same value if all files are available at the same location. +// +// Example: +// +// updater := &selfupdate.Updater{ +// CurrentVersion: version, +// ApiURL: "http://updates.yourdomain.com/", +// BinURL: "http://updates.yourdownmain.com/", +// DiffURL: "http://updates.yourdomain.com/", +// Dir: "update/", +// CmdName: "myapp", // app name +// } +// if updater != nil { +// go updater.BackgroundRun() +// } +type Updater struct { + CurrentVersion string // Currently running version. + APIURL string // Base URL for API requests (json files). + CmdName string // Command name is appended to the ApiURL like http://apiurl/CmdName/. This represents one binary. + BinURL string // Base URL for full binary downloads. + DiffURL string // Base URL for diff downloads. + Dir string // Directory to store selfupdate state. + Info struct { + Version string + Sha256 []byte + } +} + +// BackgroundRun starts the update check and apply cycle. +func (u *Updater) BackgroundRun() error { + os.MkdirAll(u.getExecRelativeDir(u.Dir), 0777) + if err := up.CanUpdate(); err != nil { + log.Println(err) + return err + } + //self, err := osext.Executable() + //if err != nil { + // fail update, couldn't figure out path to self + //return + //} + // TODO(bgentry): logger isn't on Windows. Replace w/ proper error reports. + if err := u.update(); err != nil { + return err + } + return nil +} + +func fetch(url string) (io.ReadCloser, error) { + resp, err := http.Get(url) + if err != nil { + return nil, err + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("bad http status from %s: %v", url, resp.Status) + } + return resp.Body, nil +} + +func verifySha(bin []byte, sha []byte) bool { + h := sha256.New() + h.Write(bin) + return bytes.Equal(h.Sum(nil), sha) +} + +func (u *Updater) fetchAndApplyPatch(old io.Reader) ([]byte, error) { + r, err := fetch(u.DiffURL + u.CmdName + "/" + u.CurrentVersion + "/" + u.Info.Version + "/" + plat) + if err != nil { + return nil, err + } + defer r.Close() + var buf bytes.Buffer + err = binarydist.Patch(old, &buf, r) + return buf.Bytes(), err +} + +func (u *Updater) fetchAndVerifyPatch(old io.Reader) ([]byte, error) { + bin, err := u.fetchAndApplyPatch(old) + if err != nil { + return nil, err + } + if !verifySha(bin, u.Info.Sha256) { + return nil, errHashMismatch + } + return bin, nil +} + +func (u *Updater) fetchAndVerifyFullBin() ([]byte, error) { + bin, err := u.fetchBin() + if err != nil { + return nil, err + } + verified := verifySha(bin, u.Info.Sha256) + if !verified { + return nil, errHashMismatch + } + return bin, nil +} + +func (u *Updater) fetchBin() ([]byte, error) { + r, err := fetch(u.BinURL + u.CmdName + "/" + u.Info.Version + "/" + plat + ".gz") + if err != nil { + return nil, err + } + defer r.Close() + buf := new(bytes.Buffer) + gz, err := gzip.NewReader(r) + if err != nil { + return nil, err + } + if _, err = io.Copy(buf, gz); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func (u *Updater) fetchInfo() error { + r, err := fetch(u.APIURL + u.CmdName + "/" + plat + ".json") + if err != nil { + return err + } + defer r.Close() + err = json.NewDecoder(r).Decode(&u.Info) + if err != nil { + return err + } + if len(u.Info.Sha256) != sha256.Size { + return errors.New("bad cmd hash in info") + } + return nil +} + +func (u *Updater) getExecRelativeDir(dir string) string { + filename, _ := osext.Executable() + path := filepath.Join(filepath.Dir(filename), dir) + return path +} + +func (u *Updater) update() error { + path, err := osext.Executable() + if err != nil { + return err + } + old, err := os.Open(path) + if err != nil { + return err + } + defer old.Close() + + err = u.fetchInfo() + if err != nil { + log.Println(err) + return err + } + + v1, _ := semver.Make(u.Info.Version) + v2, _ := semver.Make(u.CurrentVersion) + + if v1.Compare(v2) <= 0 { + return errors.New("already at latest version") + } + bin, err := u.fetchAndVerifyPatch(old) + if err != nil { + if err == errHashMismatch { + log.Println("update: hash mismatch from patched binary") + } else { + if u.DiffURL != "" { + log.Println("update: patching binary,", err) + } + } + + bin, err = u.fetchAndVerifyFullBin() + if err != nil { + if err == errHashMismatch { + log.Println("update: hash mismatch from full binary") + } else { + log.Println("update: fetching full binary,", err) + } + return err + } + } + + // close the old binary before installing because on windows + // it can't be renamed if a handle to the file is still open + old.Close() + + err, errRecover := up.FromStream(bytes.NewBuffer(bin)) + if errRecover != nil { + return fmt.Errorf("update and recovery errors: %q %q", err, errRecover) + } + if err != nil { + return err + } + + return nil +}