diff --git a/app/app.go b/app/app.go index a9fb2d6..5cf227a 100644 --- a/app/app.go +++ b/app/app.go @@ -13,20 +13,18 @@ import ( "net" "net/http" "net/url" - "os/exec" "strconv" "strings" "sync" "sync/atomic" - "text/template" "time" + "github.com/yudai/gotty/backends" "github.com/yudai/gotty/utils" "github.com/braintree/manners" "github.com/elazarl/go-bindata-assetfs" "github.com/gorilla/websocket" - "github.com/kr/pty" "github.com/yudai/umutex" ) @@ -36,14 +34,12 @@ type InitMessage struct { } type App struct { - command []string + manager backends.ClientContextManager options *Options upgrader *websocket.Upgrader server *manners.GracefulServer - titleTemplate *template.Template - onceMutex *umutex.UnblockingMutex timer *time.Timer @@ -66,14 +62,12 @@ type Options struct { TLSKeyFile string `hcl:"tls_key_file" flagName:"tls-key" flagDescribe:"TLS/SSL key file path" default:"~/.gotty.key"` EnableTLSClientAuth bool `hcl:"enable_tls_client_auth" default:"false"` TLSCACrtFile string `hcl:"tls_ca_crt_file" flagName:"tls-ca-crt" flagDescribe:"TLS/SSL CA certificate file for client certifications" default:"~/.gotty.ca.crt"` - TitleFormat string `hcl:"title_format" flagName:"title-format" flagDescribe:"Title format of browser window" default:"GoTTY - {{ .Command }} ({{ .Hostname }})"` EnableReconnect bool `hcl:"enable_reconnect" flagName:"reconnect" flagDescribe:"Enable reconnection" default:"false"` ReconnectTime int `hcl:"reconnect_time" flagName:"reconnect-time" flagDescribe:"Time to reconnect" default:"10"` MaxConnection int `hcl:"max_connection" flagName:"max-connection" flagDescribe:"Maximum connection to gotty" default:"0"` Once bool `hcl:"once" flagName:"once" flagDescribe:"Accept only one client and exit on disconnection" default:"false"` Timeout int `hcl:"timeout" flagName:"timeout" flagDescribe:"Timeout seconds for waiting a client(0 to disable)" default:"0"` PermitArguments bool `hcl:"permit_arguments" flagName:"permit-arguments" flagDescribe:"Permit clients to send command line arguments in URL (e.g. http://example.com:8080/?arg=AAA&arg=BBB)" default:"true"` - CloseSignal int `hcl:"close_signal" flagName:"close-signal" flagDescribe:"Signal sent to the command process when gotty close it (default: SIGHUP)" default:"1"` Preferences HtermPrefernces `hcl:"preferences"` RawPreferences map[string]interface{} `hcl:"preferences"` Width int `hcl:"width" flagName:"width" flagDescribe:"Static width of the screen, 0(default) means dynamically resize" default:"0"` @@ -82,16 +76,10 @@ type Options struct { var Version = "1.0.0" -func New(command []string, options *Options) (*App, error) { - titleTemplate, err := template.New("title").Parse(options.TitleFormat) - if err != nil { - return nil, errors.New("Title format string syntax error") - } - +func New(manager backends.ClientContextManager, options *Options) (*App, error) { connections := int64(0) - return &App{ - command: command, + manager: manager, options: options, upgrader: &websocket.Upgrader{ @@ -99,9 +87,6 @@ func New(command []string, options *Options) (*App, error) { WriteBufferSize: 1024, Subprotocols: []string{"gotty"}, }, - - titleTemplate: titleTemplate, - onceMutex: umutex.New(), connections: &connections, }, nil @@ -169,10 +154,6 @@ func (app *App) Run() error { if app.options.EnableTLS { scheme = "https" } - log.Printf( - "Server is starting with command: %s", - strings.Join(app.command, " "), - ) if app.options.Address != "" { log.Printf( "URL: %s", @@ -267,8 +248,23 @@ func (app *App) restartTimer() { func (app *App) handleWS(w http.ResponseWriter, r *http.Request) { app.stopTimer() - connections := atomic.AddInt64(app.connections, 1) + defer func() { + connections := atomic.AddInt64(app.connections, -1) + + if app.options.MaxConnection != 0 { + log.Printf("Connection closed: %s, connections: %d/%d", + r.RemoteAddr, connections, app.options.MaxConnection) + } else { + log.Printf("Connection closed: %s, connections: %d", + r.RemoteAddr, connections) + } + + if connections == 0 { + app.restartTimer() + } + }() + if int64(app.options.MaxConnection) != 0 { if connections > int64(app.options.MaxConnection) { log.Printf("Reached max connection: %d", app.options.MaxConnection) @@ -287,11 +283,11 @@ func (app *App) handleWS(w http.ResponseWriter, r *http.Request) { log.Print("Failed to upgrade connection: " + err.Error()) return } + defer conn.Close() _, stream, err := conn.ReadMessage() if err != nil { log.Print("Failed to authenticate websocket connection") - conn.Close() return } var init InitMessage @@ -299,32 +295,34 @@ func (app *App) handleWS(w http.ResponseWriter, r *http.Request) { err = json.Unmarshal(stream, &init) if err != nil { log.Printf("Failed to parse init message %v", err) - conn.Close() return } if init.AuthToken != app.options.Credential { log.Print("Failed to authenticate websocket connection") - conn.Close() return } - argv := app.command[1:] - if app.options.PermitArguments { - if init.Arguments == "" { - init.Arguments = "?" - } - query, err := url.Parse(init.Arguments) - if err != nil { - log.Print("Failed to parse arguments") - conn.Close() - return - } - params := query.Query()["arg"] - if len(params) != 0 { - argv = append(argv, params...) - } + + var queryPath string + if app.options.PermitArguments && init.Arguments != "" { + queryPath = init.Arguments + } else { + queryPath = "?" + } + + query, err := url.Parse(queryPath) + if err != nil { + log.Print("Failed to parse arguments") + return + } + params := query.Query() + ctx, err := app.manager.New(params) + if err != nil { + log.Printf("Failed to new client context %v", err) + return } app.server.StartRoutine() + defer app.server.FinishRoutine() if app.options.Once { if app.onceMutex.TryLock() { // no unlock required, it will die soon @@ -337,30 +335,7 @@ func (app *App) handleWS(w http.ResponseWriter, r *http.Request) { } } - cmd := exec.Command(app.command[0], argv...) - ptyIo, err := pty.Start(cmd) - if err != nil { - log.Print("Failed to execute command") - return - } - - if app.options.MaxConnection != 0 { - log.Printf("Command is running for client %s with PID %d (args=%q), connections: %d/%d", - r.RemoteAddr, cmd.Process.Pid, strings.Join(argv, " "), connections, app.options.MaxConnection) - } else { - log.Printf("Command is running for client %s with PID %d (args=%q), connections: %d", - r.RemoteAddr, cmd.Process.Pid, strings.Join(argv, " "), connections) - } - - context := &clientContext{ - app: app, - request: r, - connection: conn, - command: cmd, - pty: ptyIo, - writeMutex: &sync.Mutex{}, - } - + context := &clientContext{app: app, connection: conn, writeMutex: &sync.Mutex{}, ClientContext: ctx} context.goHandleClient() } diff --git a/app/client_context.go b/app/client_context.go index b17a56b..b62634e 100644 --- a/app/client_context.go +++ b/app/client_context.go @@ -1,29 +1,21 @@ package app import ( - "bytes" "encoding/base64" "encoding/json" "log" - "net/http" - "os" - "os/exec" "strings" "sync" - "sync/atomic" - "syscall" - "unsafe" "github.com/fatih/structs" "github.com/gorilla/websocket" + "github.com/yudai/gotty/backends" ) type clientContext struct { + backends.ClientContext app *App - request *http.Request connection *websocket.Conn - command *exec.Cmd - pty *os.File writeMutex *sync.Mutex } @@ -46,56 +38,23 @@ type argResizeTerminal struct { Rows float64 } -type ContextVars struct { - Command string - Pid int - Hostname string - RemoteAddr string -} - func (context *clientContext) goHandleClient() { - exit := make(chan bool, 2) + exit := make(chan bool, 3) + + context.Start(exit) go func() { defer func() { exit <- true }() - context.processSend() }() go func() { defer func() { exit <- true }() - context.processReceive() }() - go func() { - defer context.app.server.FinishRoutine() - defer func() { - connections := atomic.AddInt64(context.app.connections, -1) - - if context.app.options.MaxConnection != 0 { - log.Printf("Connection closed: %s, connections: %d/%d", - context.request.RemoteAddr, connections, context.app.options.MaxConnection) - } else { - log.Printf("Connection closed: %s, connections: %d", - context.request.RemoteAddr, connections) - } - - if connections == 0 { - context.app.restartTimer() - } - }() - - <-exit - context.pty.Close() - - // Even if the PTY has been closed, - // Read(0 in processSend() keeps blocking and the process doen't exit - context.command.Process.Signal(syscall.Signal(context.app.options.CloseSignal)) - - context.command.Wait() - context.connection.Close() - }() + <-exit + context.TearDown() } func (context *clientContext) processSend() { @@ -107,9 +66,9 @@ func (context *clientContext) processSend() { buf := make([]byte, 1024) for { - size, err := context.pty.Read(buf) + size, err := context.OutputReader().Read(buf) if err != nil { - log.Printf("Command exited for: %s", context.request.RemoteAddr) + log.Printf("failed to read output from terminal backend: %v", err) return } safeMessage := base64.StdEncoding.EncodeToString([]byte(buf[:size])) @@ -127,19 +86,11 @@ func (context *clientContext) write(data []byte) error { } func (context *clientContext) sendInitialize() error { - hostname, _ := os.Hostname() - titleVars := ContextVars{ - Command: strings.Join(context.app.command, " "), - Pid: context.command.Process.Pid, - Hostname: hostname, - RemoteAddr: context.request.RemoteAddr, - } - - titleBuffer := new(bytes.Buffer) - if err := context.app.titleTemplate.Execute(titleBuffer, titleVars); err != nil { + windowTitle, err := context.WindowTitle() + if err != nil { return err } - if err := context.write(append([]byte{SetWindowTitle}, titleBuffer.Bytes()...)); err != nil { + if err := context.write(append([]byte{SetWindowTitle}, []byte(windowTitle)...)); err != nil { return err } @@ -187,7 +138,7 @@ func (context *clientContext) processReceive() { break } - _, err := context.pty.Write(data[1:]) + _, err := context.InputWriter().Write(data[1:]) if err != nil { return } @@ -204,7 +155,6 @@ func (context *clientContext) processReceive() { log.Print("Malformed remote command") return } - rows := uint16(context.app.options.Height) if rows == 0 { rows = uint16(args.Rows) @@ -215,24 +165,7 @@ func (context *clientContext) processReceive() { columns = uint16(args.Columns) } - window := struct { - row uint16 - col uint16 - x uint16 - y uint16 - }{ - rows, - columns, - 0, - 0, - } - syscall.Syscall( - syscall.SYS_IOCTL, - context.pty.Fd(), - syscall.TIOCSWINSZ, - uintptr(unsafe.Pointer(&window)), - ) - + context.ResizeTerminal(columns, rows) default: log.Print("Unknown message type") return diff --git a/backends/interface.go b/backends/interface.go new file mode 100644 index 0000000..33f75c1 --- /dev/null +++ b/backends/interface.go @@ -0,0 +1,19 @@ +package backends + +import ( + "io" + "net/url" +) + +type ClientContextManager interface { + New(params url.Values) (ClientContext, error) +} + +type ClientContext interface { + WindowTitle() (string, error) + Start(exitCh chan bool) + InputWriter() io.Writer + OutputReader() io.Reader + ResizeTerminal(width, height uint16) error + TearDown() error +} diff --git a/backends/ptycommand/.command.go.swp b/backends/ptycommand/.command.go.swp new file mode 100644 index 0000000..936a131 Binary files /dev/null and b/backends/ptycommand/.command.go.swp differ diff --git a/backends/ptycommand/command.go b/backends/ptycommand/command.go new file mode 100644 index 0000000..e335cf6 --- /dev/null +++ b/backends/ptycommand/command.go @@ -0,0 +1,130 @@ +package ptycommand + +import ( + "bytes" + "fmt" + "io" + "log" + "net/url" + "os" + "os/exec" + "syscall" + "text/template" + "unsafe" + + "github.com/yudai/gotty/backends" + + "github.com/kr/pty" +) + +type Options struct { + CloseSignal int `hcl:"close_signal" flagName:"close-signal" flagSName:"" flagDescribe:"Signal sent to the command process when gotty close it (default: SIGHUP)" default:"1"` + TitleFormat string `hcl:"title_format" flagName:"title-format" flagSName:"" flagDescribe:"Title format of browser window" default:"GoTTY - {{ .Command }} ({{ .Hostname }})"` +} + +type CommandClientContextManager struct { + command []string + + options *Options + titleTemplate *template.Template +} + +func NewCommandClientContextManager(command []string, options *Options) (*CommandClientContextManager, error) { + titleTemplate, err := template.New("title").Parse(options.TitleFormat) + if err != nil { + return nil, fmt.Errorf("Title format string syntax error: %v", options.TitleFormat) + } + return &CommandClientContextManager{command: command, options: options, titleTemplate: titleTemplate}, nil +} + +type CommandClientContext struct { + cmd *exec.Cmd + pty *os.File + mgr *CommandClientContextManager +} + +func (mgr *CommandClientContextManager) New(params url.Values) (backends.ClientContext, error) { + argv := mgr.command[1:] + args := params["arg"] + if len(args) != 0 { + argv = append(argv, args...) + } + + cmd := exec.Command(mgr.command[0], argv...) + return &CommandClientContext{cmd: cmd, mgr: mgr}, nil +} + +func (context *CommandClientContext) WindowTitle() (title string, err error) { + hostname, _ := os.Hostname() + + titleVars := struct { + Command string + Pid int + Hostname string + }{ + Command: context.cmd.Path, + Pid: context.cmd.Process.Pid, + Hostname: hostname, + } + + titleBuffer := new(bytes.Buffer) + if err := context.mgr.titleTemplate.Execute(titleBuffer, titleVars); err != nil { + return "", err + } + return titleBuffer.String(), nil +} + +func (context *CommandClientContext) Start(exitCh chan bool) { + ptyIo, err := pty.Start(context.cmd) + if err != nil { + log.Printf("failed to start command %v", err) + exitCh <- true + } else { + context.pty = ptyIo + } +} + +func (context *CommandClientContext) InputWriter() io.Writer { + return context.pty +} + +func (context *CommandClientContext) OutputReader() io.Reader { + return context.pty +} + +func (context *CommandClientContext) ResizeTerminal(width, height uint16) error { + window := struct { + row uint16 + col uint16 + x uint16 + y uint16 + }{ + height, + width, + 0, + 0, + } + _, _, errno := syscall.Syscall( + syscall.SYS_IOCTL, + context.pty.Fd(), + syscall.TIOCSWINSZ, + uintptr(unsafe.Pointer(&window)), + ) + if errno != 0 { + return errno + } else { + return nil + } +} + +func (context *CommandClientContext) TearDown() error { + context.pty.Close() + + // Even if the PTY has been closed, + // Read(0 in processSend() keeps blocking and the process doen't exit + if context.cmd != nil && context.cmd.Process != nil { + context.cmd.Process.Signal(syscall.Signal(context.mgr.options.CloseSignal)) + context.cmd.Wait() + } + return nil +} diff --git a/main.go b/main.go index 32a37a5..2da05d1 100644 --- a/main.go +++ b/main.go @@ -9,22 +9,28 @@ import ( "github.com/codegangsta/cli" "github.com/yudai/gotty/app" + "github.com/yudai/gotty/backends/ptycommand" "github.com/yudai/gotty/utils" ) func main() { cmd := cli.NewApp() - cmd.Version = app.Version cmd.Name = "gotty" + cmd.Version = app.Version cmd.Usage = "Share your terminal as a web application" cmd.HideHelp = true + cli.AppHelpTemplate = helpTemplate - options := &app.Options{} - if err := utils.ApplyDefaultValues(options); err != nil { + appOptions := &app.Options{} + if err := utils.ApplyDefaultValues(appOptions); err != nil { + exit(err, 1) + } + backendOptions := &ptycommand.Options{} + if err := utils.ApplyDefaultValues(backendOptions); err != nil { exit(err, 1) } - cliFlags, flagMappings, err := utils.GenerateFlags(options) + cliFlags, flagMappings, err := utils.GenerateFlags(appOptions, backendOptions) if err != nil { exit(err, 3) } @@ -41,28 +47,33 @@ func main() { cmd.Action = func(c *cli.Context) { if len(c.Args()) == 0 { + msg := "Error: No command given." cli.ShowAppHelp(c) - exit(fmt.Errorf("Error: No command given."), 1) + exit(fmt.Errorf(msg), 1) } configFile := c.String("config") _, err := os.Stat(utils.ExpandHomeDir(configFile)) if configFile != "~/.gotty" || !os.IsNotExist(err) { - if err := utils.ApplyConfigFile(configFile, options); err != nil { + if err := utils.ApplyConfigFile(configFile, appOptions, backendOptions); err != nil { exit(err, 2) } } - utils.ApplyFlags(cliFlags, flagMappings, c, options) + utils.ApplyFlags(cliFlags, flagMappings, c, appOptions, backendOptions) - options.EnableBasicAuth = c.IsSet("credential") - options.EnableTLSClientAuth = c.IsSet("tls-ca-crt") + appOptions.EnableBasicAuth = c.IsSet("credential") + appOptions.EnableTLSClientAuth = c.IsSet("tls-ca-crt") - if err := app.CheckConfig(options); err != nil { + if err := app.CheckConfig(appOptions); err != nil { exit(err, 6) } - app, err := app.New(c.Args(), options) + manager, err := ptycommand.NewCommandClientContextManager(c.Args(), backendOptions) + if err != nil { + exit(err, 3) + } + app, err := app.New(manager, appOptions) if err != nil { exit(err, 3) } @@ -74,9 +85,6 @@ func main() { exit(err, 4) } } - - cli.AppHelpTemplate = helpTemplate - cmd.Run(os.Args) }