refactor: decouple gotty app with terminal backends

This commit is contained in:
zlji 2017-01-10 00:13:36 +08:00 committed by Iwasaki Yudai
parent d71e2fcfa8
commit 496ef86339
6 changed files with 226 additions and 161 deletions

View File

@ -13,20 +13,18 @@ import (
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"os/exec"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"text/template"
"time" "time"
"github.com/yudai/gotty/backends"
"github.com/yudai/gotty/utils" "github.com/yudai/gotty/utils"
"github.com/braintree/manners" "github.com/braintree/manners"
"github.com/elazarl/go-bindata-assetfs" "github.com/elazarl/go-bindata-assetfs"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/kr/pty"
"github.com/yudai/umutex" "github.com/yudai/umutex"
) )
@ -36,14 +34,12 @@ type InitMessage struct {
} }
type App struct { type App struct {
command []string manager backends.ClientContextManager
options *Options options *Options
upgrader *websocket.Upgrader upgrader *websocket.Upgrader
server *manners.GracefulServer server *manners.GracefulServer
titleTemplate *template.Template
onceMutex *umutex.UnblockingMutex onceMutex *umutex.UnblockingMutex
timer *time.Timer 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"` 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"` 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"` 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"` 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"` 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"` 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"` 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"` 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"` 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"` Preferences HtermPrefernces `hcl:"preferences"`
RawPreferences map[string]interface{} `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"` 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" var Version = "1.0.0"
func New(command []string, options *Options) (*App, error) { func New(manager backends.ClientContextManager, options *Options) (*App, error) {
titleTemplate, err := template.New("title").Parse(options.TitleFormat)
if err != nil {
return nil, errors.New("Title format string syntax error")
}
connections := int64(0) connections := int64(0)
return &App{ return &App{
command: command, manager: manager,
options: options, options: options,
upgrader: &websocket.Upgrader{ upgrader: &websocket.Upgrader{
@ -99,9 +87,6 @@ func New(command []string, options *Options) (*App, error) {
WriteBufferSize: 1024, WriteBufferSize: 1024,
Subprotocols: []string{"gotty"}, Subprotocols: []string{"gotty"},
}, },
titleTemplate: titleTemplate,
onceMutex: umutex.New(), onceMutex: umutex.New(),
connections: &connections, connections: &connections,
}, nil }, nil
@ -169,10 +154,6 @@ func (app *App) Run() error {
if app.options.EnableTLS { if app.options.EnableTLS {
scheme = "https" scheme = "https"
} }
log.Printf(
"Server is starting with command: %s",
strings.Join(app.command, " "),
)
if app.options.Address != "" { if app.options.Address != "" {
log.Printf( log.Printf(
"URL: %s", "URL: %s",
@ -267,8 +248,23 @@ func (app *App) restartTimer() {
func (app *App) handleWS(w http.ResponseWriter, r *http.Request) { func (app *App) handleWS(w http.ResponseWriter, r *http.Request) {
app.stopTimer() app.stopTimer()
connections := atomic.AddInt64(app.connections, 1) 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 int64(app.options.MaxConnection) != 0 {
if connections > int64(app.options.MaxConnection) { if connections > int64(app.options.MaxConnection) {
log.Printf("Reached max connection: %d", 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()) log.Print("Failed to upgrade connection: " + err.Error())
return return
} }
defer conn.Close()
_, stream, err := conn.ReadMessage() _, stream, err := conn.ReadMessage()
if err != nil { if err != nil {
log.Print("Failed to authenticate websocket connection") log.Print("Failed to authenticate websocket connection")
conn.Close()
return return
} }
var init InitMessage var init InitMessage
@ -299,32 +295,34 @@ func (app *App) handleWS(w http.ResponseWriter, r *http.Request) {
err = json.Unmarshal(stream, &init) err = json.Unmarshal(stream, &init)
if err != nil { if err != nil {
log.Printf("Failed to parse init message %v", err) log.Printf("Failed to parse init message %v", err)
conn.Close()
return return
} }
if init.AuthToken != app.options.Credential { if init.AuthToken != app.options.Credential {
log.Print("Failed to authenticate websocket connection") log.Print("Failed to authenticate websocket connection")
conn.Close()
return return
} }
argv := app.command[1:]
if app.options.PermitArguments { var queryPath string
if init.Arguments == "" { if app.options.PermitArguments && init.Arguments != "" {
init.Arguments = "?" queryPath = init.Arguments
} } else {
query, err := url.Parse(init.Arguments) queryPath = "?"
if err != nil { }
log.Print("Failed to parse arguments")
conn.Close() query, err := url.Parse(queryPath)
return if err != nil {
} log.Print("Failed to parse arguments")
params := query.Query()["arg"] return
if len(params) != 0 { }
argv = append(argv, params...) 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() app.server.StartRoutine()
defer app.server.FinishRoutine()
if app.options.Once { if app.options.Once {
if app.onceMutex.TryLock() { // no unlock required, it will die soon 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...) context := &clientContext{app: app, connection: conn, writeMutex: &sync.Mutex{}, ClientContext: ctx}
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.goHandleClient() context.goHandleClient()
} }

View File

@ -1,29 +1,21 @@
package app package app
import ( import (
"bytes"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"log" "log"
"net/http"
"os"
"os/exec"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"syscall"
"unsafe"
"github.com/fatih/structs" "github.com/fatih/structs"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/yudai/gotty/backends"
) )
type clientContext struct { type clientContext struct {
backends.ClientContext
app *App app *App
request *http.Request
connection *websocket.Conn connection *websocket.Conn
command *exec.Cmd
pty *os.File
writeMutex *sync.Mutex writeMutex *sync.Mutex
} }
@ -46,56 +38,23 @@ type argResizeTerminal struct {
Rows float64 Rows float64
} }
type ContextVars struct {
Command string
Pid int
Hostname string
RemoteAddr string
}
func (context *clientContext) goHandleClient() { func (context *clientContext) goHandleClient() {
exit := make(chan bool, 2) exit := make(chan bool, 3)
context.Start(exit)
go func() { go func() {
defer func() { exit <- true }() defer func() { exit <- true }()
context.processSend() context.processSend()
}() }()
go func() { go func() {
defer func() { exit <- true }() defer func() { exit <- true }()
context.processReceive() context.processReceive()
}() }()
go func() { <-exit
defer context.app.server.FinishRoutine() context.TearDown()
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()
}()
} }
func (context *clientContext) processSend() { func (context *clientContext) processSend() {
@ -107,9 +66,9 @@ func (context *clientContext) processSend() {
buf := make([]byte, 1024) buf := make([]byte, 1024)
for { for {
size, err := context.pty.Read(buf) size, err := context.OutputReader().Read(buf)
if err != nil { if err != nil {
log.Printf("Command exited for: %s", context.request.RemoteAddr) log.Printf("failed to read output from terminal backend: %v", err)
return return
} }
safeMessage := base64.StdEncoding.EncodeToString([]byte(buf[:size])) safeMessage := base64.StdEncoding.EncodeToString([]byte(buf[:size]))
@ -127,19 +86,11 @@ func (context *clientContext) write(data []byte) error {
} }
func (context *clientContext) sendInitialize() error { func (context *clientContext) sendInitialize() error {
hostname, _ := os.Hostname() windowTitle, err := context.WindowTitle()
titleVars := ContextVars{ if err != nil {
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 {
return err 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 return err
} }
@ -187,7 +138,7 @@ func (context *clientContext) processReceive() {
break break
} }
_, err := context.pty.Write(data[1:]) _, err := context.InputWriter().Write(data[1:])
if err != nil { if err != nil {
return return
} }
@ -204,7 +155,6 @@ func (context *clientContext) processReceive() {
log.Print("Malformed remote command") log.Print("Malformed remote command")
return return
} }
rows := uint16(context.app.options.Height) rows := uint16(context.app.options.Height)
if rows == 0 { if rows == 0 {
rows = uint16(args.Rows) rows = uint16(args.Rows)
@ -215,24 +165,7 @@ func (context *clientContext) processReceive() {
columns = uint16(args.Columns) columns = uint16(args.Columns)
} }
window := struct { context.ResizeTerminal(columns, rows)
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)),
)
default: default:
log.Print("Unknown message type") log.Print("Unknown message type")
return return

19
backends/interface.go Normal file
View File

@ -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
}

Binary file not shown.

View File

@ -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
}

36
main.go
View File

@ -9,22 +9,28 @@ import (
"github.com/codegangsta/cli" "github.com/codegangsta/cli"
"github.com/yudai/gotty/app" "github.com/yudai/gotty/app"
"github.com/yudai/gotty/backends/ptycommand"
"github.com/yudai/gotty/utils" "github.com/yudai/gotty/utils"
) )
func main() { func main() {
cmd := cli.NewApp() cmd := cli.NewApp()
cmd.Version = app.Version
cmd.Name = "gotty" cmd.Name = "gotty"
cmd.Version = app.Version
cmd.Usage = "Share your terminal as a web application" cmd.Usage = "Share your terminal as a web application"
cmd.HideHelp = true cmd.HideHelp = true
cli.AppHelpTemplate = helpTemplate
options := &app.Options{} appOptions := &app.Options{}
if err := utils.ApplyDefaultValues(options); err != nil { if err := utils.ApplyDefaultValues(appOptions); err != nil {
exit(err, 1)
}
backendOptions := &ptycommand.Options{}
if err := utils.ApplyDefaultValues(backendOptions); err != nil {
exit(err, 1) exit(err, 1)
} }
cliFlags, flagMappings, err := utils.GenerateFlags(options) cliFlags, flagMappings, err := utils.GenerateFlags(appOptions, backendOptions)
if err != nil { if err != nil {
exit(err, 3) exit(err, 3)
} }
@ -41,28 +47,33 @@ func main() {
cmd.Action = func(c *cli.Context) { cmd.Action = func(c *cli.Context) {
if len(c.Args()) == 0 { if len(c.Args()) == 0 {
msg := "Error: No command given."
cli.ShowAppHelp(c) cli.ShowAppHelp(c)
exit(fmt.Errorf("Error: No command given."), 1) exit(fmt.Errorf(msg), 1)
} }
configFile := c.String("config") configFile := c.String("config")
_, err := os.Stat(utils.ExpandHomeDir(configFile)) _, err := os.Stat(utils.ExpandHomeDir(configFile))
if configFile != "~/.gotty" || !os.IsNotExist(err) { 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) exit(err, 2)
} }
} }
utils.ApplyFlags(cliFlags, flagMappings, c, options) utils.ApplyFlags(cliFlags, flagMappings, c, appOptions, backendOptions)
options.EnableBasicAuth = c.IsSet("credential") appOptions.EnableBasicAuth = c.IsSet("credential")
options.EnableTLSClientAuth = c.IsSet("tls-ca-crt") appOptions.EnableTLSClientAuth = c.IsSet("tls-ca-crt")
if err := app.CheckConfig(options); err != nil { if err := app.CheckConfig(appOptions); err != nil {
exit(err, 6) 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 { if err != nil {
exit(err, 3) exit(err, 3)
} }
@ -74,9 +85,6 @@ func main() {
exit(err, 4) exit(err, 4)
} }
} }
cli.AppHelpTemplate = helpTemplate
cmd.Run(os.Args) cmd.Run(os.Args)
} }