gotty/app/app.go

357 lines
8.0 KiB
Go
Raw Normal View History

2015-08-16 09:47:23 +00:00
package app
import (
2015-08-19 11:35:04 +00:00
"crypto/rand"
"encoding/base64"
2015-08-23 11:40:18 +00:00
"errors"
"io/ioutil"
2015-08-16 09:47:23 +00:00
"log"
2015-08-19 11:35:04 +00:00
"math/big"
"net"
2015-08-16 09:47:23 +00:00
"net/http"
2015-08-23 20:34:56 +00:00
"net/url"
"os"
2015-08-16 09:47:23 +00:00
"os/exec"
2015-08-19 11:35:04 +00:00
"strconv"
2015-08-16 09:47:23 +00:00
"strings"
2015-08-23 11:40:18 +00:00
"text/template"
2015-08-16 09:47:23 +00:00
2015-08-24 10:22:25 +00:00
"github.com/braintree/manners"
2015-08-16 09:47:23 +00:00
"github.com/elazarl/go-bindata-assetfs"
2015-08-27 06:23:54 +00:00
"github.com/fatih/camelcase"
"github.com/fatih/structs"
2015-08-16 09:47:23 +00:00
"github.com/gorilla/websocket"
2015-08-27 06:23:54 +00:00
"github.com/hashicorp/hcl"
2015-08-16 09:47:23 +00:00
"github.com/kr/pty"
)
type App struct {
2015-08-27 06:23:54 +00:00
command []string
options *Options
2015-08-21 09:22:08 +00:00
upgrader *websocket.Upgrader
2015-08-24 10:22:25 +00:00
server *manners.GracefulServer
preferences map[string]interface{}
2015-08-23 11:40:18 +00:00
titleTemplate *template.Template
}
type Options struct {
2015-08-27 06:23:54 +00:00
Address string
Port string
PermitWrite bool
EnableBasicAuth bool
Credential string
EnableRandomUrl bool
RandomUrlLength int
ProfileFile string
EnableTLS bool
TLSCrtFile string
TLSKeyFile string
TitleFormat string
EnableReconnect bool
ReconnectTime int
Once bool
2015-08-16 09:47:23 +00:00
}
2015-08-27 06:23:54 +00:00
var DefaultOptions = Options{
Address: "",
Port: "8080",
PermitWrite: false,
EnableBasicAuth: false,
Credential: "",
EnableRandomUrl: false,
RandomUrlLength: 8,
ProfileFile: "~/.gotty.prf",
EnableTLS: false,
TLSCrtFile: "~/.gotty.key",
TLSKeyFile: "~/.gotty.crt",
TitleFormat: "GoTTY - {{ .Command }} ({{ .Hostname }})",
EnableReconnect: false,
ReconnectTime: 10,
Once: false,
}
2015-08-27 06:23:54 +00:00
func New(command []string, options *Options) (*App, error) {
2015-08-23 11:40:18 +00:00
titleTemplate, err := template.New("title").Parse(options.TitleFormat)
if err != nil {
return nil, errors.New("Title format string syntax error")
}
prefMap, err := loadProfileFile(options)
if err != nil {
return nil, err
}
return &App{
2015-08-27 06:23:54 +00:00
command: command,
options: options,
upgrader: &websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
Subprotocols: []string{"gotty"},
},
preferences: prefMap,
titleTemplate: titleTemplate,
}, nil
}
2015-08-27 06:23:54 +00:00
func ApplyConfigFile(options *Options, configFilePath string) error {
if err := applyConfigFile(options, configFilePath); err != nil {
return err
}
return nil
}
func applyConfigFile(options *Options, filePath string) error {
2015-08-29 04:11:46 +00:00
filePath = ExpandHomeDir(filePath)
2015-08-27 06:23:54 +00:00
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return err
}
fileString := []byte{}
log.Printf("Loading config file at: %s", filePath)
fileString, err := ioutil.ReadFile(filePath)
if err != nil {
return err
}
config := make(map[string]interface{})
hcl.Decode(&config, string(fileString))
o := structs.New(options)
for _, name := range o.Names() {
configName := strings.ToLower(strings.Join(camelcase.Split(name), "_"))
if val, ok := config[configName]; ok {
field, ok := o.FieldOk(name)
if !ok {
return errors.New("No such field: " + name)
}
err := field.Set(val)
if err != nil {
return err
}
}
}
return nil
}
2015-08-29 04:11:46 +00:00
func ExpandHomeDir(path string) string {
2015-08-27 06:23:54 +00:00
if path[0:2] == "~/" {
return os.Getenv("HOME") + path[1:]
} else {
return path
}
}
func loadProfileFile(options *Options) (map[string]interface{}, error) {
prefString := []byte{}
prefPath := options.ProfileFile
2015-08-27 06:23:54 +00:00
if options.ProfileFile == DefaultOptions.ProfileFile {
prefPath = os.Getenv("HOME") + "/.gotty.prf"
}
if _, err := os.Stat(prefPath); os.IsNotExist(err) {
2015-08-27 06:23:54 +00:00
if options.ProfileFile != DefaultOptions.ProfileFile {
return nil, err
}
} else {
log.Printf("Loading profile path: %s", prefPath)
prefString, _ = ioutil.ReadFile(prefPath)
}
var prefMap map[string]interface{}
2015-08-27 06:23:54 +00:00
err := hcl.Decode(&prefMap, string(prefString))
if err != nil {
return nil, err
}
return prefMap, nil
2015-08-16 09:47:23 +00:00
}
func (app *App) Run() error {
2015-08-23 12:00:52 +00:00
if app.options.PermitWrite {
log.Printf("Permitting clients to write input to the PTY.")
}
path := ""
2015-08-27 06:23:54 +00:00
if app.options.EnableRandomUrl {
path += "/" + generateRandomString(app.options.RandomUrlLength)
2015-08-19 11:35:04 +00:00
}
endpoint := net.JoinHostPort(app.options.Address, app.options.Port)
2015-08-22 03:51:37 +00:00
wsHandler := http.HandlerFunc(app.handleWS)
2015-08-22 03:51:37 +00:00
staticHandler := http.FileServer(
&assetfs.AssetFS{Asset: Asset, AssetDir: AssetDir, Prefix: "static"},
2015-08-22 03:51:37 +00:00
)
if app.options.Once {
log.Printf("Once option is provided, accepting only one client")
}
2015-08-22 03:51:37 +00:00
var siteMux = http.NewServeMux()
2015-08-23 11:40:18 +00:00
siteMux.Handle(path+"/", http.StripPrefix(path+"/", staticHandler))
siteMux.Handle(path+"/ws", wsHandler)
2015-08-22 03:51:37 +00:00
siteHandler := http.Handler(siteMux)
2015-08-27 06:23:54 +00:00
if app.options.EnableBasicAuth {
2015-08-22 03:51:37 +00:00
log.Printf("Using Basic Authentication")
siteHandler = wrapBasicAuth(siteHandler, app.options.Credential)
}
2015-08-22 03:51:37 +00:00
siteHandler = wrapLogger(siteHandler)
2015-08-24 07:43:03 +00:00
scheme := "http"
if app.options.EnableTLS {
scheme = "https"
}
2015-08-22 04:10:08 +00:00
log.Printf(
"Server is starting with command: %s",
2015-08-27 06:23:54 +00:00
strings.Join(app.command, " "),
2015-08-22 04:10:08 +00:00
)
if app.options.Address != "" {
2015-08-23 20:34:56 +00:00
log.Printf(
2015-08-24 07:43:03 +00:00
"URL: %s",
(&url.URL{Scheme: scheme, Host: endpoint, Path: path + "/"}).String(),
2015-08-23 20:34:56 +00:00
)
2015-08-22 04:10:08 +00:00
} else {
for _, address := range listAddresses() {
2015-08-23 20:34:56 +00:00
log.Printf(
"URL: %s",
(&url.URL{
2015-08-24 07:43:03 +00:00
Scheme: scheme,
2015-08-23 20:34:56 +00:00
Host: net.JoinHostPort(address, app.options.Port),
Path: path + "/",
}).String(),
)
2015-08-22 04:10:08 +00:00
}
}
2015-08-24 07:43:03 +00:00
var err error
2015-08-24 10:22:25 +00:00
app.server = manners.NewWithServer(
&http.Server{Addr: endpoint, Handler: siteHandler},
)
2015-08-24 07:43:03 +00:00
if app.options.EnableTLS {
2015-08-27 06:23:54 +00:00
err = app.server.ListenAndServeTLS(
2015-08-29 04:11:46 +00:00
ExpandHomeDir(app.options.TLSCrtFile),
ExpandHomeDir(app.options.TLSKeyFile),
2015-08-27 06:23:54 +00:00
)
2015-08-24 07:43:03 +00:00
} else {
2015-08-24 10:22:25 +00:00
err = app.server.ListenAndServe()
2015-08-24 07:43:03 +00:00
}
if err != nil {
2015-08-16 09:47:23 +00:00
return err
}
2015-08-24 10:22:25 +00:00
log.Printf("Exiting...")
2015-08-16 09:47:23 +00:00
return nil
}
2015-08-22 03:51:37 +00:00
func (app *App) handleWS(w http.ResponseWriter, r *http.Request) {
2015-08-21 09:22:08 +00:00
log.Printf("New client connected: %s", r.RemoteAddr)
2015-08-16 09:47:23 +00:00
2015-08-21 09:22:08 +00:00
if r.Method != "GET" {
http.Error(w, "Method not allowed", 405)
return
}
2015-08-16 09:47:23 +00:00
2015-08-21 09:22:08 +00:00
conn, err := app.upgrader.Upgrade(w, r, nil)
if err != nil {
log.Print("Failed to upgrade connection")
return
}
2015-08-27 06:23:54 +00:00
cmd := exec.Command(app.command[0], app.command[1:]...)
2015-08-21 09:22:08 +00:00
ptyIo, err := pty.Start(cmd)
if err != nil {
log.Print("Failed to execute command")
return
}
log.Printf("Command is running for client %s with PID %d", r.RemoteAddr, cmd.Process.Pid)
context := &clientContext{
app: app,
2015-08-21 09:22:08 +00:00
request: r,
connection: conn,
command: cmd,
pty: ptyIo,
}
context.goHandleClient()
2015-08-21 09:22:08 +00:00
}
2015-08-24 10:22:25 +00:00
func (app *App) Exit() (firstCall bool) {
if app.server != nil {
log.Printf("Received Exit command, waiting for all clients to close sessions...")
return app.server.Close()
}
return true
}
2015-08-22 03:51:37 +00:00
func wrapLogger(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s", r.Method, r.URL.Path)
handler.ServeHTTP(w, r)
})
}
func wrapBasicAuth(handler http.Handler, credential string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := strings.SplitN(r.Header.Get("Authorization"), " ", 2)
if len(token) != 2 || strings.ToLower(token[0]) != "basic" {
w.Header().Set("WWW-Authenticate", `Basic realm="GoTTY"`)
http.Error(w, "Bad Request", http.StatusUnauthorized)
return
}
payload, err := base64.StdEncoding.DecodeString(token[1])
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if credential != string(payload) {
w.Header().Set("WWW-Authenticate", `Basic realm="GoTTY"`)
http.Error(w, "authorization failed", http.StatusUnauthorized)
return
}
log.Printf("Basic Authentication Succeeded: %s", r.RemoteAddr)
handler.ServeHTTP(w, r)
})
}
2015-08-21 09:22:08 +00:00
func generateRandomString(length int) string {
const base = 36
size := big.NewInt(base)
n := make([]byte, length)
for i, _ := range n {
c, _ := rand.Int(rand.Reader, size)
n[i] = strconv.FormatInt(c.Int64(), base)[0]
2015-08-16 09:47:23 +00:00
}
2015-08-21 09:22:08 +00:00
return string(n)
2015-08-16 09:47:23 +00:00
}
2015-08-22 04:10:08 +00:00
func listAddresses() (addresses []string) {
ifaces, _ := net.Interfaces()
addresses = make([]string, 0, len(ifaces))
for _, iface := range ifaces {
ifAddrs, _ := iface.Addrs()
for _, ifAddr := range ifAddrs {
switch v := ifAddr.(type) {
case *net.IPNet:
addresses = append(addresses, v.IP.String())
2015-08-22 04:10:08 +00:00
case *net.IPAddr:
addresses = append(addresses, v.IP.String())
2015-08-22 04:10:08 +00:00
}
}
}
return
}