Initial commit.
authorSebastien Douheret <sebastien.douheret@iot.bzh>
Mon, 15 May 2017 09:12:21 +0000 (11:12 +0200)
committerSebastien Douheret <sebastien.douheret@iot.bzh>
Mon, 15 May 2017 17:08:31 +0000 (19:08 +0200)
Signed-off-by: Sebastien Douheret <sebastien.douheret@iot.bzh>
22 files changed:
.gitignore [new file with mode: 0644]
.vscode/launch.json [new file with mode: 0644]
.vscode/settings.json [new file with mode: 0644]
LICENSE [new file with mode: 0644]
Makefile [new file with mode: 0644]
README.md [new file with mode: 0644]
agent-config.json.in [new file with mode: 0644]
glide.yaml [new file with mode: 0644]
lib/agent/agent.go [new file with mode: 0644]
lib/apiv1/apiv1.go [new file with mode: 0644]
lib/apiv1/config.go [new file with mode: 0644]
lib/apiv1/version.go [new file with mode: 0644]
lib/common/error.go [new file with mode: 0644]
lib/common/httpclient.go [new file with mode: 0644]
lib/session/session.go [new file with mode: 0644]
lib/syncthing/st.go [new file with mode: 0644]
lib/syncthing/stfolder.go [new file with mode: 0644]
lib/xdsconfig/config.go [new file with mode: 0644]
lib/xdsconfig/fileconfig.go [new file with mode: 0644]
lib/xdsserver/server.go [new file with mode: 0644]
main.go [new file with mode: 0644]
scripts/get-syncthing.sh [new file with mode: 0755]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..1b467eb
--- /dev/null
@@ -0,0 +1,11 @@
+bin
+tools
+package
+**/glide.lock
+**/vendor
+
+debug
+
+# private (prefixed by 2 underscores) directories or files
+__*/
+__*
\ No newline at end of file
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644 (file)
index 0000000..ed892d6
--- /dev/null
@@ -0,0 +1,22 @@
+{
+    "version": "0.2.0",
+    "configurations": [{
+            "name": "XDS-Agent",
+            "type": "go",
+            "request": "launch",
+            "mode": "debug",
+            "remotePath": "",
+            "port": 2345,
+            "host": "127.0.0.1",
+            "program": "${workspaceRoot}",
+            "env": {
+                "GOPATH": "${workspaceRoot}/../../../..:${env:GOPATH}",
+                "WORKSPACE_ROOT": "${workspaceRoot}",
+                "DEBUG_MODE": "1",
+                "ROOT_DIR": "${workspaceRoot}/../../../.."
+            },
+            "args": ["-log", "debug", "-c", "config.json.in"],
+            "showLog": false
+        }
+    ]
+}
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644 (file)
index 0000000..a6647f3
--- /dev/null
@@ -0,0 +1,20 @@
+// Place your settings in this file to overwrite default and user settings.
+{
+    // Configure glob patterns for excluding files and folders.
+    "files.exclude": {
+        ".tmp": true,
+        ".git": true,
+        "glide.lock": true,
+        "vendor": true,
+        "debug": true,
+        "bin": true,
+        "tools": true
+    },
+
+    // Words to add to dictionary for a workspace.
+    "cSpell.words": [
+        "apiv", "gonic", "devel", "csrffound", "Syncthing", "STID",
+        "ISTCONFIG", "socketio", "ldflags", "SThg", "Intf", "dismissible",
+        "rpath", "WSID", "sess", "IXDS", "xdsconfig", "xdsserver", "Inot", "inotify", "cmdi"
+    ]
+}
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644 (file)
index 0000000..8dada3e
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "{}"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright {yyyy} {name of copyright owner}
+
+   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.
diff --git a/Makefile b/Makefile
new file mode 100644 (file)
index 0000000..fa86c37
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,109 @@
+# Makefile used to build XDS daemon Web Server
+
+# Application Version
+VERSION := 0.0.1
+
+# Syncthing version to install
+SYNCTHING_VERSION = 0.14.27
+SYNCTHING_INOTIFY_VERSION = 0.8.5
+
+
+
+# Retrieve git tag/commit to set sub-version string
+ifeq ($(origin SUB_VERSION), undefined)
+       SUB_VERSION := $(shell git describe --tags --always | sed 's/^v//')
+       ifeq ($(SUB_VERSION), )
+               SUB_VERSION=unknown-dev
+       endif
+endif
+
+# Configurable variables for installation (default /usr/local/...)
+ifeq ($(origin INSTALL_DIR), undefined)
+       INSTALL_DIR := /usr/local/bin
+endif
+
+HOST_GOOS=$(shell go env GOOS)
+HOST_GOARCH=$(shell go env GOARCH)
+ARCH=$(HOST_GOOS)-$(HOST_GOARCH)
+REPOPATH=github.com/iotbzh/xds-agent
+
+mkfile_path := $(abspath $(lastword $(MAKEFILE_LIST)))
+ROOT_SRCDIR := $(patsubst %/,%,$(dir $(mkfile_path)))
+ROOT_GOPRJ := $(abspath $(ROOT_SRCDIR)/../../../..)
+LOCAL_BINDIR := $(ROOT_SRCDIR)/bin
+PACKAGE_DIR := $(ROOT_SRCDIR)/package
+
+export GOPATH := $(shell go env GOPATH):$(ROOT_GOPRJ)
+export PATH := $(PATH):$(ROOT_SRCDIR)/tools
+
+VERBOSE_1 := -v
+VERBOSE_2 := -v -x
+
+
+all: build
+
+build: vendor tools/syncthing
+       @echo "### Build XDS agent (version $(VERSION), subversion $(SUB_VERSION))";
+       @cd $(ROOT_SRCDIR); $(BUILD_ENV_FLAGS) go build $(VERBOSE_$(V)) -i -o $(LOCAL_BINDIR)/xds-agent -ldflags "-X main.AppVersion=$(VERSION) -X main.AppSubVersion=$(SUB_VERSION)" .
+
+package: clean build
+       @mkdir -p $(PACKAGE_DIR)/xds-agent
+       @cp agent-config.json.in $(PACKAGE_DIR)/xds-agent/agent-config.json
+       @cp -a $(LOCAL_BINDIR)/* $(PACKAGE_DIR)/xds-agent
+       cd $(PACKAGE_DIR) && zip -r $(LOCAL_BINDIR)/xds-agent_$(ARCH)-v$(VERSION)_$(SUB_VERSION).zip ./xds-agent
+
+test: tools/glide
+       go test --race $(shell ./tools/glide novendor)
+
+vet: tools/glide
+       go vet $(shell ./tools/glide novendor)
+
+fmt: tools/glide
+       go fmt $(shell ./tools/glide novendor)
+
+run: build/xds tools/syncthing
+       $(LOCAL_BINDIR)/xds-agent --log info -c agent-config.json.in
+
+debug: build/xds tools/syncthing
+       $(LOCAL_BINDIR)/xds-agent --log debug -c agent-config.json.in
+
+.PHONY: clean
+clean:
+       rm -rf $(LOCAL_BINDIR)/* debug $(ROOT_GOPRJ)/pkg/*/$(REPOPATH) $(PACKAGE_DIR)
+
+.PHONY: distclean
+distclean: clean
+       rm -rf $(LOCAL_BINDIR) tools glide.lock vendor
+
+.PHONY: install
+install: all
+       mkdir -p $(INSTALL_DIR) && cp $(LOCAL_BINDIR)/* $(INSTALL_DIR)
+
+vendor: tools/glide glide.yaml
+       ./tools/glide install --strip-vendor
+
+tools/glide:
+       @echo "Downloading glide"
+       mkdir -p tools
+       curl --silent -L https://glide.sh/get | GOBIN=./tools  sh
+
+.PHONY: tools/syncthing
+tools/syncthing:
+       @(test -s $(LOCAL_BINDIR)/syncthing || \
+       DESTDIR=$(LOCAL_BINDIR) \
+       SYNCTHING_VERSION=$(SYNCTHING_VERSION) \
+       SYNCTHING_INOTIFY_VERSION=$(SYNCTHING_INOTIFY_VERSION) \
+       ./scripts/get-syncthing.sh)
+
+.PHONY: help
+help:
+       @echo "Main supported rules:"
+       @echo "  build               (default)"
+       @echo "  package"
+       @echo "  install"
+       @echo "  clean"
+       @echo "  distclean"
+       @echo ""
+       @echo "Influential make variables:"
+       @echo "  V                 - Build verbosity {0,1,2}."
+       @echo "  BUILD_ENV_FLAGS   - Environment added to 'go build'."
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..d72ed95
--- /dev/null
+++ b/README.md
@@ -0,0 +1,74 @@
+# XDS - X(cross) Development System Agent
+
+XDS-agent is a agent that should be run on your local machine when you use XDS.
+
+This agent takes care of starting [Syncthing](https://syncthing.net/) tool to synchronize your projects files from your local machine to build server machine
+or container.
+
+
+> **SEE ALSO**: [xds-server](https://github.com/iotbzh/xds-server), a web server
+used to remotely cross build applications.
+
+
+## How to build
+
+### Dependencies
+
+- Install and setup [Go](https://golang.org/doc/install) version 1.7 or
+higher to compile this tool.
+
+
+### Building
+
+Clone this repo into your `$GOPATH/src/github.com/iotbzh` and use delivered Makefile:
+```bash
+ mkdir -p $GOPATH/src/github.com/iotbzh
+ cd $GOPATH/src/github.com/iotbzh
+ git clone https://github.com/iotbzh/xds-agent.git
+ cd xds-agent
+ make all
+```
+
+And to install xds-agent in /usr/local/bin:
+```bash
+make install
+```
+
+## How to run
+
+## Configuration
+
+xds-agent configuration is driven by a JSON config file (`agent-config.json`).
+
+Here is the logic to determine which `agent-config.json` file will be used:
+1. from command line option: `--config myConfig.json`
+2. `$HOME/.xds/agent-config.json` file
+3. `<current dir>/agent-config.json` file
+4. `<xds-agent executable dir>/agent-config.json` file
+
+Supported fields in configuration file are:
+```json
+{
+    "httpPort": "http port of agent REST interface",
+    "syncthing": {
+        "binDir": "syncthing binaries directory (use xds-agent executable dir when not set)",
+        "home": "syncthing home directory (usually .../syncthing-config)",
+        "gui-address": "syncthing gui url (default http://localhost:8384)"
+    }
+}
+```
+
+>**NOTE:** environment variables are supported by using `${MY_VAR}` syntax.
+
+## Start-up
+
+```bash
+./bin/xds-agent.sh
+
+# OR if you have install agent
+
+/usr/local/bin/xds-agent.sh
+```
+
+>**NOTE** you can define some environment variables to setup for example
+config file `XDS_CONFIGFILE` or change logs level `LOG_LEVEL`.
diff --git a/agent-config.json.in b/agent-config.json.in
new file mode 100644 (file)
index 0000000..a0c0ad5
--- /dev/null
@@ -0,0 +1,7 @@
+{
+    "syncthing": {
+        "binDir": "./bin",
+        "home": "${ROOT_DIR}/tmp/local_dev/syncthing-config",
+        "gui-address": "http://localhost:8384"
+    }
+}
\ No newline at end of file
diff --git a/glide.yaml b/glide.yaml
new file mode 100644 (file)
index 0000000..794de5d
--- /dev/null
@@ -0,0 +1,19 @@
+package: github.com/iotbzh/xds-agent
+license: Apache-2
+owners:
+- name: Sebastien Douheret
+  email: sebastien@iot.bzh
+import:
+- package: github.com/gin-gonic/gin
+  version: ^1.1.4
+- package: github.com/gin-contrib/static
+- package: github.com/syncthing/syncthing
+  version: ^0.14.27-rc.2
+- package: github.com/codegangsta/cli
+  version: ^1.19.1
+- package: github.com/Sirupsen/logrus
+  version: ^0.11.5
+- package: github.com/googollee/go-socket.io
+- package: github.com/zhouhui8915/go-socket.io-client
+- package: github.com/satori/go.uuid
+  version: ^1.1.0
diff --git a/lib/agent/agent.go b/lib/agent/agent.go
new file mode 100644 (file)
index 0000000..80c97f7
--- /dev/null
@@ -0,0 +1,76 @@
+package agent
+
+import (
+       "fmt"
+       "os"
+       "os/exec"
+       "os/signal"
+       "syscall"
+
+       "github.com/Sirupsen/logrus"
+       "github.com/codegangsta/cli"
+       "github.com/iotbzh/xds-agent/lib/syncthing"
+       "github.com/iotbzh/xds-agent/lib/xdsconfig"
+       "github.com/iotbzh/xds-agent/lib/xdsserver"
+)
+
+// Context holds the Agent context structure
+type Context struct {
+       ProgName    string
+       Config      *xdsconfig.Config
+       Log         *logrus.Logger
+       SThg        *st.SyncThing
+       SThgCmd     *exec.Cmd
+       SThgInotCmd *exec.Cmd
+       WWWServer   *xdsserver.ServerService
+       Exit        chan os.Signal
+}
+
+// NewAgent Create a new instance of Agent
+func NewAgent(cliCtx *cli.Context) *Context {
+       var err error
+
+       // Set logger level and formatter
+       log := cliCtx.App.Metadata["logger"].(*logrus.Logger)
+
+       logLevel := cliCtx.GlobalString("log")
+       if logLevel == "" {
+               logLevel = "error" // FIXME get from Config DefaultLogLevel
+       }
+       if log.Level, err = logrus.ParseLevel(logLevel); err != nil {
+               fmt.Printf("Invalid log level : \"%v\"\n", logLevel)
+               os.Exit(1)
+       }
+       log.Formatter = &logrus.TextFormatter{}
+
+       // Define default configuration
+       ctx := Context{
+               ProgName: cliCtx.App.Name,
+               Log:      log,
+               Exit:     make(chan os.Signal, 1),
+       }
+
+       // register handler on SIGTERM / exit
+       signal.Notify(ctx.Exit, os.Interrupt, syscall.SIGTERM)
+       go handlerSigTerm(&ctx)
+
+       return &ctx
+}
+
+// Handle exit and properly stop/close all stuff
+func handlerSigTerm(ctx *Context) {
+       <-ctx.Exit
+       if ctx.SThg != nil {
+               ctx.Log.Infof("Stoping Syncthing... (PID %d)",
+                       ctx.SThgCmd.Process.Pid)
+               ctx.Log.Infof("Stoping Syncthing-inotify... (PID %d)",
+                       ctx.SThgInotCmd.Process.Pid)
+               ctx.SThg.Stop()
+               ctx.SThg.StopInotify()
+       }
+       if ctx.WWWServer != nil {
+               ctx.Log.Infof("Stoping Web server...")
+               ctx.WWWServer.Stop()
+       }
+       os.Exit(1)
+}
diff --git a/lib/apiv1/apiv1.go b/lib/apiv1/apiv1.go
new file mode 100644 (file)
index 0000000..734929b
--- /dev/null
@@ -0,0 +1,36 @@
+package apiv1
+
+import (
+       "github.com/Sirupsen/logrus"
+       "github.com/gin-gonic/gin"
+
+       "github.com/iotbzh/xds-agent/lib/session"
+       "github.com/iotbzh/xds-agent/lib/xdsconfig"
+)
+
+// APIService .
+type APIService struct {
+       router    *gin.Engine
+       apiRouter *gin.RouterGroup
+       sessions  *session.Sessions
+       cfg       *xdsconfig.Config
+       log       *logrus.Logger
+}
+
+// New creates a new instance of API service
+func New(sess *session.Sessions, conf *xdsconfig.Config, log *logrus.Logger, r *gin.Engine) *APIService {
+       s := &APIService{
+               router:    r,
+               sessions:  sess,
+               apiRouter: r.Group("/api/v1"),
+               cfg:       conf,
+               log:       log,
+       }
+
+       s.apiRouter.GET("/version", s.getVersion)
+
+       s.apiRouter.GET("/config", s.getConfig)
+       s.apiRouter.POST("/config", s.setConfig)
+
+       return s
+}
diff --git a/lib/apiv1/config.go b/lib/apiv1/config.go
new file mode 100644 (file)
index 0000000..79225f4
--- /dev/null
@@ -0,0 +1,45 @@
+package apiv1
+
+import (
+       "net/http"
+       "sync"
+
+       "github.com/gin-gonic/gin"
+       "github.com/iotbzh/xds-agent/lib/common"
+       "github.com/iotbzh/xds-agent/lib/xdsconfig"
+)
+
+var confMut sync.Mutex
+
+// GetConfig returns the configuration
+func (s *APIService) getConfig(c *gin.Context) {
+       confMut.Lock()
+       defer confMut.Unlock()
+
+       c.JSON(http.StatusOK, s.cfg)
+}
+
+// SetConfig sets configuration
+func (s *APIService) setConfig(c *gin.Context) {
+       // FIXME - must be tested
+       c.JSON(http.StatusNotImplemented, "Not implemented")
+
+       var cfgArg xdsconfig.Config
+
+       if c.BindJSON(&cfgArg) != nil {
+               common.APIError(c, "Invalid arguments")
+               return
+       }
+
+       confMut.Lock()
+       defer confMut.Unlock()
+
+       s.log.Debugln("SET config: ", cfgArg)
+
+       if err := s.cfg.UpdateAll(cfgArg); err != nil {
+               common.APIError(c, err.Error())
+               return
+       }
+
+       c.JSON(http.StatusOK, s.cfg)
+}
diff --git a/lib/apiv1/version.go b/lib/apiv1/version.go
new file mode 100644 (file)
index 0000000..e022441
--- /dev/null
@@ -0,0 +1,24 @@
+package apiv1
+
+import (
+       "net/http"
+
+       "github.com/gin-gonic/gin"
+)
+
+type version struct {
+       Version       string `json:"version"`
+       APIVersion    string `json:"apiVersion"`
+       VersionGitTag string `json:"gitTag"`
+}
+
+// getInfo : return various information about server
+func (s *APIService) getVersion(c *gin.Context) {
+       response := version{
+               Version:       s.cfg.Version,
+               APIVersion:    s.cfg.APIVersion,
+               VersionGitTag: s.cfg.VersionGitTag,
+       }
+
+       c.JSON(http.StatusOK, response)
+}
diff --git a/lib/common/error.go b/lib/common/error.go
new file mode 100644 (file)
index 0000000..d03c176
--- /dev/null
@@ -0,0 +1,13 @@
+package common
+
+import (
+       "github.com/gin-gonic/gin"
+)
+
+// APIError returns an uniform json formatted error
+func APIError(c *gin.Context, err string) {
+       c.JSON(500, gin.H{
+               "status": "error",
+               "error":  err,
+       })
+}
diff --git a/lib/common/httpclient.go b/lib/common/httpclient.go
new file mode 100644 (file)
index 0000000..40d7bc2
--- /dev/null
@@ -0,0 +1,221 @@
+package common
+
+import (
+       "bytes"
+       "crypto/tls"
+       "encoding/json"
+       "errors"
+       "fmt"
+       "io/ioutil"
+       "net/http"
+       "strings"
+)
+
+type HTTPClient struct {
+       httpClient http.Client
+       endpoint   string
+       apikey     string
+       username   string
+       password   string
+       id         string
+       csrf       string
+       conf       HTTPClientConfig
+}
+
+type HTTPClientConfig struct {
+       URLPrefix           string
+       HeaderAPIKeyName    string
+       Apikey              string
+       HeaderClientKeyName string
+       CsrfDisable         bool
+}
+
+// Inspired by syncthing/cmd/cli
+
+const insecure = false
+
+// HTTPNewClient creates a new HTTP client to deal with Syncthing
+func HTTPNewClient(baseURL string, cfg HTTPClientConfig) (*HTTPClient, error) {
+
+       // Create w new Http client
+       httpClient := http.Client{
+               Transport: &http.Transport{
+                       TLSClientConfig: &tls.Config{
+                               InsecureSkipVerify: insecure,
+                       },
+               },
+       }
+       client := HTTPClient{
+               httpClient: httpClient,
+               endpoint:   baseURL,
+               apikey:     cfg.Apikey,
+               conf:       cfg,
+               /* TODO - add user + pwd support
+               username:   c.GlobalString("username"),
+               password:   c.GlobalString("password"),
+               */
+       }
+
+       if client.apikey == "" {
+               if err := client.getCidAndCsrf(); err != nil {
+                       return nil, err
+               }
+       }
+       return &client, nil
+}
+
+// Send request to retrieve Client id and/or CSRF token
+func (c *HTTPClient) getCidAndCsrf() error {
+       request, err := http.NewRequest("GET", c.endpoint, nil)
+       if err != nil {
+               return err
+       }
+       if _, err := c.handleRequest(request); err != nil {
+               return err
+       }
+       if c.id == "" {
+               return errors.New("Failed to get device ID")
+       }
+       if !c.conf.CsrfDisable && c.csrf == "" {
+               return errors.New("Failed to get CSRF token")
+       }
+       return nil
+}
+
+// GetClientID returns the id
+func (c *HTTPClient) GetClientID() string {
+       return c.id
+}
+
+// formatURL Build full url by concatenating all parts
+func (c *HTTPClient) formatURL(endURL string) string {
+       url := c.endpoint
+       if !strings.HasSuffix(url, "/") {
+               url += "/"
+       }
+       url += strings.TrimLeft(c.conf.URLPrefix, "/")
+       if !strings.HasSuffix(url, "/") {
+               url += "/"
+       }
+       return url + strings.TrimLeft(endURL, "/")
+}
+
+// HTTPGet Send a Get request to client and return an error object
+func (c *HTTPClient) HTTPGet(url string, data *[]byte) error {
+       _, err := c.HTTPGetWithRes(url, data)
+       return err
+}
+
+// HTTPGetWithRes Send a Get request to client and return both response and error
+func (c *HTTPClient) HTTPGetWithRes(url string, data *[]byte) (*http.Response, error) {
+       request, err := http.NewRequest("GET", c.formatURL(url), nil)
+       if err != nil {
+               return nil, err
+       }
+       res, err := c.handleRequest(request)
+       if err != nil {
+               return res, err
+       }
+       if res.StatusCode != 200 {
+               return res, errors.New(res.Status)
+       }
+
+       *data = c.responseToBArray(res)
+
+       return res, nil
+}
+
+// HTTPPost Send a POST request to client and return an error object
+func (c *HTTPClient) HTTPPost(url string, body string) error {
+       _, err := c.HTTPPostWithRes(url, body)
+       return err
+}
+
+// HTTPPostWithRes Send a POST request to client and return both response and error
+func (c *HTTPClient) HTTPPostWithRes(url string, body string) (*http.Response, error) {
+       request, err := http.NewRequest("POST", c.formatURL(url), bytes.NewBufferString(body))
+       if err != nil {
+               return nil, err
+       }
+       res, err := c.handleRequest(request)
+       if err != nil {
+               return res, err
+       }
+       if res.StatusCode != 200 {
+               return res, errors.New(res.Status)
+       }
+       return res, nil
+}
+
+func (c *HTTPClient) responseToBArray(response *http.Response) []byte {
+       defer response.Body.Close()
+       bytes, err := ioutil.ReadAll(response.Body)
+       if err != nil {
+               // TODO improved error reporting
+               fmt.Println("ERROR: " + err.Error())
+       }
+       return bytes
+}
+
+func (c *HTTPClient) handleRequest(request *http.Request) (*http.Response, error) {
+       if c.conf.HeaderAPIKeyName != "" && c.apikey != "" {
+               request.Header.Set(c.conf.HeaderAPIKeyName, c.apikey)
+       }
+       if c.conf.HeaderClientKeyName != "" && c.id != "" {
+               request.Header.Set(c.conf.HeaderClientKeyName, c.id)
+       }
+       if c.username != "" || c.password != "" {
+               request.SetBasicAuth(c.username, c.password)
+       }
+       if c.csrf != "" {
+               request.Header.Set("X-CSRF-Token-"+c.id[:5], c.csrf)
+       }
+
+       response, err := c.httpClient.Do(request)
+       if err != nil {
+               return nil, err
+       }
+
+       // Detect client ID change
+       cid := response.Header.Get(c.conf.HeaderClientKeyName)
+       if cid != "" && c.id != cid {
+               c.id = cid
+       }
+
+       // Detect CSR token change
+       for _, item := range response.Cookies() {
+               if item.Name == "CSRF-Token-"+c.id[:5] {
+                       c.csrf = item.Value
+                       goto csrffound
+               }
+       }
+       // OK CSRF found
+csrffound:
+
+       if response.StatusCode == 404 {
+               return nil, errors.New("Invalid endpoint or API call")
+       } else if response.StatusCode == 401 {
+               return nil, errors.New("Invalid username or password")
+       } else if response.StatusCode == 403 {
+               if c.apikey == "" {
+                       // Request a new Csrf for next requests
+                       c.getCidAndCsrf()
+                       return nil, errors.New("Invalid CSRF token")
+               }
+               return nil, errors.New("Invalid API key")
+       } else if response.StatusCode != 200 {
+               data := make(map[string]interface{})
+               // Try to decode error field of APIError struct
+               json.Unmarshal(c.responseToBArray(response), &data)
+               if err, found := data["error"]; found {
+                       return nil, fmt.Errorf(err.(string))
+               } else {
+                       body := strings.TrimSpace(string(c.responseToBArray(response)))
+                       if body != "" {
+                               return nil, fmt.Errorf(body)
+                       }
+               }
+               return nil, errors.New("Unknown HTTP status returned: " + response.Status)
+       }
+       return response, nil
+}
diff --git a/lib/session/session.go b/lib/session/session.go
new file mode 100644 (file)
index 0000000..af05daa
--- /dev/null
@@ -0,0 +1,227 @@
+package session
+
+import (
+       "encoding/base64"
+       "strconv"
+       "time"
+
+       "github.com/Sirupsen/logrus"
+       "github.com/gin-gonic/gin"
+       "github.com/googollee/go-socket.io"
+       uuid "github.com/satori/go.uuid"
+       "github.com/syncthing/syncthing/lib/sync"
+)
+
+const sessionCookieName = "xds-agent-sid"
+const sessionHeaderName = "XDS-AGENT-SID"
+
+const sessionMonitorTime = 10 // Time (in seconds) to schedule monitoring session tasks
+
+const initSessionMaxAge = 10 // Initial session max age in seconds
+const maxSessions = 100000   // Maximum number of sessions in sessMap map
+
+const secureCookie = false // TODO: see https://github.com/astaxie/beego/blob/master/session/session.go#L218
+
+// ClientSession contains the info of a user/client session
+type ClientSession struct {
+       ID       string
+       WSID     string // only one WebSocket per client/session
+       MaxAge   int64
+       IOSocket *socketio.Socket
+
+       // private
+       expireAt time.Time
+       useCount int64
+}
+
+// Sessions holds client sessions
+type Sessions struct {
+       router       *gin.Engine
+       cookieMaxAge int64
+       sessMap      map[string]ClientSession
+       mutex        sync.Mutex
+       log          *logrus.Logger
+       stop         chan struct{} // signals intentional stop
+}
+
+// NewClientSessions .
+func NewClientSessions(router *gin.Engine, log *logrus.Logger, cookieMaxAge string) *Sessions {
+       ckMaxAge, err := strconv.ParseInt(cookieMaxAge, 10, 0)
+       if err != nil {
+               ckMaxAge = 0
+       }
+       s := Sessions{
+               router:       router,
+               cookieMaxAge: ckMaxAge,
+               sessMap:      make(map[string]ClientSession),
+               mutex:        sync.NewMutex(),
+               log:          log,
+               stop:         make(chan struct{}),
+       }
+       s.router.Use(s.Middleware())
+
+       // Start monitoring of sessions Map (use to manage expiration and cleanup)
+       go s.monitorSessMap()
+
+       return &s
+}
+
+// Stop sessions management
+func (s *Sessions) Stop() {
+       close(s.stop)
+}
+
+// Middleware is used to managed session
+func (s *Sessions) Middleware() gin.HandlerFunc {
+       return func(c *gin.Context) {
+               // FIXME Add CSRF management
+
+               // Get session
+               sess := s.Get(c)
+               if sess == nil {
+                       // Allocate a new session key and put in cookie
+                       sess = s.newSession("")
+               } else {
+                       s.refresh(sess.ID)
+               }
+
+               // Set session in cookie and in header
+               // Do not set Domain to localhost (http://stackoverflow.com/questions/1134290/cookies-on-localhost-with-explicit-domain)
+               c.SetCookie(sessionCookieName, sess.ID, int(sess.MaxAge), "/", "",
+                       secureCookie, false)
+               c.Header(sessionHeaderName, sess.ID)
+
+               // Save session id in gin metadata
+               c.Set(sessionCookieName, sess.ID)
+
+               c.Next()
+       }
+}
+
+// Get returns the client session for a specific ID
+func (s *Sessions) Get(c *gin.Context) *ClientSession {
+       var sid string
+
+       // First get from gin metadata
+       v, exist := c.Get(sessionCookieName)
+       if v != nil {
+               sid = v.(string)
+       }
+
+       // Then look in cookie
+       if !exist || sid == "" {
+               sid, _ = c.Cookie(sessionCookieName)
+       }
+
+       // Then look in Header
+       if sid == "" {
+               sid = c.Request.Header.Get(sessionCookieName)
+       }
+       if sid != "" {
+               s.mutex.Lock()
+               defer s.mutex.Unlock()
+               if key, ok := s.sessMap[sid]; ok {
+                       // TODO: return a copy ???
+                       return &key
+               }
+       }
+       return nil
+}
+
+// IOSocketGet Get socketio definition from sid
+func (s *Sessions) IOSocketGet(sid string) *socketio.Socket {
+       s.mutex.Lock()
+       defer s.mutex.Unlock()
+       sess, ok := s.sessMap[sid]
+       if ok {
+               return sess.IOSocket
+       }
+       return nil
+}
+
+// UpdateIOSocket updates the IO Socket definition for of a session
+func (s *Sessions) UpdateIOSocket(sid string, so *socketio.Socket) error {
+       s.mutex.Lock()
+       defer s.mutex.Unlock()
+       if _, ok := s.sessMap[sid]; ok {
+               sess := s.sessMap[sid]
+               if so == nil {
+                       // Could be the case when socketio is closed/disconnected
+                       sess.WSID = ""
+               } else {
+                       sess.WSID = (*so).Id()
+               }
+               sess.IOSocket = so
+               s.sessMap[sid] = sess
+       }
+       return nil
+}
+
+// nesSession Allocate a new client session
+func (s *Sessions) newSession(prefix string) *ClientSession {
+       uuid := prefix + uuid.NewV4().String()
+       id := base64.URLEncoding.EncodeToString([]byte(uuid))
+       se := ClientSession{
+               ID:       id,
+               WSID:     "",
+               MaxAge:   initSessionMaxAge,
+               IOSocket: nil,
+               expireAt: time.Now().Add(time.Duration(initSessionMaxAge) * time.Second),
+               useCount: 0,
+       }
+       s.mutex.Lock()
+       defer s.mutex.Unlock()
+
+       s.sessMap[se.ID] = se
+
+       s.log.Debugf("NEW session (%d): %s", len(s.sessMap), id)
+       return &se
+}
+
+// refresh Move this session ID to the head of the list
+func (s *Sessions) refresh(sid string) {
+       s.mutex.Lock()
+       defer s.mutex.Unlock()
+
+       sess := s.sessMap[sid]
+       sess.useCount++
+       if sess.MaxAge < s.cookieMaxAge && sess.useCount > 1 {
+               sess.MaxAge = s.cookieMaxAge
+               sess.expireAt = time.Now().Add(time.Duration(sess.MaxAge) * time.Second)
+       }
+
+       // TODO - Add flood detection (like limit_req of nginx)
+       // (delayed request when to much requests in a short period of time)
+
+       s.sessMap[sid] = sess
+}
+
+func (s *Sessions) monitorSessMap() {
+       const dbgFullTrace = false // for debugging
+
+       for {
+               select {
+               case <-s.stop:
+                       s.log.Debugln("Stop monitorSessMap")
+                       return
+               case <-time.After(sessionMonitorTime * time.Second):
+                       s.log.Debugf("Sessions Map size: %d", len(s.sessMap))
+                       if dbgFullTrace {
+                               s.log.Debugf("Sessions Map : %v", s.sessMap)
+                       }
+
+                       if len(s.sessMap) > maxSessions {
+                               s.log.Errorln("TOO MUCH sessions, cleanup old ones !")
+                       }
+
+                       s.mutex.Lock()
+                       for _, ss := range s.sessMap {
+                               if ss.expireAt.Sub(time.Now()) < 0 {
+                                       s.log.Debugf("Delete expired session id: %s", ss.ID)
+                                       delete(s.sessMap, ss.ID)
+                               }
+                       }
+                       s.mutex.Unlock()
+               }
+       }
+}
diff --git a/lib/syncthing/st.go b/lib/syncthing/st.go
new file mode 100644 (file)
index 0000000..e513876
--- /dev/null
@@ -0,0 +1,242 @@
+package st
+
+import (
+       "encoding/json"
+       "io/ioutil"
+       "os"
+       "path"
+       "strings"
+       "syscall"
+       "time"
+
+       "fmt"
+
+       "os/exec"
+
+       "github.com/Sirupsen/logrus"
+       "github.com/iotbzh/xds-agent/lib/common"
+       "github.com/iotbzh/xds-agent/lib/xdsconfig"
+       "github.com/syncthing/syncthing/lib/config"
+)
+
+// SyncThing .
+type SyncThing struct {
+       BaseURL string
+       ApiKey  string
+       Home    string
+       STCmd   *exec.Cmd
+       STICmd  *exec.Cmd
+
+       // Private fields
+       binDir      string
+       exitSTChan  chan ExitChan
+       exitSTIChan chan ExitChan
+       client      *common.HTTPClient
+       log         *logrus.Logger
+}
+
+// Monitor process exit
+type ExitChan struct {
+       status int
+       err    error
+}
+
+// NewSyncThing creates a new instance of Syncthing
+//func NewSyncThing(url string, apiKey string, home string, log *logrus.Logger) *SyncThing {
+func NewSyncThing(conf *xdsconfig.SyncThingConf, log *logrus.Logger) *SyncThing {
+       url := conf.GuiAddress
+       apiKey := conf.GuiAPIKey
+       home := conf.Home
+
+       s := SyncThing{
+               BaseURL: url,
+               ApiKey:  apiKey,
+               Home:    home,
+               binDir:  conf.BinDir,
+               log:     log,
+       }
+
+       if s.BaseURL == "" {
+               s.BaseURL = "http://localhost:8384"
+       }
+       if s.BaseURL[0:7] != "http://" {
+               s.BaseURL = "http://" + s.BaseURL
+       }
+
+       return &s
+}
+
+// Start Starts syncthing process
+func (s *SyncThing) startProc(exeName string, args []string, env []string, eChan *chan ExitChan) (*exec.Cmd, error) {
+
+       // Kill existing process (useful for debug ;-) )
+       if os.Getenv("DEBUG_MODE") != "" {
+               exec.Command("bash", "-c", "pkill -9 "+exeName).Output()
+       }
+
+       path, err := exec.LookPath(path.Join(s.binDir, exeName))
+       if err != nil {
+               return nil, fmt.Errorf("Cannot find %s executable in %s", exeName, s.binDir)
+       }
+       cmd := exec.Command(path, args...)
+       cmd.Env = os.Environ()
+       for _, ev := range env {
+               cmd.Env = append(cmd.Env, ev)
+       }
+
+       err = cmd.Start()
+       if err != nil {
+               return nil, err
+       }
+
+       *eChan = make(chan ExitChan, 1)
+       go func(c *exec.Cmd) {
+               status := 0
+               cmdOut, err := c.StdoutPipe()
+               if err == nil {
+                       s.log.Errorf("Pipe stdout error for : %s", err)
+               } else if cmdOut != nil {
+                       stdOutput, _ := ioutil.ReadAll(cmdOut)
+                       fmt.Printf("STDOUT: %s\n", stdOutput)
+               }
+               sts, err := c.Process.Wait()
+               if !sts.Success() {
+                       s := sts.Sys().(syscall.WaitStatus)
+                       status = s.ExitStatus()
+               }
+               *eChan <- ExitChan{status, err}
+       }(cmd)
+
+       return cmd, nil
+}
+
+// Start Starts syncthing process
+func (s *SyncThing) Start() (*exec.Cmd, error) {
+       var err error
+       args := []string{
+               "--home=" + s.Home,
+               "-no-browser",
+               "--gui-address=" + s.BaseURL,
+       }
+
+       if s.ApiKey != "" {
+               args = append(args, "-gui-apikey=\""+s.ApiKey+"\"")
+       }
+       if s.log.Level == logrus.DebugLevel {
+               args = append(args, "-verbose")
+       }
+
+       env := []string{
+               "STNODEFAULTFOLDER=1",
+       }
+
+       s.STCmd, err = s.startProc("syncthing", args, env, &s.exitSTChan)
+
+       return s.STCmd, err
+}
+
+// StartInotify Starts syncthing-inotify process
+func (s *SyncThing) StartInotify() (*exec.Cmd, error) {
+       var err error
+
+       args := []string{
+               "--home=" + s.Home,
+               "-target=" + s.BaseURL,
+       }
+       if s.log.Level == logrus.DebugLevel {
+               args = append(args, "-verbosity=4")
+       }
+
+       env := []string{}
+
+       s.STICmd, err = s.startProc("syncthing-inotify", args, env, &s.exitSTIChan)
+
+       return s.STICmd, err
+}
+
+func (s *SyncThing) stopProc(pname string, proc *os.Process, exit chan ExitChan) {
+       if err := proc.Signal(os.Interrupt); err != nil {
+               s.log.Errorf("Proc interrupt %s error: %s", pname, err.Error())
+
+               select {
+               case <-exit:
+               case <-time.After(time.Second):
+                       // A bigger bonk on the head.
+                       if err := proc.Signal(os.Kill); err != nil {
+                               s.log.Errorf("Proc term %s error: %s", pname, err.Error())
+                       }
+                       <-exit
+               }
+       }
+       s.log.Infof("%s stopped (PID %d)", pname, proc.Pid)
+}
+
+// Stop Stops syncthing process
+func (s *SyncThing) Stop() {
+       if s.STCmd == nil {
+               return
+       }
+       s.stopProc("syncthing", s.STCmd.Process, s.exitSTChan)
+       s.STCmd = nil
+}
+
+// StopInotify Stops syncthing process
+func (s *SyncThing) StopInotify() {
+       if s.STICmd == nil {
+               return
+       }
+       s.stopProc("syncthing-inotify", s.STICmd.Process, s.exitSTIChan)
+       s.STICmd = nil
+}
+
+// Connect Establish HTTP connection with Syncthing
+func (s *SyncThing) Connect() error {
+       var err error
+       s.client, err = common.HTTPNewClient(s.BaseURL,
+               common.HTTPClientConfig{
+                       URLPrefix:           "/rest",
+                       HeaderClientKeyName: "X-Syncthing-ID",
+               })
+       if err != nil {
+               msg := ": " + err.Error()
+               if strings.Contains(err.Error(), "connection refused") {
+                       msg = fmt.Sprintf("(url: %s)", s.BaseURL)
+               }
+               return fmt.Errorf("ERROR: cannot connect to Syncthing %s", msg)
+       }
+       if s.client == nil {
+               return fmt.Errorf("ERROR: cannot connect to Syncthing (null client)")
+       }
+       return nil
+}
+
+// IDGet returns the Syncthing ID of Syncthing instance running locally
+func (s *SyncThing) IDGet() (string, error) {
+       var data []byte
+       if err := s.client.HTTPGet("system/status", &data); err != nil {
+               return "", err
+       }
+       status := make(map[string]interface{})
+       json.Unmarshal(data, &status)
+       return status["myID"].(string), nil
+}
+
+// ConfigGet returns the current Syncthing configuration
+func (s *SyncThing) ConfigGet() (config.Configuration, error) {
+       var data []byte
+       config := config.Configuration{}
+       if err := s.client.HTTPGet("system/config", &data); err != nil {
+               return config, err
+       }
+       err := json.Unmarshal(data, &config)
+       return config, err
+}
+
+// ConfigSet set Syncthing configuration
+func (s *SyncThing) ConfigSet(cfg config.Configuration) error {
+       body, err := json.Marshal(cfg)
+       if err != nil {
+               return err
+       }
+       return s.client.HTTPPost("system/config", string(body))
+}
diff --git a/lib/syncthing/stfolder.go b/lib/syncthing/stfolder.go
new file mode 100644 (file)
index 0000000..d79e579
--- /dev/null
@@ -0,0 +1,116 @@
+package st
+
+import (
+       "path/filepath"
+       "strings"
+
+       "github.com/syncthing/syncthing/lib/config"
+       "github.com/syncthing/syncthing/lib/protocol"
+)
+
+// FIXME remove and use an interface on xdsconfig.FolderConfig
+type FolderChangeArg struct {
+       ID           string
+       Label        string
+       RelativePath string
+       SyncThingID  string
+       ShareRootDir string
+}
+
+// FolderChange is called when configuration has changed
+func (s *SyncThing) FolderChange(f FolderChangeArg) error {
+
+       // Get current config
+       stCfg, err := s.ConfigGet()
+       if err != nil {
+               s.log.Errorln(err)
+               return err
+       }
+
+       // Add new Device if needed
+       var devID protocol.DeviceID
+       if err := devID.UnmarshalText([]byte(f.SyncThingID)); err != nil {
+               s.log.Errorf("not a valid device id (err %v)\n", err)
+               return err
+       }
+
+       newDevice := config.DeviceConfiguration{
+               DeviceID:  devID,
+               Name:      f.SyncThingID,
+               Addresses: []string{"dynamic"},
+       }
+
+       var found = false
+       for _, device := range stCfg.Devices {
+               if device.DeviceID == devID {
+                       found = true
+                       break
+               }
+       }
+       if !found {
+               stCfg.Devices = append(stCfg.Devices, newDevice)
+       }
+
+       // Add or update Folder settings
+       var label, id string
+       if label = f.Label; label == "" {
+               label = strings.Split(id, "/")[0]
+       }
+       if id = f.ID; id == "" {
+               id = f.SyncThingID[0:15] + "_" + label
+       }
+
+       folder := config.FolderConfiguration{
+               ID:      id,
+               Label:   label,
+               RawPath: filepath.Join(f.ShareRootDir, f.RelativePath),
+       }
+
+       folder.Devices = append(folder.Devices, config.FolderDeviceConfiguration{
+               DeviceID: newDevice.DeviceID,
+       })
+
+       found = false
+       var fld config.FolderConfiguration
+       for _, fld = range stCfg.Folders {
+               if folder.ID == fld.ID {
+                       fld = folder
+                       found = true
+                       break
+               }
+       }
+       if !found {
+               stCfg.Folders = append(stCfg.Folders, folder)
+               fld = stCfg.Folders[0]
+       }
+
+       err = s.ConfigSet(stCfg)
+       if err != nil {
+               s.log.Errorln(err)
+       }
+
+       return nil
+}
+
+// FolderDelete is called to delete a folder config
+func (s *SyncThing) FolderDelete(id string) error {
+       // Get current config
+       stCfg, err := s.ConfigGet()
+       if err != nil {
+               s.log.Errorln(err)
+               return err
+       }
+
+       for i, fld := range stCfg.Folders {
+               if id == fld.ID {
+                       stCfg.Folders = append(stCfg.Folders[:i], stCfg.Folders[i+1:]...)
+                       err = s.ConfigSet(stCfg)
+                       if err != nil {
+                               s.log.Errorln(err)
+                               return err
+                       }
+               }
+       }
+
+       return nil
+}
diff --git a/lib/xdsconfig/config.go b/lib/xdsconfig/config.go
new file mode 100644 (file)
index 0000000..1f53cbd
--- /dev/null
@@ -0,0 +1,65 @@
+package xdsconfig
+
+import (
+       "fmt"
+
+       "os"
+
+       "github.com/Sirupsen/logrus"
+       "github.com/codegangsta/cli"
+)
+
+// Config parameters (json format) of /config command
+type Config struct {
+       Version       string `json:"version"`
+       APIVersion    string `json:"apiVersion"`
+       VersionGitTag string `json:"gitTag"`
+
+       // Private / un-exported fields
+       HTTPPort string `json:"-"`
+       FileConf *FileConfig
+       log      *logrus.Logger
+}
+
+// Config default values
+const (
+       DefaultAPIVersion = "1"
+       DefaultPort       = "8010"
+       DefaultLogLevel   = "error"
+)
+
+// Init loads the configuration on start-up
+func Init(ctx *cli.Context, log *logrus.Logger) (Config, error) {
+       var err error
+
+       // Define default configuration
+       c := Config{
+               Version:       ctx.App.Metadata["version"].(string),
+               APIVersion:    DefaultAPIVersion,
+               VersionGitTag: ctx.App.Metadata["git-tag"].(string),
+
+               HTTPPort: DefaultPort,
+               log:      log,
+       }
+
+       // config file settings overwrite default config
+       c.FileConf, err = updateConfigFromFile(&c, ctx.GlobalString("config"))
+       if err != nil {
+               return Config{}, err
+       }
+
+       return c, nil
+}
+
+// UpdateAll Update the current configuration
+func (c *Config) UpdateAll(newCfg Config) error {
+       return fmt.Errorf("Not Supported")
+}
+
+func dirExists(path string) bool {
+       _, err := os.Stat(path)
+       if os.IsNotExist(err) {
+               return false
+       }
+       return true
+}
diff --git a/lib/xdsconfig/fileconfig.go b/lib/xdsconfig/fileconfig.go
new file mode 100644 (file)
index 0000000..0c4828c
--- /dev/null
@@ -0,0 +1,120 @@
+package xdsconfig
+
+import (
+       "encoding/json"
+       "fmt"
+       "os"
+       "os/user"
+       "path"
+       "path/filepath"
+       "regexp"
+)
+
+type SyncThingConf struct {
+       BinDir     string `json:"binDir"`
+       Home       string `json:"home"`
+       GuiAddress string `json:"gui-address"`
+       GuiAPIKey  string `json:"gui-apikey"`
+}
+
+type FileConfig struct {
+       HTTPPort string         `json:"httpPort"`
+       SThgConf *SyncThingConf `json:"syncthing"`
+}
+
+// getConfigFromFile reads configuration from a config file.
+// Order to determine which config file is used:
+//  1/ from command line option: "--config myConfig.json"
+//  2/ $HOME/.xds/agent-config.json file
+//  3/ <current_dir>/agent-config.json file
+//  4/ <executable dir>/agent-config.json file
+
+func updateConfigFromFile(c *Config, confFile string) (*FileConfig, error) {
+
+       searchIn := make([]string, 0, 3)
+       if confFile != "" {
+               searchIn = append(searchIn, confFile)
+       }
+       if usr, err := user.Current(); err == nil {
+               searchIn = append(searchIn, path.Join(usr.HomeDir, ".xds", "agent-config.json"))
+       }
+       cwd, err := os.Getwd()
+       if err == nil {
+               searchIn = append(searchIn, path.Join(cwd, "agent-config.json"))
+       }
+       exePath, err := filepath.Abs(filepath.Dir(os.Args[0]))
+       if err == nil {
+               searchIn = append(searchIn, path.Join(exePath, "agent-config.json"))
+       }
+
+       var cFile *string
+       for _, p := range searchIn {
+               if _, err := os.Stat(p); err == nil {
+                       cFile = &p
+                       break
+               }
+       }
+       fCfg := FileConfig{}
+       if cFile == nil {
+               // No config file found
+               return &fCfg, nil
+       }
+
+       c.log.Infof("Use config file: %s", *cFile)
+
+       // TODO move on viper package to support comments in JSON and also
+       // bind with flags (command line options)
+       // see https://github.com/spf13/viper#working-with-flags
+
+       fd, _ := os.Open(*cFile)
+       defer fd.Close()
+       if err := json.NewDecoder(fd).Decode(&fCfg); err != nil {
+               return nil, err
+       }
+
+       // Support environment variables (IOW ${MY_ENV_VAR} syntax) in agent-config.json
+       // TODO: better to use reflect package to iterate on fields and be more generic
+       var rep string
+
+       if rep, err = resolveEnvVar(fCfg.SThgConf.BinDir); err != nil {
+               return nil, err
+       }
+       fCfg.SThgConf.BinDir = path.Clean(rep)
+
+       if rep, err = resolveEnvVar(fCfg.SThgConf.Home); err != nil {
+               return nil, err
+       }
+       fCfg.SThgConf.Home = path.Clean(rep)
+
+       return &fCfg, nil
+}
+
+// resolveEnvVar Resolved environment variable regarding the syntax ${MYVAR}
+func resolveEnvVar(s string) (string, error) {
+       re := regexp.MustCompile("\\${(.*)}")
+       vars := re.FindAllStringSubmatch(s, -1)
+       res := s
+       for _, v := range vars {
+               val := os.Getenv(v[1])
+               if val == "" {
+                       return res, fmt.Errorf("ERROR: %s env variable not defined", v[1])
+               }
+
+               rer := regexp.MustCompile("\\${" + v[1] + "}")
+               res = rer.ReplaceAllString(res, val)
+       }
+
+       return res, nil
+}
+
+// exists returns whether the given file or directory exists or not
+func exists(path string) bool {
+       _, err := os.Stat(path)
+       if err == nil {
+               return true
+       }
+       if os.IsNotExist(err) {
+               return false
+       }
+       return true
+}
diff --git a/lib/xdsserver/server.go b/lib/xdsserver/server.go
new file mode 100644 (file)
index 0000000..f0777e3
--- /dev/null
@@ -0,0 +1,168 @@
+package xdsserver
+
+import (
+       "net/http"
+
+       "github.com/Sirupsen/logrus"
+       "github.com/gin-gonic/gin"
+       "github.com/googollee/go-socket.io"
+       "github.com/iotbzh/xds-agent/lib/apiv1"
+       "github.com/iotbzh/xds-agent/lib/session"
+       "github.com/iotbzh/xds-agent/lib/xdsconfig"
+)
+
+// ServerService .
+type ServerService struct {
+       router    *gin.Engine
+       api       *apiv1.APIService
+       sIOServer *socketio.Server
+       webApp    *gin.RouterGroup
+       cfg       *xdsconfig.Config
+       sessions  *session.Sessions
+       log       *logrus.Logger
+       stop      chan struct{} // signals intentional stop
+}
+
+const indexFilename = "index.html"
+const cookieMaxAge = "3600"
+
+// NewServer creates an instance of ServerService
+func NewServer(conf *xdsconfig.Config, log *logrus.Logger) *ServerService {
+
+       // Setup logging for gin router
+       if log.Level == logrus.DebugLevel {
+               gin.SetMode(gin.DebugMode)
+       } else {
+               gin.SetMode(gin.ReleaseMode)
+       }
+
+       // TODO
+       //  - try to bind gin DefaultWriter & DefaultErrorWriter to logrus logger
+       //  - try to fix pb about isTerminal=false when out is in VSC Debug Console
+       //gin.DefaultWriter = ??
+       //gin.DefaultErrorWriter = ??
+
+       // Creates gin router
+       r := gin.New()
+
+       svr := &ServerService{
+               router:    r,
+               api:       nil,
+               sIOServer: nil,
+               webApp:    nil,
+               cfg:       conf,
+               log:       log,
+               sessions:  nil,
+               stop:      make(chan struct{}),
+       }
+
+       return svr
+}
+
+// Serve starts a new instance of the Web Server
+func (s *ServerService) Serve() error {
+       var err error
+
+       // Setup middlewares
+       s.router.Use(gin.Logger())
+       s.router.Use(gin.Recovery())
+       s.router.Use(s.middlewareXDSDetails())
+       s.router.Use(s.middlewareCORS())
+
+       // Sessions manager
+       s.sessions = session.NewClientSessions(s.router, s.log, cookieMaxAge)
+
+       // Create REST API
+       s.api = apiv1.New(s.sessions, s.cfg, s.log, s.router)
+
+       // Websocket routes
+       s.sIOServer, err = socketio.NewServer(nil)
+       if err != nil {
+               s.log.Fatalln(err)
+       }
+
+       s.router.GET("/socket.io/", s.socketHandler)
+       s.router.POST("/socket.io/", s.socketHandler)
+       /* TODO: do we want to support ws://...  ?
+       s.router.Handle("WS", "/socket.io/", s.socketHandler)
+       s.router.Handle("WSS", "/socket.io/", s.socketHandler)
+       */
+
+       // Serve in the background
+       serveError := make(chan error, 1)
+       go func() {
+               serveError <- http.ListenAndServe(":"+s.cfg.HTTPPort, s.router)
+       }()
+
+       // Wait for stop, restart or error signals
+       select {
+       case <-s.stop:
+               // Shutting down permanently
+               s.sessions.Stop()
+               s.log.Infoln("shutting down (stop)")
+       case err = <-serveError:
+               // Error due to listen/serve failure
+               s.log.Errorln(err)
+       }
+
+       return nil
+}
+
+// Stop web server
+func (s *ServerService) Stop() {
+       close(s.stop)
+}
+
+// Add details in Header
+func (s *ServerService) middlewareXDSDetails() gin.HandlerFunc {
+       return func(c *gin.Context) {
+               c.Header("XDS-Agent-Version", s.cfg.Version)
+               c.Header("XDS-API-Version", s.cfg.APIVersion)
+               c.Next()
+       }
+}
+
+// CORS middleware
+func (s *ServerService) middlewareCORS() gin.HandlerFunc {
+       return func(c *gin.Context) {
+
+               if c.Request.Method == "OPTIONS" {
+                       c.Header("Access-Control-Allow-Origin", "*")
+                       c.Header("Access-Control-Allow-Headers", "Content-Type")
+                       c.Header("Access-Control-Allow-Methods", "POST, DELETE, GET, PUT")
+                       c.Header("Content-Type", "application/json")
+                       c.Header("Access-Control-Max-Age", cookieMaxAge)
+                       c.AbortWithStatus(204)
+                       return
+               }
+
+               c.Next()
+       }
+}
+
+// socketHandler is the handler for the "main" websocket connection
+func (s *ServerService) socketHandler(c *gin.Context) {
+
+       // Retrieve user session
+       sess := s.sessions.Get(c)
+       if sess == nil {
+               c.JSON(500, gin.H{"error": "Cannot retrieve session"})
+               return
+       }
+
+       s.sIOServer.On("connection", func(so socketio.Socket) {
+               s.log.Debugf("WS Connected (SID=%v)", so.Id())
+               s.sessions.UpdateIOSocket(sess.ID, &so)
+
+               so.On("disconnection", func() {
+                       s.log.Debugf("WS disconnected (SID=%v)", so.Id())
+                       s.sessions.UpdateIOSocket(sess.ID, nil)
+               })
+       })
+
+       s.sIOServer.On("error", func(so socketio.Socket, err error) {
+               s.log.Errorf("WS SID=%v Error : %v", so.Id(), err.Error())
+       })
+
+       s.sIOServer.ServeHTTP(c.Writer, c.Request)
+}
diff --git a/main.go b/main.go
new file mode 100644 (file)
index 0000000..93d13a2
--- /dev/null
+++ b/main.go
@@ -0,0 +1,138 @@
+// TODO add Doc
+//
+package main
+
+import (
+       "log"
+       "os"
+       "time"
+
+       "fmt"
+
+       "github.com/Sirupsen/logrus"
+       "github.com/codegangsta/cli"
+       "github.com/iotbzh/xds-agent/lib/agent"
+       "github.com/iotbzh/xds-agent/lib/syncthing"
+       "github.com/iotbzh/xds-agent/lib/xdsconfig"
+       "github.com/iotbzh/xds-agent/lib/xdsserver"
+)
+
+const (
+       appName        = "xds-agent"
+       appDescription = "X(cross) Development System Agent is a web server that allows to remotely cross build applications."
+       appCopyright   = "Apache-2.0"
+       appUsage       = "X(cross) Development System Agent"
+)
+
+var appAuthors = []cli.Author{
+       cli.Author{Name: "Sebastien Douheret", Email: "sebastien@iot.bzh"},
+}
+
+// AppVersion is the version of this application
+var AppVersion = "?.?.?"
+
+// AppSubVersion is the git tag id added to version string
+// Should be set by compilation -ldflags "-X main.AppSubVersion=xxx"
+var AppSubVersion = "unknown-dev"
+
+// xdsAgent main routine
+func xdsAgent(cliCtx *cli.Context) error {
+
+       // Create Agent context
+       ctx := agent.NewAgent(cliCtx)
+
+       // Load config
+       cfg, err := xdsconfig.Init(cliCtx, ctx.Log)
+       if err != nil {
+               return cli.NewExitError(err, 2)
+       }
+       ctx.Config = &cfg
+
+       // Start local instance of Syncthing and Syncthing-notify
+       ctx.SThg = st.NewSyncThing(ctx.Config.FileConf.SThgConf, ctx.Log)
+
+       ctx.Log.Infof("Starting Syncthing...")
+       ctx.SThgCmd, err = ctx.SThg.Start()
+       if err != nil {
+               return cli.NewExitError(err, 2)
+       }
+       ctx.Log.Infof("Syncthing started (PID %d)", ctx.SThgCmd.Process.Pid)
+
+       ctx.Log.Infof("Starting Syncthing-inotify...")
+       ctx.SThgInotCmd, err = ctx.SThg.StartInotify()
+       if err != nil {
+               return cli.NewExitError(err, 2)
+       }
+       ctx.Log.Infof("Syncthing-inotify started (PID %d)", ctx.SThgInotCmd.Process.Pid)
+
+       // Establish connection with local Syncthing (retry if connection fail)
+       time.Sleep(3 * time.Second)
+       retry := 10
+       for retry > 0 {
+               if err := ctx.SThg.Connect(); err == nil {
+                       break
+               }
+               ctx.Log.Infof("Establishing connection to Syncthing (retry %d/5)", retry)
+               time.Sleep(time.Second)
+               retry--
+       }
+       if ctx.SThg == nil {
+               err = fmt.Errorf("ERROR: cannot connect to Syncthing (url: %s)", ctx.SThg.BaseURL)
+               return cli.NewExitError(err, 2)
+       }
+
+       // Retrieve Syncthing config
+       id, err := ctx.SThg.IDGet()
+       if err != nil {
+               return cli.NewExitError(err, 2)
+       }
+       ctx.Log.Infof("Local Syncthing ID: %s", id)
+
+       // Create and start Web Server
+       ctx.WWWServer = xdsserver.NewServer(ctx.Config, ctx.Log)
+       if err = ctx.WWWServer.Serve(); err != nil {
+               log.Println(err)
+               return cli.NewExitError(err, 3)
+       }
+
+       return cli.NewExitError("Program exited ", 4)
+}
+
+// main
+func main() {
+
+       // Create a new instance of the logger
+       log := logrus.New()
+
+       // Create a new App instance
+       app := cli.NewApp()
+       app.Name = appName
+       app.Description = appDescription
+       app.Usage = appUsage
+       app.Version = AppVersion + " (" + AppSubVersion + ")"
+       app.Authors = appAuthors
+       app.Copyright = appCopyright
+       app.Metadata = make(map[string]interface{})
+       app.Metadata["version"] = AppVersion
+       app.Metadata["git-tag"] = AppSubVersion
+       app.Metadata["logger"] = log
+
+       app.Flags = []cli.Flag{
+               cli.StringFlag{
+                       Name:   "config, c",
+                       Usage:  "JSON config file to use\n\t",
+                       EnvVar: "XDS_CONFIGFILE",
+               },
+               cli.StringFlag{
+                       Name:   "log, l",
+                       Value:  "error",
+                       Usage:  "logging level (supported levels: panic, fatal, error, warn, info, debug)\n\t",
+                       EnvVar: "LOG_LEVEL",
+               },
+       }
+
+       // only one action
+       app.Action = xdsAgent
+
+       app.Run(os.Args)
+}
diff --git a/scripts/get-syncthing.sh b/scripts/get-syncthing.sh
new file mode 100755 (executable)
index 0000000..284c58e
--- /dev/null
@@ -0,0 +1,40 @@
+#!/bin/bash
+
+# Configurable variables
+[ -z "$SYNCTHING_VERSION" ] && SYNCTHING_VERSION=0.14.25
+[ -z "$SYNCTHING_INOTIFY_VERSION" ] && SYNCTHING_INOTIFY_VERSION=0.8.5
+[ -z "$DESTDIR" ] && DESTDIR=/usr/local/bin
+[ -z "$TMPDIR" ] && TMPDIR=/tmp
+
+
+TEMPDIR=$TMPDIR/.get-st.$$
+mkdir -p ${TEMPDIR} && cd ${TEMPDIR} || exit 1
+trap "cleanExit" 0 1 2 15
+cleanExit ()
+{
+   rm -rf ${TEMPDIR}
+}
+
+echo "Get Syncthing..."
+
+## Install Syncthing + Syncthing-inotify
+## gpg: key 00654A3E: public key "Syncthing Release Management <release@syncthing.net>" imported
+gpg -q --keyserver pool.sks-keyservers.net --recv-keys 37C84554E7E0A261E4F76E1ED26E6ED000654A3E || exit 1
+
+tarball="syncthing-linux-amd64-v${SYNCTHING_VERSION}.tar.gz" \
+       && curl -sfSL "https://github.com/syncthing/syncthing/releases/download/v${SYNCTHING_VERSION}/${tarball}" -O \
+    && curl -sfSL "https://github.com/syncthing/syncthing/releases/download/v${SYNCTHING_VERSION}/sha1sum.txt.asc" -O \
+       && gpg -q --verify sha1sum.txt.asc \
+       && grep -E " ${tarball}\$" sha1sum.txt.asc | sha1sum -c - \
+       && rm sha1sum.txt.asc \
+       && tar -xvf "$tarball" --strip-components=1 "$(basename "$tarball" .tar.gz)"/syncthing \
+       && mv syncthing ${DESTDIR}/syncthing
+
+
+echo "Get Syncthing-inotify..."
+tarball="syncthing-inotify-linux-amd64-v${SYNCTHING_INOTIFY_VERSION}.tar.gz" \
+    && curl -sfSL "https://github.com/syncthing/syncthing-inotify/releases/download/v${SYNCTHING_INOTIFY_VERSION}/${tarball}" -O \
+    && tar -xvf "${tarball}" syncthing-inotify \
+       && mv syncthing-inotify ${DESTDIR}/syncthing-inotify
+
+echo "DONE: syncthing and syncthing-inotify successfuly installed in ${DESTDIR}"
\ No newline at end of file