Always close gdb pty/tty even if an error rises.
[src/xds/xds-server.git] / lib / apiv1 / exec.go
1 package apiv1
2
3 import (
4         "fmt"
5         "net/http"
6         "os"
7         "regexp"
8         "strconv"
9         "strings"
10         "time"
11
12         "github.com/gin-gonic/gin"
13         common "github.com/iotbzh/xds-common/golib"
14         "github.com/iotbzh/xds-common/golib/eows"
15         "github.com/kr/pty"
16 )
17
18 type (
19         // ExecArgs JSON parameters of /exec command
20         ExecArgs struct {
21                 ID              string   `json:"id" binding:"required"`
22                 SdkID           string   `json:"sdkID"` // sdk ID to use for setting env
23                 CmdID           string   `json:"cmdID"` // command unique ID
24                 Cmd             string   `json:"cmd" binding:"required"`
25                 Args            []string `json:"args"`
26                 Env             []string `json:"env"`
27                 RPath           string   `json:"rpath"`           // relative path into project
28                 TTY             bool     `json:"tty"`             // Use a tty, specific to gdb --tty option
29                 TTYGdbserverFix bool     `json:"ttyGdbserverFix"` // Set to true to activate gdbserver workaround about inferior output
30                 ExitImmediate   bool     `json:"exitImmediate"`   // when true, exit event sent immediately when command exited (IOW, don't wait file synchronization)
31                 CmdTimeout      int      `json:"timeout"`         // command completion timeout in Second
32         }
33
34         // ExecInMsg Message used to received input characters (stdin)
35         ExecInMsg struct {
36                 CmdID     string `json:"cmdID"`
37                 Timestamp string `json:"timestamp"`
38                 Stdin     string `json:"stdin"`
39         }
40
41         // ExecOutMsg Message used to send output characters (stdout+stderr)
42         ExecOutMsg struct {
43                 CmdID     string `json:"cmdID"`
44                 Timestamp string `json:"timestamp"`
45                 Stdout    string `json:"stdout"`
46                 Stderr    string `json:"stderr"`
47         }
48
49         // ExecExitMsg Message sent when executed command exited
50         ExecExitMsg struct {
51                 CmdID     string `json:"cmdID"`
52                 Timestamp string `json:"timestamp"`
53                 Code      int    `json:"code"`
54                 Error     error  `json:"error"`
55         }
56
57         // ExecSignalArgs JSON parameters of /exec/signal command
58         ExecSignalArgs struct {
59                 CmdID  string `json:"cmdID" binding:"required"`  // command id
60                 Signal string `json:"signal" binding:"required"` // signal number
61         }
62 )
63
64 const (
65         // ExecInEvent Event send in WS when characters are sent (stdin)
66         ExecInEvent = "exec:input"
67
68         // ExecOutEvent Event send in WS when characters are received (stdout or stderr)
69         ExecOutEvent = "exec:output"
70
71         // ExecExitEvent Event send in WS when program exited
72         ExecExitEvent = "exec:exit"
73
74         // ExecInferiorInEvent Event send in WS when characters are sent to an inferior (used by gdb inferior/tty)
75         ExecInferiorInEvent = "exec:inferior-input"
76
77         // ExecInferiorOutEvent Event send in WS when characters are received by an inferior
78         ExecInferiorOutEvent = "exec:inferior-output"
79 )
80
81 var execCommandID = 1
82
83 // ExecCmd executes remotely a command
84 func (s *APIService) execCmd(c *gin.Context) {
85         var gdbPty, gdbTty *os.File
86         var err error
87         var args ExecArgs
88         if c.BindJSON(&args) != nil {
89                 common.APIError(c, "Invalid arguments")
90                 return
91         }
92
93         // TODO: add permission ?
94
95         // Retrieve session info
96         sess := s.sessions.Get(c)
97         if sess == nil {
98                 common.APIError(c, "Unknown sessions")
99                 return
100         }
101         sop := sess.IOSocket
102         if sop == nil {
103                 common.APIError(c, "Websocket not established")
104                 return
105         }
106
107         // Allow to pass id in url (/exec/:id) or as JSON argument
108         idArg := c.Param("id")
109         if idArg == "" {
110                 idArg = args.ID
111         }
112         if idArg == "" {
113                 common.APIError(c, "Invalid id")
114                 return
115         }
116         id, err := s.mfolders.ResolveID(idArg)
117         if err != nil {
118                 common.APIError(c, err.Error())
119                 return
120         }
121         f := s.mfolders.Get(id)
122         if f == nil {
123                 common.APIError(c, "Unknown id")
124                 return
125         }
126         fld := *f
127         prj := fld.GetConfig()
128
129         // Build command line
130         cmd := []string{}
131         // Setup env var regarding Sdk ID (used for example to setup cross toolchain)
132         if envCmd := s.sdks.GetEnvCmd(args.SdkID, prj.DefaultSdk); len(envCmd) > 0 {
133                 cmd = append(cmd, envCmd...)
134                 cmd = append(cmd, "&&")
135         } else {
136                 // It's an error if no envcmd found while a sdkid has been provided
137                 if args.SdkID != "" {
138                         common.APIError(c, "Unknown sdkid")
139                         return
140                 }
141         }
142
143         cmd = append(cmd, "cd", "\""+fld.GetFullPath(args.RPath)+"\"")
144         // FIXME - add 'exec' prevents to use syntax:
145         //       xds-exec -l debug -c xds-config.env -- "cd build && cmake .."
146         //  but exec is mandatory to allow to pass correctly signals
147         //  As workaround, exec is set for now on client side (eg. in xds-gdb)
148         //cmd = append(cmd, "&&", "exec", args.Cmd)
149         cmd = append(cmd, "&&", args.Cmd)
150
151         // Process command arguments
152         cmdArgs := make([]string, len(args.Args)+1)
153
154         // Copy and Translate path from client to server
155         for _, aa := range args.Args {
156                 if strings.Contains(aa, prj.ClientPath) {
157                         cmdArgs = append(cmdArgs, fld.ConvPathCli2Svr(aa))
158                 } else {
159                         cmdArgs = append(cmdArgs, aa)
160                 }
161         }
162
163         // Allocate pts if tty if used
164         if args.TTY {
165                 gdbPty, gdbTty, err = pty.Open()
166                 if err != nil {
167                         common.APIError(c, err.Error())
168                         return
169                 }
170
171                 s.log.Debugf("Client command tty: %v %v\n", gdbTty.Name(), gdbTty.Name())
172                 cmdArgs = append(cmdArgs, "--tty="+gdbTty.Name())
173         }
174
175         // Unique ID for each commands
176         if args.CmdID == "" {
177                 args.CmdID = s.cfg.ServerUID[:18] + "_" + strconv.Itoa(execCommandID)
178                 execCommandID++
179         }
180
181         // Create new execution over WS context
182         execWS := eows.New(strings.Join(cmd, " "), cmdArgs, sop, sess.ID, args.CmdID)
183         execWS.Log = s.log
184
185         // Append client project dir to environment
186         execWS.Env = append(args.Env, "CLIENT_PROJECT_DIR="+prj.ClientPath)
187
188         // Set command execution timeout
189         if args.CmdTimeout == 0 {
190                 // 0 : default timeout
191                 // TODO get default timeout from config.json file
192                 execWS.CmdExecTimeout = 24 * 60 * 60 // 1 day
193         } else {
194                 execWS.CmdExecTimeout = args.CmdTimeout
195         }
196
197         // Define callback for input (stdin)
198         execWS.InputEvent = ExecInEvent
199         execWS.InputCB = func(e *eows.ExecOverWS, stdin string) (string, error) {
200                 s.log.Debugf("STDIN <<%v>>", strings.Replace(stdin, "\n", "\\n", -1))
201
202                 // Handle Ctrl-D
203                 if len(stdin) == 1 && stdin == "\x04" {
204                         // Close stdin
205                         errMsg := fmt.Errorf("close stdin: %v", stdin)
206                         return "", errMsg
207                 }
208
209                 // Set correct path
210                 data := e.UserData
211                 prjID := (*data)["ID"].(string)
212                 f := s.mfolders.Get(prjID)
213                 if f == nil {
214                         s.log.Errorf("InputCB: Cannot get folder ID %s", prjID)
215                 } else {
216                         // Translate paths from client to server
217                         stdin = (*f).ConvPathCli2Svr(stdin)
218                 }
219
220                 return stdin, nil
221         }
222
223         // Define callback for output (stdout+stderr)
224         execWS.OutputCB = func(e *eows.ExecOverWS, stdout, stderr string) {
225                 // IO socket can be nil when disconnected
226                 so := s.sessions.IOSocketGet(e.Sid)
227                 if so == nil {
228                         s.log.Infof("%s not emitted: WS closed (sid:%s, msgid:%s)", ExecOutEvent, e.Sid, e.CmdID)
229                         return
230                 }
231
232                 // Retrieve project ID and RootPath
233                 data := e.UserData
234                 prjID := (*data)["ID"].(string)
235                 gdbServerTTY := (*data)["gdbServerTTY"].(string)
236
237                 f := s.mfolders.Get(prjID)
238                 if f == nil {
239                         s.log.Errorf("OutputCB: Cannot get folder ID %s", prjID)
240                 } else {
241                         // Translate paths from server to client
242                         stdout = (*f).ConvPathSvr2Cli(stdout)
243                         stderr = (*f).ConvPathSvr2Cli(stderr)
244                 }
245
246                 s.log.Debugf("%s emitted - WS sid[4:] %s - id:%s - prjID:%s", ExecOutEvent, e.Sid[4:], e.CmdID, prjID)
247                 if stdout != "" {
248                         s.log.Debugf("STDOUT <<%v>>", strings.Replace(stdout, "\n", "\\n", -1))
249                 }
250                 if stderr != "" {
251                         s.log.Debugf("STDERR <<%v>>", strings.Replace(stderr, "\n", "\\n", -1))
252                 }
253
254                 // FIXME replace by .BroadcastTo a room
255                 err := (*so).Emit(ExecOutEvent, ExecOutMsg{
256                         CmdID:     e.CmdID,
257                         Timestamp: time.Now().String(),
258                         Stdout:    stdout,
259                         Stderr:    stderr,
260                 })
261                 if err != nil {
262                         s.log.Errorf("WS Emit : %v", err)
263                 }
264
265                 // XXX - Workaround due to gdbserver bug that doesn't redirect
266                 // inferior output (https://bugs.eclipse.org/bugs/show_bug.cgi?id=437532#c13)
267                 if gdbServerTTY == "workaround" && len(stdout) > 1 && stdout[0] == '&' {
268
269                         // Extract and cleanup string like &"bla bla\n"
270                         re := regexp.MustCompile("&\"(.*)\"")
271                         rer := re.FindAllStringSubmatch(stdout, -1)
272                         out := ""
273                         if rer != nil && len(rer) > 0 {
274                                 for _, o := range rer {
275                                         if len(o) >= 1 {
276                                                 out = strings.Replace(o[1], "\\n", "\n", -1)
277                                                 out = strings.Replace(out, "\\r", "\r", -1)
278                                                 out = strings.Replace(out, "\\t", "\t", -1)
279
280                                                 s.log.Debugf("STDOUT INFERIOR: <<%v>>", out)
281                                                 err := (*so).Emit(ExecInferiorOutEvent, ExecOutMsg{
282                                                         CmdID:     e.CmdID,
283                                                         Timestamp: time.Now().String(),
284                                                         Stdout:    out,
285                                                         Stderr:    "",
286                                                 })
287                                                 if err != nil {
288                                                         s.log.Errorf("WS Emit : %v", err)
289                                                 }
290                                         }
291                                 }
292                         } else {
293                                 s.log.Errorf("INFERIOR out parsing error: stdout=<%v>", stdout)
294                         }
295                 }
296         }
297
298         // Define callback for output
299         execWS.ExitCB = func(e *eows.ExecOverWS, code int, err error) {
300                 s.log.Debugf("Command [Cmd ID %s] exited: code %d, error: %v", e.CmdID, code, err)
301
302                 // Close client tty
303                 defer func() {
304                         if gdbPty != nil {
305                                 gdbPty.Close()
306                         }
307                         if gdbTty != nil {
308                                 gdbTty.Close()
309                         }
310                 }()
311
312                 // IO socket can be nil when disconnected
313                 so := s.sessions.IOSocketGet(e.Sid)
314                 if so == nil {
315                         s.log.Infof("%s not emitted - WS closed (id:%s)", ExecExitEvent, e.CmdID)
316                         return
317                 }
318
319                 // Retrieve project ID and RootPath
320                 data := e.UserData
321                 prjID := (*data)["ID"].(string)
322                 exitImm := (*data)["ExitImmediate"].(bool)
323
324                 // XXX - workaround to be sure that Syncthing detected all changes
325                 if err := s.mfolders.ForceSync(prjID); err != nil {
326                         s.log.Errorf("Error while syncing folder %s: %v", prjID, err)
327                 }
328                 if !exitImm {
329                         // Wait end of file sync
330                         // FIXME pass as argument
331                         tmo := 60
332                         for t := tmo; t > 0; t-- {
333                                 s.log.Debugf("Wait file in-sync for %s (%d/%d)", prjID, t, tmo)
334                                 if sync, err := s.mfolders.IsFolderInSync(prjID); sync || err != nil {
335                                         if err != nil {
336                                                 s.log.Errorf("ERROR IsFolderInSync (%s): %v", prjID, err)
337                                         }
338                                         break
339                                 }
340                                 time.Sleep(time.Second)
341                         }
342                 }
343
344                 // FIXME replace by .BroadcastTo a room
345                 errSoEmit := (*so).Emit(ExecExitEvent, ExecExitMsg{
346                         CmdID:     e.CmdID,
347                         Timestamp: time.Now().String(),
348                         Code:      code,
349                         Error:     err,
350                 })
351                 if errSoEmit != nil {
352                         s.log.Errorf("WS Emit : %v", errSoEmit)
353                 }
354         }
355
356         // User data (used within callbacks)
357         data := make(map[string]interface{})
358         data["ID"] = prj.ID
359         data["ExitImmediate"] = args.ExitImmediate
360         if args.TTY && args.TTYGdbserverFix {
361                 data["gdbServerTTY"] = "workaround"
362         } else {
363                 data["gdbServerTTY"] = ""
364         }
365         execWS.UserData = &data
366
367         // Start command execution
368         s.log.Infof("Execute [Cmd ID %s]: %v %v", execWS.CmdID, execWS.Cmd, execWS.Args)
369
370         err = execWS.Start()
371         if err != nil {
372                 common.APIError(c, err.Error())
373                 return
374         }
375
376         c.JSON(http.StatusOK,
377                 gin.H{
378                         "status": "OK",
379                         "cmdID":  execWS.CmdID,
380                 })
381 }
382
383 // ExecCmd executes remotely a command
384 func (s *APIService) execSignalCmd(c *gin.Context) {
385         var args ExecSignalArgs
386
387         if c.BindJSON(&args) != nil {
388                 common.APIError(c, "Invalid arguments")
389                 return
390         }
391
392         s.log.Debugf("Signal %s for command ID %s", args.Signal, args.CmdID)
393
394         e := eows.GetEows(args.CmdID)
395         if e == nil {
396                 common.APIError(c, "unknown cmdID")
397                 return
398         }
399
400         err := e.Signal(args.Signal)
401         if err != nil {
402                 common.APIError(c, err.Error())
403                 return
404         }
405
406         c.JSON(http.StatusOK,
407                 gin.H{
408                         "status": "OK",
409                 })
410 }