summaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/apiv1/apiv1.go49
-rw-r--r--lib/apiv1/config.go45
-rw-r--r--lib/apiv1/exec.go154
-rw-r--r--lib/apiv1/folders.go77
-rw-r--r--lib/apiv1/make.go151
-rw-r--r--lib/apiv1/version.go24
-rw-r--r--lib/common/error.go13
-rw-r--r--lib/common/execPipeWs.go148
-rw-r--r--lib/common/httpclient.go221
-rw-r--r--lib/session/session.go227
-rw-r--r--lib/syncthing/st.go76
-rw-r--r--lib/syncthing/stfolder.go116
-rw-r--r--lib/xdsconfig/builderconfig.go50
-rw-r--r--lib/xdsconfig/config.go231
-rw-r--r--lib/xdsconfig/fileconfig.go133
-rw-r--r--lib/xdsconfig/folderconfig.go79
-rw-r--r--lib/xdsconfig/foldersconfig.go47
-rw-r--r--lib/xdsserver/server.go189
18 files changed, 2030 insertions, 0 deletions
diff --git a/lib/apiv1/apiv1.go b/lib/apiv1/apiv1.go
new file mode 100644
index 0000000..56c7503
--- /dev/null
+++ b/lib/apiv1/apiv1.go
@@ -0,0 +1,49 @@
+package apiv1
+
+import (
+ "github.com/Sirupsen/logrus"
+ "github.com/gin-gonic/gin"
+
+ "github.com/iotbzh/xds-server/lib/session"
+ "github.com/iotbzh/xds-server/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, cfg xdsconfig.Config, r *gin.Engine) *APIService {
+ s := &APIService{
+ router: r,
+ sessions: sess,
+ apiRouter: r.Group("/api/v1"),
+ cfg: cfg,
+ log: cfg.Log,
+ }
+
+ s.apiRouter.GET("/version", s.getVersion)
+
+ s.apiRouter.GET("/config", s.getConfig)
+ s.apiRouter.POST("/config", s.setConfig)
+
+ s.apiRouter.GET("/folders", s.getFolders)
+ s.apiRouter.GET("/folder/:id", s.getFolder)
+ s.apiRouter.POST("/folder", s.addFolder)
+ s.apiRouter.DELETE("/folder/:id", s.delFolder)
+
+ s.apiRouter.POST("/make", s.buildMake)
+ s.apiRouter.POST("/make/:id", s.buildMake)
+
+ /* TODO: to be tested and then enabled
+ s.apiRouter.POST("/exec", s.execCmd)
+ s.apiRouter.POST("/exec/:id", s.execCmd)
+ */
+
+ return s
+}
diff --git a/lib/apiv1/config.go b/lib/apiv1/config.go
new file mode 100644
index 0000000..a2817a0
--- /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-server/lib/common"
+ "github.com/iotbzh/xds-server/lib/xdsconfig"
+)
+
+var confMut sync.Mutex
+
+// GetConfig returns server configuration
+func (s *APIService) getConfig(c *gin.Context) {
+ confMut.Lock()
+ defer confMut.Unlock()
+
+ c.JSON(http.StatusOK, s.cfg)
+}
+
+// SetConfig sets server 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/exec.go b/lib/apiv1/exec.go
new file mode 100644
index 0000000..f7beea6
--- /dev/null
+++ b/lib/apiv1/exec.go
@@ -0,0 +1,154 @@
+package apiv1
+
+import (
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/iotbzh/xds-server/lib/common"
+)
+
+// ExecArgs JSON parameters of /exec command
+type ExecArgs struct {
+ ID string `json:"id"`
+ RPath string `json:"rpath"` // relative path into project
+ Cmd string `json:"cmd" binding:"required"`
+ Args []string `json:"args"`
+ CmdTimeout int `json:"timeout"` // command completion timeout in Second
+}
+
+// ExecOutMsg Message send on each output (stdout+stderr) of executed command
+type ExecOutMsg struct {
+ CmdID string `json:"cmdID"`
+ Timestamp string `json:timestamp`
+ Stdout string `json:"stdout"`
+ Stderr string `json:"stderr"`
+}
+
+// ExecExitMsg Message send when executed command exited
+type ExecExitMsg struct {
+ CmdID string `json:"cmdID"`
+ Timestamp string `json:timestamp`
+ Code int `json:"code"`
+ Error error `json:"error"`
+}
+
+// Event name send in WS
+const ExecOutEvent = "exec:output"
+const ExecExitEvent = "exec:exit"
+
+var execCommandID = 1
+
+// ExecCmd executes remotely a command
+func (s *APIService) execCmd(c *gin.Context) {
+ var args ExecArgs
+ if c.BindJSON(&args) != nil {
+ common.APIError(c, "Invalid arguments")
+ return
+ }
+
+ // TODO: add permission
+
+ // Retrieve session info
+ sess := s.sessions.Get(c)
+ if sess == nil {
+ common.APIError(c, "Unknown sessions")
+ return
+ }
+ sop := sess.IOSocket
+ if sop == nil {
+ common.APIError(c, "Websocket not established")
+ return
+ }
+
+ // Allow to pass id in url (/exec/:id) or as JSON argument
+ id := c.Param("id")
+ if id == "" {
+ id = args.ID
+ }
+ if id == "" {
+ common.APIError(c, "Invalid id")
+ return
+ }
+
+ prj := s.cfg.GetFolderFromID(id)
+ if prj == nil {
+ common.APIError(c, "Unknown id")
+ return
+ }
+
+ execTmo := args.CmdTimeout
+ if execTmo == 0 {
+ // TODO get default timeout from config.json file
+ execTmo = 24 * 60 * 60 // 1 day
+ }
+
+ // Define callback for output
+ var oCB common.EmitOutputCB
+ oCB = func(sid string, id int, stdout, stderr string) {
+ // IO socket can be nil when disconnected
+ so := s.sessions.IOSocketGet(sid)
+ if so == nil {
+ s.log.Infof("%s not emitted: WS closed - sid: %s - msg id:%d", ExecOutEvent, sid, id)
+ return
+ }
+ s.log.Debugf("%s emitted - WS sid %s - id:%d", ExecOutEvent, sid, id)
+
+ // FIXME replace by .BroadcastTo a room
+ err := (*so).Emit(ExecOutEvent, ExecOutMsg{
+ CmdID: strconv.Itoa(id),
+ Timestamp: time.Now().String(),
+ Stdout: stdout,
+ Stderr: stderr,
+ })
+ if err != nil {
+ s.log.Errorf("WS Emit : %v", err)
+ }
+ }
+
+ // Define callback for output
+ eCB := func(sid string, id int, code int, err error) {
+ s.log.Debugf("Command [Cmd ID %d] exited: code %d, error: %v", id, code, err)
+
+ // IO socket can be nil when disconnected
+ so := s.sessions.IOSocketGet(sid)
+ if so == nil {
+ s.log.Infof("%s not emitted - WS closed (id:%d", ExecExitEvent, id)
+ return
+ }
+
+ // FIXME replace by .BroadcastTo a room
+ e := (*so).Emit(ExecExitEvent, ExecExitMsg{
+ CmdID: strconv.Itoa(id),
+ Timestamp: time.Now().String(),
+ Code: code,
+ Error: err,
+ })
+ if e != nil {
+ s.log.Errorf("WS Emit : %v", e)
+ }
+ }
+
+ cmdID := execCommandID
+ execCommandID++
+
+ cmd := "cd " + prj.GetFullPath(args.RPath) + " && " + args.Cmd
+ if len(args.Args) > 0 {
+ cmd += " " + strings.Join(args.Args, " ")
+ }
+
+ s.log.Debugf("Execute [Cmd ID %d]: %v %v", cmdID, cmd)
+ err := common.ExecPipeWs(cmd, sop, sess.ID, cmdID, execTmo, s.log, oCB, eCB)
+ if err != nil {
+ common.APIError(c, err.Error())
+ return
+ }
+
+ c.JSON(http.StatusOK,
+ gin.H{
+ "status": "OK",
+ "cmdID": cmdID,
+ })
+}
diff --git a/lib/apiv1/folders.go b/lib/apiv1/folders.go
new file mode 100644
index 0000000..b1864a2
--- /dev/null
+++ b/lib/apiv1/folders.go
@@ -0,0 +1,77 @@
+package apiv1
+
+import (
+ "net/http"
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+ "github.com/iotbzh/xds-server/lib/common"
+ "github.com/iotbzh/xds-server/lib/xdsconfig"
+)
+
+// getFolders returns all folders configuration
+func (s *APIService) getFolders(c *gin.Context) {
+ confMut.Lock()
+ defer confMut.Unlock()
+
+ c.JSON(http.StatusOK, s.cfg.Folders)
+}
+
+// getFolder returns a specific folder configuration
+func (s *APIService) getFolder(c *gin.Context) {
+ id, err := strconv.Atoi(c.Param("id"))
+ if err != nil || id < 0 || id > len(s.cfg.Folders) {
+ common.APIError(c, "Invalid id")
+ return
+ }
+
+ confMut.Lock()
+ defer confMut.Unlock()
+
+ c.JSON(http.StatusOK, s.cfg.Folders[id])
+}
+
+// addFolder adds a new folder to server config
+func (s *APIService) addFolder(c *gin.Context) {
+ var cfgArg xdsconfig.FolderConfig
+ if c.BindJSON(&cfgArg) != nil {
+ common.APIError(c, "Invalid arguments")
+ return
+ }
+
+ confMut.Lock()
+ defer confMut.Unlock()
+
+ s.log.Debugln("Add folder config: ", cfgArg)
+
+ newFld, err := s.cfg.UpdateFolder(cfgArg)
+ if err != nil {
+ common.APIError(c, err.Error())
+ return
+ }
+
+ c.JSON(http.StatusOK, newFld)
+}
+
+// delFolder deletes folder from server config
+func (s *APIService) delFolder(c *gin.Context) {
+ id := c.Param("id")
+ if id == "" {
+ common.APIError(c, "Invalid id")
+ return
+ }
+
+ confMut.Lock()
+ defer confMut.Unlock()
+
+ s.log.Debugln("Delete folder id ", id)
+
+ var delEntry xdsconfig.FolderConfig
+ var err error
+ if delEntry, err = s.cfg.DeleteFolder(id); err != nil {
+ common.APIError(c, err.Error())
+ return
+ }
+ c.JSON(http.StatusOK, delEntry)
+
+}
diff --git a/lib/apiv1/make.go b/lib/apiv1/make.go
new file mode 100644
index 0000000..eac6210
--- /dev/null
+++ b/lib/apiv1/make.go
@@ -0,0 +1,151 @@
+package apiv1
+
+import (
+ "net/http"
+
+ "time"
+
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+ "github.com/iotbzh/xds-server/lib/common"
+)
+
+// MakeArgs is the parameters (json format) of /make command
+type MakeArgs struct {
+ ID string `json:"id"`
+ RPath string `json:"rpath"` // relative path into project
+ Args string `json:"args"`
+ CmdTimeout int `json:"timeout"` // command completion timeout in Second
+}
+
+// MakeOutMsg Message send on each output (stdout+stderr) of make command
+type MakeOutMsg struct {
+ CmdID string `json:"cmdID"`
+ Timestamp string `json:timestamp`
+ Stdout string `json:"stdout"`
+ Stderr string `json:"stderr"`
+}
+
+// MakeExitMsg Message send on make command exit
+type MakeExitMsg struct {
+ CmdID string `json:"cmdID"`
+ Timestamp string `json:timestamp`
+ Code int `json:"code"`
+ Error error `json:"error"`
+}
+
+// Event name send in WS
+const MakeOutEvent = "make:output"
+const MakeExitEvent = "make:exit"
+
+var makeCommandID = 1
+
+func (s *APIService) buildMake(c *gin.Context) {
+ var args MakeArgs
+
+ if c.BindJSON(&args) != nil {
+ common.APIError(c, "Invalid arguments")
+ return
+ }
+
+ sess := s.sessions.Get(c)
+ if sess == nil {
+ common.APIError(c, "Unknown sessions")
+ return
+ }
+ sop := sess.IOSocket
+ if sop == nil {
+ common.APIError(c, "Websocket not established")
+ return
+ }
+
+ // Allow to pass id in url (/make/:id) or as JSON argument
+ id := c.Param("id")
+ if id == "" {
+ id = args.ID
+ }
+ if id == "" {
+ common.APIError(c, "Invalid id")
+ return
+ }
+
+ prj := s.cfg.GetFolderFromID(id)
+ if prj == nil {
+ common.APIError(c, "Unknown id")
+ return
+ }
+
+ execTmo := args.CmdTimeout
+ if execTmo == 0 {
+ // TODO get default timeout from config.json file
+ execTmo = 24 * 60 * 60 // 1 day
+ }
+
+ cmd := "cd " + prj.GetFullPath(args.RPath) + " && make"
+ if args.Args != "" {
+ cmd += " " + args.Args
+ }
+
+ // Define callback for output
+ var oCB common.EmitOutputCB
+ oCB = func(sid string, id int, stdout, stderr string) {
+ // IO socket can be nil when disconnected
+ so := s.sessions.IOSocketGet(sid)
+ if so == nil {
+ s.log.Infof("%s not emitted: WS closed - sid: %s - msg id:%d", MakeOutEvent, sid, id)
+ return
+ }
+ s.log.Debugf("%s emitted - WS sid %s - id:%d", MakeOutEvent, sid, id)
+
+ // FIXME replace by .BroadcastTo a room
+ err := (*so).Emit(MakeOutEvent, MakeOutMsg{
+ CmdID: strconv.Itoa(id),
+ Timestamp: time.Now().String(),
+ Stdout: stdout,
+ Stderr: stderr,
+ })
+ if err != nil {
+ s.log.Errorf("WS Emit : %v", err)
+ }
+ }
+
+ // Define callback for output
+ eCB := func(sid string, id int, code int, err error) {
+ s.log.Debugf("Command [Cmd ID %d] exited: code %d, error: %v", id, code, err)
+
+ // IO socket can be nil when disconnected
+ so := s.sessions.IOSocketGet(sid)
+ if so == nil {
+ s.log.Infof("%s not emitted - WS closed (id:%d", MakeExitEvent, id)
+ return
+ }
+
+ // FIXME replace by .BroadcastTo a room
+ e := (*so).Emit(MakeExitEvent, MakeExitMsg{
+ CmdID: strconv.Itoa(id),
+ Timestamp: time.Now().String(),
+ Code: code,
+ Error: err,
+ })
+ if e != nil {
+ s.log.Errorf("WS Emit : %v", e)
+ }
+ }
+
+ cmdID := makeCommandID
+ makeCommandID++
+
+ s.log.Debugf("Execute [Cmd ID %d]: %v", cmdID, cmd)
+ err := common.ExecPipeWs(cmd, sop, sess.ID, cmdID, execTmo, s.log, oCB, eCB)
+ if err != nil {
+ common.APIError(c, err.Error())
+ return
+ }
+
+ c.JSON(http.StatusOK,
+ gin.H{
+ "status": "OK",
+ "cmdID": cmdID,
+ })
+}
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/execPipeWs.go b/lib/common/execPipeWs.go
new file mode 100644
index 0000000..3b63cdc
--- /dev/null
+++ b/lib/common/execPipeWs.go
@@ -0,0 +1,148 @@
+package common
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "os"
+ "time"
+
+ "syscall"
+
+ "github.com/Sirupsen/logrus"
+ "github.com/googollee/go-socket.io"
+)
+
+// EmitOutputCB is the function callback used to emit data
+type EmitOutputCB func(sid string, cmdID int, stdout, stderr string)
+
+// EmitExitCB is the function callback used to emit exit proc code
+type EmitExitCB func(sid string, cmdID int, code int, err error)
+
+// Inspired by :
+// https://github.com/gorilla/websocket/blob/master/examples/command/main.go
+
+// ExecPipeWs executes a command and redirect stdout/stderr into a WebSocket
+func ExecPipeWs(cmd string, so *socketio.Socket, sid string, cmdID int,
+ cmdExecTimeout int, log *logrus.Logger, eoCB EmitOutputCB, eeCB EmitExitCB) error {
+
+ outr, outw, err := os.Pipe()
+ if err != nil {
+ return fmt.Errorf("Pipe stdout error: " + err.Error())
+ }
+
+ // XXX - do we need to pipe stdin one day ?
+ inr, inw, err := os.Pipe()
+ if err != nil {
+ outr.Close()
+ outw.Close()
+ return fmt.Errorf("Pipe stdin error: " + err.Error())
+ }
+
+ bashArgs := []string{"/bin/bash", "-c", cmd}
+ proc, err := os.StartProcess("/bin/bash", bashArgs, &os.ProcAttr{
+ Files: []*os.File{inr, outw, outw},
+ })
+ if err != nil {
+ outr.Close()
+ outw.Close()
+ inr.Close()
+ inw.Close()
+ return fmt.Errorf("Process start error: " + err.Error())
+ }
+
+ go func() {
+ defer outr.Close()
+ defer outw.Close()
+ defer inr.Close()
+ defer inw.Close()
+
+ stdoutDone := make(chan struct{})
+ go cmdPumpStdout(so, outr, stdoutDone, sid, cmdID, log, eoCB)
+
+ // Blocking function that poll input or wait for end of process
+ cmdPumpStdin(so, inw, proc, sid, cmdID, cmdExecTimeout, log, eeCB)
+
+ // Some commands will exit when stdin is closed.
+ inw.Close()
+
+ defer outr.Close()
+
+ if status, err := proc.Wait(); err == nil {
+ // Other commands need a bonk on the head.
+ if !status.Exited() {
+ if err := proc.Signal(os.Interrupt); err != nil {
+ log.Errorln("Proc interrupt:", err)
+ }
+
+ select {
+ case <-stdoutDone:
+ case <-time.After(time.Second):
+ // A bigger bonk on the head.
+ if err := proc.Signal(os.Kill); err != nil {
+ log.Errorln("Proc term:", err)
+ }
+ <-stdoutDone
+ }
+ }
+ }
+ }()
+
+ return nil
+}
+
+func cmdPumpStdin(so *socketio.Socket, w io.Writer, proc *os.Process,
+ sid string, cmdID int, tmo int, log *logrus.Logger, exitFuncCB EmitExitCB) {
+ /* XXX - code to add to support stdin through WS
+ for {
+ _, message, err := so. ?? ReadMessage()
+ if err != nil {
+ break
+ }
+ message = append(message, '\n')
+ if _, err := w.Write(message); err != nil {
+ break
+ }
+ }
+ */
+
+ // Monitor process exit
+ type DoneChan struct {
+ status int
+ err error
+ }
+ done := make(chan DoneChan, 1)
+ go func() {
+ status := 0
+ sts, err := proc.Wait()
+ if !sts.Success() {
+ s := sts.Sys().(syscall.WaitStatus)
+ status = s.ExitStatus()
+ }
+ done <- DoneChan{status, err}
+ }()
+
+ // Wait cmd complete
+ select {
+ case dC := <-done:
+ exitFuncCB(sid, cmdID, dC.status, dC.err)
+ case <-time.After(time.Duration(tmo) * time.Second):
+ exitFuncCB(sid, cmdID, -99,
+ fmt.Errorf("Exit Timeout for command ID %v", cmdID))
+ }
+}
+
+func cmdPumpStdout(so *socketio.Socket, r io.Reader, done chan struct{},
+ sid string, cmdID int, log *logrus.Logger, emitFuncCB EmitOutputCB) {
+ defer func() {
+ }()
+
+ sc := bufio.NewScanner(r)
+ for sc.Scan() {
+ emitFuncCB(sid, cmdID, string(sc.Bytes()), "")
+ }
+ if sc.Err() != nil {
+ log.Errorln("scan:", sc.Err())
+ }
+ close(done)
+}
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..35dfdc6
--- /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-sid"
+const sessionHeaderName = "XDS-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..7d07b70
--- /dev/null
+++ b/lib/syncthing/st.go
@@ -0,0 +1,76 @@
+package st
+
+import (
+ "encoding/json"
+
+ "strings"
+
+ "fmt"
+
+ "github.com/Sirupsen/logrus"
+ "github.com/iotbzh/xds-server/lib/common"
+ "github.com/syncthing/syncthing/lib/config"
+)
+
+// SyncThing .
+type SyncThing struct {
+ BaseURL string
+ client *common.HTTPClient
+ log *logrus.Logger
+}
+
+// NewSyncThing creates a new instance of Syncthing
+func NewSyncThing(url string, apikey string, log *logrus.Logger) *SyncThing {
+ cl, err := common.HTTPNewClient(url,
+ 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)", url)
+ }
+ log.Debugf("ERROR: cannot connect to Syncthing %s", msg)
+ return nil
+ }
+
+ s := SyncThing{
+ BaseURL: url,
+ client: cl,
+ log: log,
+ }
+
+ return &s
+}
+
+// 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/builderconfig.go b/lib/xdsconfig/builderconfig.go
new file mode 100644
index 0000000..c64fe9c
--- /dev/null
+++ b/lib/xdsconfig/builderconfig.go
@@ -0,0 +1,50 @@
+package xdsconfig
+
+import (
+ "errors"
+ "net"
+)
+
+// BuilderConfig represents the builder container configuration
+type BuilderConfig struct {
+ IP string `json:"ip"`
+ Port string `json:"port"`
+ SyncThingID string `json:"syncThingID"`
+}
+
+// NewBuilderConfig creates a new BuilderConfig instance
+func NewBuilderConfig(stID string) (BuilderConfig, error) {
+ // Do we really need it ? may be not accessible from client side
+ ip, err := getLocalIP()
+ if err != nil {
+ return BuilderConfig{}, err
+ }
+
+ b := BuilderConfig{
+ IP: ip, // TODO currently not used
+ Port: "", // TODO currently not used
+ SyncThingID: stID,
+ }
+ return b, nil
+}
+
+// Copy makes a real copy of BuilderConfig
+func (c *BuilderConfig) Copy(n BuilderConfig) {
+ // TODO
+}
+
+func getLocalIP() (string, error) {
+ addrs, err := net.InterfaceAddrs()
+ if err != nil {
+ return "", err
+ }
+ for _, address := range addrs {
+ // check the address type and if it is not a loopback the display it
+ if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
+ if ipnet.IP.To4() != nil {
+ return ipnet.IP.String(), nil
+ }
+ }
+ }
+ return "", errors.New("Cannot determined local IP")
+}
diff --git a/lib/xdsconfig/config.go b/lib/xdsconfig/config.go
new file mode 100644
index 0000000..df98439
--- /dev/null
+++ b/lib/xdsconfig/config.go
@@ -0,0 +1,231 @@
+package xdsconfig
+
+import (
+ "fmt"
+ "strings"
+
+ "os"
+
+ "time"
+
+ "github.com/Sirupsen/logrus"
+ "github.com/codegangsta/cli"
+ "github.com/iotbzh/xds-server/lib/syncthing"
+)
+
+// Config parameters (json format) of /config command
+type Config struct {
+ Version string `json:"version"`
+ APIVersion string `json:"apiVersion"`
+ VersionGitTag string `json:"gitTag"`
+ Builder BuilderConfig `json:"builder"`
+ Folders FoldersConfig `json:"folders"`
+
+ // Private / un-exported fields
+ progName string
+ fileConf FileConfig
+ WebAppDir string `json:"-"`
+ HTTPPort string `json:"-"`
+ ShareRootDir string `json:"-"`
+ Log *logrus.Logger `json:"-"`
+ SThg *st.SyncThing `json:"-"`
+}
+
+// Config default values
+const (
+ DefaultAPIVersion = "1"
+ DefaultPort = "8000"
+ DefaultShareDir = "/mnt/share"
+ DefaultLogLevel = "error"
+)
+
+// Init loads the configuration on start-up
+func Init(ctx *cli.Context) (Config, error) {
+ var err error
+
+ // Set logger level and formatter
+ log := ctx.App.Metadata["logger"].(*logrus.Logger)
+
+ logLevel := ctx.GlobalString("log")
+ if logLevel == "" {
+ logLevel = 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
+ c := Config{
+ Version: ctx.App.Metadata["version"].(string),
+ APIVersion: DefaultAPIVersion,
+ VersionGitTag: ctx.App.Metadata["git-tag"].(string),
+ Builder: BuilderConfig{},
+ Folders: FoldersConfig{},
+
+ progName: ctx.App.Name,
+ WebAppDir: "webapp/dist",
+ HTTPPort: DefaultPort,
+ ShareRootDir: DefaultShareDir,
+ Log: log,
+ SThg: nil,
+ }
+
+ // config file settings overwrite default config
+ err = updateConfigFromFile(&c, ctx.GlobalString("config"))
+ if err != nil {
+ return Config{}, err
+ }
+
+ // Update location of shared dir if needed
+ if !dirExists(c.ShareRootDir) {
+ if err := os.MkdirAll(c.ShareRootDir, 0770); err != nil {
+ c.Log.Fatalf("No valid shared directory found (err=%v)", err)
+ }
+ }
+ c.Log.Infoln("Share root directory: ", c.ShareRootDir)
+
+ // FIXME - add a builder interface and support other builder type (eg. native)
+ builderType := "syncthing"
+
+ switch builderType {
+ case "syncthing":
+ // Syncthing settings only configurable from config.json file
+ stGuiAddr := c.fileConf.SThgConf.GuiAddress
+ stGuiApikey := c.fileConf.SThgConf.GuiAPIKey
+ if stGuiAddr == "" {
+ stGuiAddr = "http://localhost:8384"
+ }
+ if stGuiAddr[0:7] != "http://" {
+ stGuiAddr = "http://" + stGuiAddr
+ }
+
+ // Retry if connection fail
+ retry := 5
+ for retry > 0 {
+ c.SThg = st.NewSyncThing(stGuiAddr, stGuiApikey, c.Log)
+ if c.SThg != nil {
+ break
+ }
+ c.Log.Warningf("Establishing connection to Syncthing (retry %d/5)", retry)
+ time.Sleep(time.Second)
+ retry--
+ }
+ if c.SThg == nil {
+ c.Log.Fatalf("ERROR: cannot connect to Syncthing (url: %s)", stGuiAddr)
+ }
+
+ // Retrieve Syncthing config
+ id, err := c.SThg.IDGet()
+ if err != nil {
+ return Config{}, err
+ }
+
+ if c.Builder, err = NewBuilderConfig(id); err != nil {
+ c.Log.Fatalln(err)
+ }
+
+ // Retrieve initial Syncthing config
+ stCfg, err := c.SThg.ConfigGet()
+ if err != nil {
+ return Config{}, err
+ }
+ for _, stFld := range stCfg.Folders {
+ relativePath := strings.TrimPrefix(stFld.RawPath, c.ShareRootDir)
+ if relativePath == "" {
+ relativePath = stFld.RawPath
+ }
+ newFld := NewFolderConfig(stFld.ID, stFld.Label, c.ShareRootDir, strings.Trim(relativePath, "/"))
+ c.Folders = c.Folders.Update(FoldersConfig{newFld})
+ }
+
+ default:
+ log.Fatalln("Unsupported builder type")
+ }
+
+ return c, nil
+}
+
+// GetFolderFromID retrieves the Folder config from id
+func (c *Config) GetFolderFromID(id string) *FolderConfig {
+ if idx := c.Folders.GetIdx(id); idx != -1 {
+ return &c.Folders[idx]
+ }
+ return nil
+}
+
+// UpdateAll updates all the current configuration
+func (c *Config) UpdateAll(newCfg Config) error {
+ return fmt.Errorf("Not Supported")
+ /*
+ if err := VerifyConfig(newCfg); err != nil {
+ return err
+ }
+
+ // TODO: c.Builder = c.Builder.Update(newCfg.Builder)
+ c.Folders = c.Folders.Update(newCfg.Folders)
+
+ // SEB A SUP model.NotifyListeners(c, NotifyFoldersChange, FolderConfig{})
+ // FIXME To be tested & improved error handling
+ for _, f := range c.Folders {
+ if err := c.SThg.FolderChange(st.FolderChangeArg{
+ ID: f.ID,
+ Label: f.Label,
+ RelativePath: f.RelativePath,
+ SyncThingID: f.SyncThingID,
+ ShareRootDir: c.ShareRootDir,
+ }); err != nil {
+ return err
+ }
+ }
+
+ return nil
+ */
+}
+
+// UpdateFolder updates a specific folder into the current configuration
+func (c *Config) UpdateFolder(newFolder FolderConfig) (FolderConfig, error) {
+ if err := FolderVerify(newFolder); err != nil {
+ return FolderConfig{}, err
+ }
+
+ c.Folders = c.Folders.Update(FoldersConfig{newFolder})
+
+ // SEB A SUP model.NotifyListeners(c, NotifyFolderAdd, newFolder)
+ err := c.SThg.FolderChange(st.FolderChangeArg{
+ ID: newFolder.ID,
+ Label: newFolder.Label,
+ RelativePath: newFolder.RelativePath,
+ SyncThingID: newFolder.SyncThingID,
+ ShareRootDir: c.ShareRootDir,
+ })
+
+ newFolder.BuilderSThgID = c.Builder.SyncThingID // FIXME - should be removed after local ST config rework
+ newFolder.Status = FolderStatusEnable
+
+ return newFolder, err
+}
+
+// DeleteFolder deletes a specific folder
+func (c *Config) DeleteFolder(id string) (FolderConfig, error) {
+ var fld FolderConfig
+ var err error
+
+ //SEB A SUP model.NotifyListeners(c, NotifyFolderDelete, fld)
+ if err = c.SThg.FolderDelete(id); err != nil {
+ return fld, err
+ }
+
+ c.Folders, fld, err = c.Folders.Delete(id)
+
+ return fld, err
+}
+
+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..262d023
--- /dev/null
+++ b/lib/xdsconfig/fileconfig.go
@@ -0,0 +1,133 @@
+package xdsconfig
+
+import (
+ "encoding/json"
+ "os"
+ "os/user"
+ "path"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+type SyncThingConf struct {
+ Home string `json:"home"`
+ GuiAddress string `json:"gui-address"`
+ GuiAPIKey string `json:"gui-apikey"`
+}
+
+type FileConfig struct {
+ WebAppDir string `json:"webAppDir"`
+ ShareRootDir string `json:"shareRootDir"`
+ 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/config.json file
+// 3/ <xds-server executable dir>/config.json file
+
+func updateConfigFromFile(c *Config, confFile string) 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", "config.json"))
+ }
+ cwd, err := os.Getwd()
+ if err == nil {
+ searchIn = append(searchIn, path.Join(cwd, "config.json"))
+ }
+ exePath, err := filepath.Abs(filepath.Dir(os.Args[0]))
+ if err == nil {
+ searchIn = append(searchIn, path.Join(exePath, "config.json"))
+ }
+
+ var cFile *string
+ for _, p := range searchIn {
+ if _, err := os.Stat(p); err == nil {
+ cFile = &p
+ break
+ }
+ }
+ if cFile == nil {
+ // No config file found
+ return nil
+ }
+
+ // 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()
+ fCfg := FileConfig{}
+ if err := json.NewDecoder(fd).Decode(&fCfg); err != nil {
+ return err
+ }
+ c.fileConf = fCfg
+
+ // Support environment variables (IOW ${MY_ENV_VAR} syntax) in config.json
+ // TODO: better to use reflect package to iterate on fields and be more generic
+ fCfg.WebAppDir = path.Clean(resolveEnvVar(fCfg.WebAppDir))
+ fCfg.ShareRootDir = path.Clean(resolveEnvVar(fCfg.ShareRootDir))
+ fCfg.SThgConf.Home = path.Clean(resolveEnvVar(fCfg.SThgConf.Home))
+
+ // Config file settings overwrite default config
+
+ if fCfg.WebAppDir != "" {
+ c.WebAppDir = strings.Trim(fCfg.WebAppDir, " ")
+ }
+ // Is it a full path ?
+ if !strings.HasPrefix(c.WebAppDir, "/") && exePath != "" {
+ // Check first from current directory
+ for _, rootD := range []string{cwd, exePath} {
+ ff := path.Join(rootD, c.WebAppDir, "index.html")
+ if exists(ff) {
+ c.WebAppDir = path.Join(rootD, c.WebAppDir)
+ break
+ }
+ }
+ }
+
+ if fCfg.ShareRootDir != "" {
+ c.ShareRootDir = fCfg.ShareRootDir
+ }
+
+ if fCfg.HTTPPort != "" {
+ c.HTTPPort = fCfg.HTTPPort
+ }
+
+ return nil
+}
+
+// resolveEnvVar Resolved environment variable regarding the syntax ${MYVAR}
+func resolveEnvVar(s string) string {
+ re := regexp.MustCompile("\\${(.*)}")
+ vars := re.FindAllStringSubmatch(s, -1)
+ res := s
+ for _, v := range vars {
+ val := os.Getenv(v[1])
+ if val != "" {
+ rer := regexp.MustCompile("\\${" + v[1] + "}")
+ res = rer.ReplaceAllString(res, val)
+ }
+ }
+ return res
+}
+
+// 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/xdsconfig/folderconfig.go b/lib/xdsconfig/folderconfig.go
new file mode 100644
index 0000000..e8bff4f
--- /dev/null
+++ b/lib/xdsconfig/folderconfig.go
@@ -0,0 +1,79 @@
+package xdsconfig
+
+import (
+ "fmt"
+ "log"
+ "path/filepath"
+)
+
+// FolderType constances
+const (
+ FolderTypeDocker = 0
+ FolderTypeWindowsSubsystem = 1
+ FolderTypeCloudSync = 2
+
+ FolderStatusErrorConfig = "ErrorConfig"
+ FolderStatusDisable = "Disable"
+ FolderStatusEnable = "Enable"
+)
+
+// FolderType is the type of sharing folder
+type FolderType int
+
+// FolderConfig is the config for one folder
+type FolderConfig struct {
+ ID string `json:"id" binding:"required"`
+ Label string `json:"label"`
+ RelativePath string `json:"path"`
+ Type FolderType `json:"type"`
+ SyncThingID string `json:"syncThingID"`
+ BuilderSThgID string `json:"builderSThgID"`
+ Status string `json:"status"`
+
+ // Private fields
+ rootPath string
+}
+
+// NewFolderConfig creates a new folder object
+func NewFolderConfig(id, label, rootDir, path string) FolderConfig {
+ return FolderConfig{
+ ID: id,
+ Label: label,
+ RelativePath: path,
+ Type: FolderTypeCloudSync,
+ SyncThingID: "",
+ Status: FolderStatusDisable,
+ rootPath: rootDir,
+ }
+}
+
+// GetFullPath returns the full path
+func (c *FolderConfig) GetFullPath(dir string) string {
+ if &dir == nil {
+ dir = ""
+ }
+ if filepath.IsAbs(dir) {
+ return filepath.Join(c.rootPath, dir)
+ }
+ return filepath.Join(c.rootPath, c.RelativePath, dir)
+}
+
+// FolderVerify is called to verify that a configuration is valid
+func FolderVerify(fCfg FolderConfig) error {
+ var err error
+
+ if fCfg.Type != FolderTypeCloudSync {
+ err = fmt.Errorf("Unsupported folder type")
+ }
+
+ if fCfg.SyncThingID == "" {
+ err = fmt.Errorf("device id not set (SyncThingID field)")
+ }
+
+ if err != nil {
+ fCfg.Status = FolderStatusErrorConfig
+ log.Printf("ERROR FolderVerify: %v\n", err)
+ }
+
+ return err
+}
diff --git a/lib/xdsconfig/foldersconfig.go b/lib/xdsconfig/foldersconfig.go
new file mode 100644
index 0000000..4ad16df
--- /dev/null
+++ b/lib/xdsconfig/foldersconfig.go
@@ -0,0 +1,47 @@
+package xdsconfig
+
+import (
+ "fmt"
+)
+
+// FoldersConfig contains all the folder configurations
+type FoldersConfig []FolderConfig
+
+// GetIdx returns the index of the folder matching id in FoldersConfig array
+func (c FoldersConfig) GetIdx(id string) int {
+ for i := range c {
+ if id == c[i].ID {
+ return i
+ }
+ }
+ return -1
+}
+
+// Update is used to fully update or add a new FolderConfig
+func (c FoldersConfig) Update(newCfg FoldersConfig) FoldersConfig {
+ for i := range newCfg {
+ found := false
+ for j := range c {
+ if newCfg[i].ID == c[j].ID {
+ c[j] = newCfg[i]
+ found = true
+ break
+ }
+ }
+ if !found {
+ c = append(c, newCfg[i])
+ }
+ }
+ return c
+}
+
+// Delete is used to delete a folder matching id in FoldersConfig array
+func (c FoldersConfig) Delete(id string) (FoldersConfig, FolderConfig, error) {
+ if idx := c.GetIdx(id); idx != -1 {
+ f := c[idx]
+ c = append(c[:idx], c[idx+1:]...)
+ return c, f, nil
+ }
+
+ return c, FolderConfig{}, fmt.Errorf("invalid id")
+}
diff --git a/lib/xdsserver/server.go b/lib/xdsserver/server.go
new file mode 100644
index 0000000..90d0f38
--- /dev/null
+++ b/lib/xdsserver/server.go
@@ -0,0 +1,189 @@
+package xdsserver
+
+import (
+ "net/http"
+ "os"
+
+ "path"
+
+ "github.com/Sirupsen/logrus"
+ "github.com/gin-contrib/static"
+ "github.com/gin-gonic/gin"
+ "github.com/googollee/go-socket.io"
+ "github.com/iotbzh/xds-server/lib/apiv1"
+ "github.com/iotbzh/xds-server/lib/session"
+ "github.com/iotbzh/xds-server/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(cfg xdsconfig.Config) *ServerService {
+
+ // Setup logging for gin router
+ if cfg.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: cfg,
+ log: cfg.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.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)
+ */
+
+ // Web Application (serve on / )
+ idxFile := path.Join(s.cfg.WebAppDir, indexFilename)
+ if _, err := os.Stat(idxFile); err != nil {
+ s.log.Fatalln("Web app directory not found, check/use webAppDir setting in config file: ", idxFile)
+ }
+ s.log.Infof("Serve WEB app dir: %s", s.cfg.WebAppDir)
+ s.router.Use(static.Serve("/", static.LocalFile(s.cfg.WebAppDir, true)))
+ s.webApp = s.router.Group("/", s.serveIndexFile)
+ {
+ s.webApp.GET("/")
+ }
+
+ // 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)
+}
+
+// serveIndexFile provides initial file (eg. index.html) of webapp
+func (s *ServerService) serveIndexFile(c *gin.Context) {
+ c.HTML(200, indexFilename, gin.H{})
+}
+
+// Add details in Header
+func (s *ServerService) middlewareXDSDetails() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ c.Header("XDS-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)
+}