mirror of
https://github.com/sorenisanerd/gotty.git
synced 2024-11-12 16:44:24 +00:00
0e81c484a9
Safari doesn't support basic authentication for websocket sessions. This commit introduces a token-based authentication only for websocket connection. The token is shared by all clients and that might be not secure. However, basic authentication itself is insecure and the credential is already shared by clients, so don't mind.
368 lines
8.6 KiB
Go
368 lines
8.6 KiB
Go
package app
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"errors"
|
|
"io/ioutil"
|
|
"log"
|
|
"math/big"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"text/template"
|
|
|
|
"github.com/braintree/manners"
|
|
"github.com/elazarl/go-bindata-assetfs"
|
|
"github.com/fatih/camelcase"
|
|
"github.com/fatih/structs"
|
|
"github.com/gorilla/websocket"
|
|
"github.com/hashicorp/hcl"
|
|
"github.com/kr/pty"
|
|
)
|
|
|
|
type App struct {
|
|
command []string
|
|
options *Options
|
|
|
|
upgrader *websocket.Upgrader
|
|
server *manners.GracefulServer
|
|
authToken string
|
|
|
|
titleTemplate *template.Template
|
|
}
|
|
|
|
type Options struct {
|
|
Address string
|
|
Port string
|
|
PermitWrite bool
|
|
EnableBasicAuth bool
|
|
Credential string
|
|
EnableRandomUrl bool
|
|
RandomUrlLength int
|
|
IndexFile string
|
|
EnableTLS bool
|
|
TLSCrtFile string
|
|
TLSKeyFile string
|
|
TitleFormat string
|
|
EnableReconnect bool
|
|
ReconnectTime int
|
|
Once bool
|
|
Preferences map[string]interface{}
|
|
}
|
|
|
|
var DefaultOptions = Options{
|
|
Address: "",
|
|
Port: "8080",
|
|
PermitWrite: false,
|
|
EnableBasicAuth: false,
|
|
Credential: "",
|
|
EnableRandomUrl: false,
|
|
RandomUrlLength: 8,
|
|
IndexFile: "",
|
|
EnableTLS: false,
|
|
TLSCrtFile: "~/.gotty.crt",
|
|
TLSKeyFile: "~/.gotty.key",
|
|
TitleFormat: "GoTTY - {{ .Command }} ({{ .Hostname }})",
|
|
EnableReconnect: false,
|
|
ReconnectTime: 10,
|
|
Once: false,
|
|
Preferences: make(map[string]interface{}),
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
return &App{
|
|
command: command,
|
|
options: options,
|
|
|
|
upgrader: &websocket.Upgrader{
|
|
ReadBufferSize: 1024,
|
|
WriteBufferSize: 1024,
|
|
Subprotocols: []string{"gotty"},
|
|
},
|
|
authToken: generateRandomString(20),
|
|
|
|
titleTemplate: titleTemplate,
|
|
}, nil
|
|
}
|
|
|
|
func ApplyConfigFile(options *Options, filePath string) error {
|
|
filePath = ExpandHomeDir(filePath)
|
|
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 option: " + name)
|
|
}
|
|
|
|
var err error
|
|
if name == "Preferences" {
|
|
prefs := val.([]map[string]interface{})[0]
|
|
htermPrefs := make(map[string]interface{})
|
|
for key, value := range prefs {
|
|
htermPrefs[strings.Replace(key, "_", "-", -1)] = value
|
|
}
|
|
err = field.Set(htermPrefs)
|
|
} else {
|
|
err = field.Set(val)
|
|
}
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (app *App) Run() error {
|
|
if app.options.PermitWrite {
|
|
log.Printf("Permitting clients to write input to the PTY.")
|
|
}
|
|
|
|
if app.options.Once {
|
|
log.Printf("Once option is provided, accepting only one client")
|
|
}
|
|
|
|
path := ""
|
|
if app.options.EnableRandomUrl {
|
|
path += "/" + generateRandomString(app.options.RandomUrlLength)
|
|
}
|
|
|
|
endpoint := net.JoinHostPort(app.options.Address, app.options.Port)
|
|
|
|
wsHandler := http.HandlerFunc(app.handleWS)
|
|
customIndexHandler := http.HandlerFunc(app.handleCustomIndex)
|
|
authTokenHandler := http.HandlerFunc(app.handleAuthToken)
|
|
staticHandler := http.FileServer(
|
|
&assetfs.AssetFS{Asset: Asset, AssetDir: AssetDir, Prefix: "static"},
|
|
)
|
|
|
|
var siteMux = http.NewServeMux()
|
|
|
|
if app.options.IndexFile != "" {
|
|
log.Printf("Using index file at " + app.options.IndexFile)
|
|
siteMux.Handle(path+"/", customIndexHandler)
|
|
} else {
|
|
siteMux.Handle(path+"/", http.StripPrefix(path+"/", staticHandler))
|
|
}
|
|
siteMux.Handle(path+"/auth_token.js", authTokenHandler)
|
|
siteMux.Handle(path+"/js/", http.StripPrefix(path+"/", staticHandler))
|
|
siteMux.Handle(path+"/favicon.png", http.StripPrefix(path+"/", staticHandler))
|
|
|
|
siteHandler := http.Handler(siteMux)
|
|
|
|
if app.options.EnableBasicAuth {
|
|
log.Printf("Using Basic Authentication")
|
|
siteHandler = wrapBasicAuth(siteHandler, app.options.Credential)
|
|
}
|
|
|
|
wsMux := http.NewServeMux()
|
|
wsMux.Handle("/", siteHandler)
|
|
wsMux.Handle(path+"/ws", wsHandler)
|
|
siteHandler = (http.Handler(wsMux))
|
|
|
|
siteHandler = wrapLogger(siteHandler)
|
|
|
|
scheme := "http"
|
|
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",
|
|
(&url.URL{Scheme: scheme, Host: endpoint, Path: path + "/"}).String(),
|
|
)
|
|
} else {
|
|
for _, address := range listAddresses() {
|
|
log.Printf(
|
|
"URL: %s",
|
|
(&url.URL{
|
|
Scheme: scheme,
|
|
Host: net.JoinHostPort(address, app.options.Port),
|
|
Path: path + "/",
|
|
}).String(),
|
|
)
|
|
}
|
|
}
|
|
|
|
var err error
|
|
app.server = manners.NewWithServer(
|
|
&http.Server{Addr: endpoint, Handler: siteHandler},
|
|
)
|
|
if app.options.EnableTLS {
|
|
crtFile := ExpandHomeDir(app.options.TLSCrtFile)
|
|
keyFile := ExpandHomeDir(app.options.TLSKeyFile)
|
|
log.Printf("TLS crt file: " + crtFile)
|
|
log.Printf("TLS key file: " + keyFile)
|
|
err = app.server.ListenAndServeTLS(crtFile, keyFile)
|
|
} else {
|
|
err = app.server.ListenAndServe()
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
log.Printf("Exiting...")
|
|
|
|
return nil
|
|
}
|
|
|
|
func (app *App) handleWS(w http.ResponseWriter, r *http.Request) {
|
|
log.Printf("New client connected: %s", r.RemoteAddr)
|
|
|
|
if r.Method != "GET" {
|
|
http.Error(w, "Method not allowed", 405)
|
|
return
|
|
}
|
|
|
|
conn, err := app.upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
log.Print("Failed to upgrade connection")
|
|
return
|
|
}
|
|
|
|
_, initMessage, err := conn.ReadMessage()
|
|
if err != nil || string(initMessage) != app.authToken {
|
|
log.Print("Failed to authenticate websocket connection")
|
|
return
|
|
}
|
|
|
|
cmd := exec.Command(app.command[0], app.command[1:]...)
|
|
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,
|
|
request: r,
|
|
connection: conn,
|
|
command: cmd,
|
|
pty: ptyIo,
|
|
}
|
|
|
|
context.goHandleClient()
|
|
}
|
|
|
|
func (app *App) handleCustomIndex(w http.ResponseWriter, r *http.Request) {
|
|
http.ServeFile(w, r, ExpandHomeDir(app.options.IndexFile))
|
|
}
|
|
|
|
func (app *App) handleAuthToken(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte("var gotty_auth_token = '" + app.authToken + "';"))
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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)
|
|
})
|
|
}
|
|
|
|
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]
|
|
}
|
|
return string(n)
|
|
}
|
|
|
|
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())
|
|
case *net.IPAddr:
|
|
addresses = append(addresses, v.IP.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func ExpandHomeDir(path string) string {
|
|
if path[0:2] == "~/" {
|
|
return os.Getenv("HOME") + path[1:]
|
|
} else {
|
|
return path
|
|
}
|
|
}
|