diff options
Diffstat (limited to 'lib')
-rw-r--r-- | lib/apiv1/apiv1.go | 49 | ||||
-rw-r--r-- | lib/apiv1/config.go | 45 | ||||
-rw-r--r-- | lib/apiv1/exec.go | 154 | ||||
-rw-r--r-- | lib/apiv1/folders.go | 77 | ||||
-rw-r--r-- | lib/apiv1/make.go | 151 | ||||
-rw-r--r-- | lib/apiv1/version.go | 24 | ||||
-rw-r--r-- | lib/common/error.go | 13 | ||||
-rw-r--r-- | lib/common/execPipeWs.go | 148 | ||||
-rw-r--r-- | lib/common/httpclient.go | 221 | ||||
-rw-r--r-- | lib/session/session.go | 227 | ||||
-rw-r--r-- | lib/syncthing/st.go | 76 | ||||
-rw-r--r-- | lib/syncthing/stfolder.go | 116 | ||||
-rw-r--r-- | lib/xdsconfig/builderconfig.go | 50 | ||||
-rw-r--r-- | lib/xdsconfig/config.go | 231 | ||||
-rw-r--r-- | lib/xdsconfig/fileconfig.go | 133 | ||||
-rw-r--r-- | lib/xdsconfig/folderconfig.go | 79 | ||||
-rw-r--r-- | lib/xdsconfig/foldersconfig.go | 47 | ||||
-rw-r--r-- | lib/xdsserver/server.go | 189 |
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) +} |