From bfeab33538d50ee52750de4dd4c0e72b64f674f6 Mon Sep 17 00:00:00 2001 From: Sebastien Douheret Date: Mon, 15 May 2017 11:12:21 +0200 Subject: [PATCH] Initial commit. Signed-off-by: Sebastien Douheret --- .gitignore | 11 ++ .vscode/launch.json | 22 ++++ .vscode/settings.json | 20 ++++ LICENSE | 201 ++++++++++++++++++++++++++++++++++++ Makefile | 109 ++++++++++++++++++++ README.md | 74 ++++++++++++++ agent-config.json.in | 7 ++ glide.yaml | 19 ++++ lib/agent/agent.go | 76 ++++++++++++++ lib/apiv1/apiv1.go | 36 +++++++ lib/apiv1/config.go | 45 ++++++++ lib/apiv1/version.go | 24 +++++ lib/common/error.go | 13 +++ lib/common/httpclient.go | 221 ++++++++++++++++++++++++++++++++++++++++ lib/session/session.go | 227 +++++++++++++++++++++++++++++++++++++++++ lib/syncthing/st.go | 242 ++++++++++++++++++++++++++++++++++++++++++++ lib/syncthing/stfolder.go | 116 +++++++++++++++++++++ lib/xdsconfig/config.go | 65 ++++++++++++ lib/xdsconfig/fileconfig.go | 120 ++++++++++++++++++++++ lib/xdsserver/server.go | 168 ++++++++++++++++++++++++++++++ main.go | 138 +++++++++++++++++++++++++ scripts/get-syncthing.sh | 40 ++++++++ 22 files changed, 1994 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 agent-config.json.in create mode 100644 glide.yaml create mode 100644 lib/agent/agent.go create mode 100644 lib/apiv1/apiv1.go create mode 100644 lib/apiv1/config.go create mode 100644 lib/apiv1/version.go create mode 100644 lib/common/error.go create mode 100644 lib/common/httpclient.go create mode 100644 lib/session/session.go create mode 100644 lib/syncthing/st.go create mode 100644 lib/syncthing/stfolder.go create mode 100644 lib/xdsconfig/config.go create mode 100644 lib/xdsconfig/fileconfig.go create mode 100644 lib/xdsserver/server.go create mode 100644 main.go create mode 100755 scripts/get-syncthing.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b467eb --- /dev/null +++ b/.gitignore @@ -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 index 0000000..ed892d6 --- /dev/null +++ b/.vscode/launch.json @@ -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 index 0000000..a6647f3 --- /dev/null +++ b/.vscode/settings.json @@ -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 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 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 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. `/agent-config.json` file +4. `/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 index 0000000..a0c0ad5 --- /dev/null +++ b/agent-config.json.in @@ -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 index 0000000..794de5d --- /dev/null +++ b/glide.yaml @@ -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 index 0000000..80c97f7 --- /dev/null +++ b/lib/agent/agent.go @@ -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 index 0000000..734929b --- /dev/null +++ b/lib/apiv1/apiv1.go @@ -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 index 0000000..79225f4 --- /dev/null +++ b/lib/apiv1/config.go @@ -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 index 0000000..e022441 --- /dev/null +++ b/lib/apiv1/version.go @@ -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 index 0000000..d03c176 --- /dev/null +++ b/lib/common/error.go @@ -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 index 0000000..40d7bc2 --- /dev/null +++ b/lib/common/httpclient.go @@ -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 index 0000000..af05daa --- /dev/null +++ b/lib/session/session.go @@ -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 index 0000000..e513876 --- /dev/null +++ b/lib/syncthing/st.go @@ -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 index 0000000..d79e579 --- /dev/null +++ b/lib/syncthing/stfolder.go @@ -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 index 0000000..1f53cbd --- /dev/null +++ b/lib/xdsconfig/config.go @@ -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 index 0000000..0c4828c --- /dev/null +++ b/lib/xdsconfig/fileconfig.go @@ -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/ /agent-config.json file +// 4/ /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 index 0000000..f0777e3 --- /dev/null +++ b/lib/xdsserver/server.go @@ -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 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 index 0000000..284c58e --- /dev/null +++ b/scripts/get-syncthing.sh @@ -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 " 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 -- 2.16.6