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/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 = "?"
var queryPath string
if app.options.PermitArguments && init.Arguments != "" {
queryPath = init.Arguments
} else {
queryPath = "?"
}
query, err := url.Parse(init.Arguments)
query, err := url.Parse(queryPath)
if err != nil {
log.Print("Failed to parse arguments")
conn.Close()
return
}
params := query.Query()["arg"]
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()
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()
}

View File

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

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/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)
}