Commit 0e81c484 by Iwasaki Yudai

Authenticate WS connection using token

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.
1 parent e7e607b3
......@@ -29,8 +29,9 @@ type App struct {
command []string
options *Options
upgrader *websocket.Upgrader
server *manners.GracefulServer
upgrader *websocket.Upgrader
server *manners.GracefulServer
authToken string
titleTemplate *template.Template
}
......@@ -88,19 +89,13 @@ func New(command []string, options *Options) (*App, error) {
WriteBufferSize: 1024,
Subprotocols: []string{"gotty"},
},
authToken: generateRandomString(20),
titleTemplate: titleTemplate,
}, nil
}
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 {
func ApplyConfigFile(options *Options, filePath string) error {
filePath = ExpandHomeDir(filePath)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return err
......@@ -146,19 +141,15 @@ func applyConfigFile(options *Options, filePath string) error {
return nil
}
func ExpandHomeDir(path string) string {
if path[0:2] == "~/" {
return os.Getenv("HOME") + path[1:]
} else {
return path
}
}
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)
......@@ -167,31 +158,23 @@ func (app *App) Run() error {
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"},
)
if app.options.Once {
log.Printf("Once option is provided, accepting only one client")
}
var siteMux = http.NewServeMux()
if app.options.IndexFile != "" {
log.Printf("Using index file at " + app.options.IndexFile)
indexHandler := http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, ExpandHomeDir(app.options.IndexFile))
},
)
siteMux.Handle(path+"/", indexHandler)
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))
siteMux.Handle(path+"/ws", wsHandler)
siteHandler := http.Handler(siteMux)
......@@ -200,6 +183,11 @@ func (app *App) Run() error {
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"
......@@ -264,6 +252,12 @@ func (app *App) handleWS(w http.ResponseWriter, r *http.Request) {
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 {
......@@ -283,6 +277,14 @@ func (app *App) handleWS(w http.ResponseWriter, r *http.Request) {
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...")
......@@ -355,3 +357,11 @@ func listAddresses() (addresses []string) {
return
}
func ExpandHomeDir(path string) string {
if path[0:2] == "~/" {
return os.Getenv("HOME") + path[1:]
} else {
return path
}
}
......@@ -91,7 +91,7 @@ func staticFaviconPng() (*asset, error) {
return a, nil
}
var _staticIndexHtml = []byte("\x1f\x8b\x08\x00\x00\x09\x6e\x88\x00\xff\x74\x90\xc1\x52\xc4\x20\x0c\x86\xef\x3e\x45\xc4\xf1\xe6\x94\x7a\xdd\xd2\x5e\x7d\x81\xbd\x78\x64\x4b\xb6\x64\xa5\xc0\x40\x5c\xed\x38\xbe\xbb\xb0\xd8\x93\xe3\x89\x3f\xf9\xff\x49\x3e\xa2\xee\x4d\x98\x79\x8b\x08\x96\x57\x37\xdd\xa9\xf6\x00\x28\x8b\xda\x54\x51\x24\x13\x3b\x9c\x5e\xc2\xf1\xf8\xaa\x64\x2b\x9a\x91\x79\x2b\xfa\x14\xcc\xf6\x04\x0f\x8c\x69\x25\xaf\x1d\x7c\xc5\x90\x89\x29\xf8\x03\xe8\x53\x0e\xee\x9d\x71\x00\x8b\xb4\x58\x3e\xc0\x73\xdf\x3f\x0e\xf0\x41\x86\xed\x5e\xac\x3a\x2d\x54\xc2\x7d\xfc\x1c\xbe\x95\x6c\x43\xdb\x02\x47\xfe\x0d\x12\xba\x51\xd0\x1c\xbc\x80\x4a\x5a\xf4\xaa\x17\x94\xd1\x2f\x02\x6c\xc2\xf3\x28\xce\xfa\x5a\xfd\xae\xb6\x6e\xf0\x72\xa7\x57\x15\xee\x77\x98\xa1\x2b\x90\x19\xc5\x0e\x2a\x26\x25\x4b\x6f\xff\xcb\x9c\x28\x32\xe4\x34\x8f\xa2\x93\x97\x2c\x6d\xcd\x75\x97\x5c\x63\xcd\xfc\x2f\xb9\x04\xe6\xed\x4f\x52\xc9\xb6\xbb\xc0\xdc\x6e\xfa\x13\x00\x00\xff\xff\x96\x39\xb4\x53\x6b\x01\x00\x00")
var _staticIndexHtml = []byte("\x1f\x8b\x08\x00\x00\x09\x6e\x88\x00\xff\x84\x90\xc1\x52\xc3\x20\x10\x86\xef\x3e\xc5\x8a\xe3\xcd\x09\xf1\xda\x90\x5c\x7d\x81\x5e\x3c\x39\x34\x6c\xc3\xb6\x04\x32\xb0\xad\x66\x1c\xdf\x5d\x28\xe6\xa4\x33\x9e\xf8\x77\xf7\x9b\xe5\x03\x75\x6f\xc2\xc8\xeb\x82\x60\x79\x76\xc3\x9d\xaa\x07\x80\xb2\xa8\x4d\x09\x39\x32\xb1\xc3\xe1\x25\xec\xf7\xaf\x4a\xd6\xa2\x0e\x12\xaf\x39\x1f\x82\x59\x9f\xe0\x81\x31\xce\xe4\xb5\x83\xcf\x25\x24\x62\x0a\x7e\x07\xfa\x90\x82\xbb\x30\x76\x60\x91\x26\xcb\x3b\x78\x6e\xdb\xc7\x0e\xde\xc9\xb0\xdd\x8a\x59\xc7\x89\x32\xdc\x2e\x1f\xdd\x97\x92\x75\x69\xbd\xc0\x91\x3f\x43\x44\xd7\x0b\x1a\x83\x17\x50\x4c\x73\x9e\xf5\x84\x72\xf1\x93\x00\x1b\xf1\xd8\x8b\xa3\xbe\x96\x79\x53\x5a\x37\x79\xb9\xd9\xab\x22\xf7\xb3\xcc\xd0\x15\xc8\xf4\x62\x13\x15\x83\x92\xb9\xb7\xbd\x65\x8c\xb4\x30\xa4\x38\xf6\xa2\x91\xa7\x24\x6d\xe1\x9a\x53\x2a\x58\x1d\xfe\x49\xea\x0b\xdb\x37\x0e\x67\xf4\xff\xb3\x79\xeb\x14\x98\xd7\x5f\xa4\x92\xd5\x33\x8b\xdf\xfe\xff\x3b\x00\x00\xff\xff\x35\xaf\x3e\x6a\x97\x01\x00\x00")
func staticIndexHtmlBytes() ([]byte, error) {
return bindataRead(
......@@ -106,12 +106,12 @@ func staticIndexHtml() (*asset, error) {
return nil, err
}
info := bindataFileInfo{name: "static/index.html", size: 363, mode: os.FileMode(436), modTime: time.Unix(1440919621, 0)}
info := bindataFileInfo{name: "static/index.html", size: 407, mode: os.FileMode(436), modTime: time.Unix(1441003850, 0)}
a := &asset{bytes: bytes, info: info}
return a, nil
}
var _staticJsGottyJs = []byte("\x1f\x8b\x08\x00\x00\x09\x6e\x88\x00\xff\x94\x56\x5f\x6f\xe2\x38\x10\x7f\xef\xa7\xb0\xf2\x82\x73\x47\x5d\xb8\x93\xee\x01\xc4\x9d\x74\x55\x57\xda\x3f\xda\xae\x0a\xbb\x7d\xa8\xfa\x60\x92\x01\xb2\x18\x1b\xd9\x4e\x23\x76\xc5\x77\xdf\x99\x10\x20\x04\xa7\x6c\xfd\x10\x19\xcf\xcc\x6f\xfe\x78\x7e\x63\xf8\x2c\xd7\x89\xcf\x8c\xe6\x31\xfb\x79\xc5\x70\xbd\x48\xcb\x16\xde\xaf\xdd\x9d\x96\x53\x05\x29\x1b\xb1\x22\xd3\xa9\x29\x84\x32\x89\x24\x55\xb1\xb6\xc6\x9b\xc4\x28\x36\x1a\xb1\xa8\xd4\x1d\x44\xc3\x83\x71\x6e\x51\xc0\xf8\x09\xc6\x7f\xac\x53\x38\x37\xb8\xb9\xe9\xb0\x01\x6d\x69\x17\xb3\x3f\xcf\x90\x17\xc6\xf9\xc0\xf1\x5a\xfa\x85\x96\x2b\x40\x11\x1a\x77\x8e\xbe\xf6\x91\x38\xf4\xf8\x14\xcd\x8d\xf7\x9b\xe8\xf9\x28\x96\xb9\x37\x0f\x90\x18\xad\x21\xf1\xa8\x72\xdd\x1f\x5e\x1d\x84\x66\x0d\xfa\x91\x0c\xcf\x4a\xb0\xd7\x28\x48\xaa\xa1\x60\x8f\x30\x1d\x9b\x64\x09\x9e\x63\x72\xdd\xa3\xd7\xb8\x82\xdb\x1b\x78\xb0\xab\xda\x51\xe1\x84\xd1\xe4\xa6\xee\x04\x5e\x40\xfb\xba\x27\x5a\x0b\xb2\x14\x29\xcc\x64\xae\xfc\xd8\x1b\x2b\xe7\x50\xf9\x56\xd9\x54\x54\x27\xe2\x13\x16\x44\xf1\x78\x78\xd1\x56\x24\x0a\xa4\xe5\xf5\xf8\x68\x91\x66\x05\xbb\xb3\x9a\xe0\x27\xd3\x3b\xcc\x33\x4d\x31\x07\xff\xc5\xc2\xcc\xf1\x58\x38\xcc\x3d\x72\xa0\xd3\x6b\xd0\x89\x49\x33\x3d\x8f\xba\x2c\xb2\xb2\x88\x82\x96\x46\xef\x91\x1f\x40\xa6\x9b\xb6\x22\xd7\x6b\x97\x19\xd4\x2a\x8d\x33\x23\xd6\xb9\x5b\x9c\xc5\x44\x0b\x65\x46\x7f\x9b\x7c\x84\x8d\xf3\xd6\x2c\xa1\x8e\x8c\x27\x21\xf0\xea\x26\x28\x78\x1e\xf5\x22\xec\x21\x52\x1c\x9e\xe9\x6d\xc3\xee\xc8\x6e\xec\x2d\x66\x8c\xbe\x9a\xee\xdb\x22\x3c\x66\xef\xb2\x1f\x27\x41\x62\xdb\xe4\x2b\xed\xba\xcc\x9a\xc2\x5d\x0a\x37\x28\xa4\x15\xf5\x29\x8f\x0f\xe3\xfb\xcf\xc2\x95\xb1\x65\xb3\x4d\xbb\x36\xad\xb0\xa3\xfa\xaa\x22\x1b\xec\x37\xdd\x8b\x16\x94\xc2\xa0\xfc\xbe\xae\xbb\x6d\x95\xc6\x41\xc9\xf9\x69\xe8\x6e\x76\xbd\xa2\x9d\x97\x4a\xe1\x85\x4c\x8d\xb4\x69\x93\x1b\x4d\xbb\x8a\x2a\x09\x92\xc4\x03\x4f\x4d\x92\xaf\x90\x8d\xd4\xe8\x77\x0a\x68\xfb\xff\xe6\x3d\x76\x89\xaf\xae\x2f\x8a\x6b\x78\xdb\x26\xb3\x57\xe0\xdc\x8e\xa7\xaf\x93\x3b\x95\x5e\xa2\x52\x29\x13\xf4\x43\x38\x95\x25\xc0\xfb\x8d\x60\x5d\x91\xf9\x64\xc1\x8f\x7a\x4f\xbd\xe7\x26\x56\x22\x1d\xb0\x4e\xaf\x33\x68\x29\x87\x11\x85\xcd\x3c\x7c\x9d\xbc\xeb\xff\xc3\x09\x23\xd0\xe8\x53\x0b\x72\x39\x0c\xc0\xf6\xdb\x60\x91\xfd\x8f\xe5\x48\x9e\x64\x5e\xc1\x9b\x71\xff\x0a\xe0\xae\x71\xb0\x80\xc5\x61\x02\x34\x64\xcb\x4e\x5e\x4b\xeb\x5a\xc1\xef\xa7\xdf\x71\x86\x8b\x25\x32\x8f\xd7\x6c\x63\x31\x33\xf6\x4e\x62\xd9\x0e\x77\x80\x2a\x6d\xbc\xc2\x97\xc0\x19\x05\xf8\xb0\xcc\x79\x34\x06\xef\x89\xd5\xc4\x24\xb4\xc1\x6f\x34\x28\x7f\xd4\x63\x7b\x42\xc9\x73\x20\x9c\x43\x69\x1a\x33\x12\xd5\xbb\xbf\x63\xbf\x7d\x4b\xfd\xfe\x0e\xd4\xaf\xf9\xb4\x5d\xae\x60\x00\xfe\x48\xcc\x6d\xa3\xb7\x13\x65\xdc\xe5\xce\xce\x66\x8c\x53\x15\x42\xf5\x2e\xab\x93\xeb\x0b\x04\x3d\x68\xd2\xa4\x5d\x98\xe2\xfe\x05\xac\x92\x1b\x1e\xdd\xee\x32\x43\xd7\xec\x96\x62\x49\xf1\xbd\xd1\xb9\x52\x4d\x82\x9f\xd2\xdb\x6e\x0e\x35\xa9\x7b\x6a\x66\x07\xd6\x1a\x7b\x92\x1d\x1d\x34\xb3\x68\x47\xab\x61\xee\xfe\x45\x1c\xde\xaa\xf2\x5f\x40\xcd\xae\xed\xe5\xa3\xca\x9d\x5e\xe1\xbf\x23\xd6\x6b\x46\x80\x1d\x35\xc9\x56\x60\x72\xcf\x77\x7e\xba\x8d\x7b\xff\x83\xf5\x7b\xbd\x5e\x20\xb6\x6d\xcc\xe3\xab\x5f\x01\x00\x00\xff\xff\x42\x24\x4b\x87\xdd\x09\x00\x00")
var _staticJsGottyJs = []byte("\x1f\x8b\x08\x00\x00\x09\x6e\x88\x00\xff\x94\x56\xdd\x8e\xda\x3a\x10\xbe\xdf\xa7\xb0\x72\x83\x73\x0e\x9b\x85\x73\xa4\x5e\x80\x68\xa5\xae\xb6\x52\x7f\xd4\xad\x16\xda\xbd\x58\xad\x56\x26\x19\x48\x8a\xb1\x91\x3d\xd9\x88\x56\xbc\x7b\xc7\x21\x40\x08\xce\xd2\xf5\x45\x64\x3c\xdf\x7c\xf3\xe3\x99\x31\x7c\x96\xab\x18\x33\xad\x78\xc8\x7e\x5f\x30\x5a\xcf\xc2\xb0\x14\x71\x65\x6f\x94\x98\x4a\x48\xd8\x88\x15\x99\x4a\x74\x11\x49\x1d\x0b\x07\x8d\x56\x46\xa3\x8e\xb5\x64\xa3\x11\x0b\x4a\xec\x20\x18\xee\x95\x73\x43\x02\xc6\x8f\x38\xde\xb1\x4e\x61\xed\xe0\xea\xaa\xc3\x06\x6e\xeb\x76\x21\xfb\xf7\x84\x39\xd5\x16\x3d\xc7\x2b\x81\xa9\x12\x4b\x20\x11\x29\x77\x0e\xb6\x76\x9e\x58\xb2\xf8\x10\xcc\x35\xe2\x3a\x78\x3c\x88\x45\x8e\xfa\x0e\x62\xad\x14\xc4\x48\x90\xcb\xfe\xf0\x62\x2f\xd4\x2b\x50\xf7\x4e\xf1\x24\x05\x3b\x44\xe1\xa4\x0a\x0a\x76\x0f\xd3\xb1\x8e\x17\x80\x9c\x82\xeb\x1e\xac\x86\x15\xdd\x4e\x01\xc1\x2c\x6b\x47\x85\x8d\xb4\x72\x66\xea\x46\xe0\x19\x14\xd6\x2d\x55\x48\x0b\x2a\xe1\x65\x04\x4f\xe4\x76\xfa\x84\x7a\x01\xaa\x6e\xc0\xad\xd4\x59\x88\x12\x98\x89\x5c\xe2\x18\xb5\x11\x73\xa8\x7c\x94\xd9\x34\xaa\x4e\xa2\x2f\x94\x38\xc9\xc3\xe1\x59\xdd\x28\x96\x20\x0c\x6f\x9a\x71\xc8\x8a\x76\xab\x35\xa1\x4f\xa6\xb6\x9c\x27\xc8\x68\x0e\xf8\xcd\xc0\xcc\xf2\x90\xa2\x40\x1e\xb8\x50\x2e\x41\xc5\x3a\xc9\xd4\x3c\xe8\xb2\xc0\x88\x22\xf0\x6a\x6a\xb5\x63\xbe\x03\x91\xac\xdb\x2e\xa3\x9e\xe3\x4c\x13\xaa\x54\xce\x74\xb4\xca\x6d\x7a\xe2\x93\x5b\x24\xd3\xea\xc7\xe4\x33\xac\x2d\x1a\xca\x64\x9d\x99\x4e\x7c\xe4\xf5\x7b\x08\x7a\x01\xd5\x9a\x03\x0e\x4f\x70\x1b\xbf\x39\xa7\x37\x46\x43\x11\x93\xad\xa6\xf9\x36\x0f\x0f\xd1\xdb\xec\xd7\x91\x93\x54\x5e\xf9\x52\xd9\x2e\x33\xba\xb0\xe7\xdc\xf5\x0a\xdd\x0a\xfa\x2e\x8e\x4f\xe3\xdb\xaf\x91\x2d\x7d\xcb\x66\xeb\x76\xb4\x5b\x7e\x43\xf5\x55\x79\x36\xd8\x6d\xba\x67\x35\x5c\x08\x83\xf2\xfb\x32\x76\xd3\x2a\x0d\xbd\x92\xd3\x53\xdf\xdd\x6c\x6b\x45\x59\x14\x52\xd2\x85\x4c\xb5\x30\x49\xb3\x37\x9a\x7a\x55\xab\xc4\xd4\x24\x08\x3c\xd1\x71\xbe\xa4\xae\x75\x85\x7e\x23\xc1\x6d\xdf\xaf\x3f\x52\x95\x60\x75\x7d\x41\x58\xe3\xdb\x34\x27\xc0\x12\xac\xdd\xf6\xe9\xcb\x43\x20\x11\x28\x08\x54\xca\x22\xf7\x23\xb2\x32\x8b\x81\xf7\x1b\xce\xda\x22\xc3\x38\xe5\x07\xdc\x43\xef\xb1\xc9\x15\x0b\x0b\xac\xd3\xeb\x0c\x5a\xd2\xa1\xa3\xc2\x64\x08\xdf\x27\x1f\xfa\x6f\xb8\xe3\xf0\x14\xfa\xd4\x80\x58\x0c\x3d\xb4\xfd\x36\x5a\xea\xfe\xfb\x72\x74\x4f\x32\x94\xf0\x6a\xde\xff\x3c\xbc\x2b\x1a\x2c\x60\x68\x98\x80\x1b\xc6\x65\x25\xaf\x84\xb1\xad\xe4\xb7\xd3\x9f\x34\xeb\xa3\x05\x75\x1e\xaf\xe9\x86\xd1\x4c\x9b\x1b\x41\x69\xdb\xdf\x01\x41\xda\xfa\x8a\x5e\x0c\xab\x25\xd0\x03\x34\xe7\xc1\x18\x10\x5d\x57\xbb\x4e\x22\x1d\xfa\x06\x83\xf2\x47\xdd\xb7\x07\x92\x3c\x7a\xdc\xd9\xa7\xa6\x31\x23\x09\xde\xfd\x1b\xfd\xcd\x6b\xf2\xf7\xbf\x27\x7f\xcd\x27\xf0\x7c\x06\x3d\xf4\x87\xc6\xdc\x34\x6a\x3b\x96\xda\x9e\xaf\xec\x6c\xc6\xb8\xcb\x82\x2f\xdf\x65\x76\x72\x75\xa6\x41\xf7\x48\x37\x69\x53\x5d\xdc\x3e\x83\x91\x62\xcd\x83\xeb\x6d\x64\x64\x9a\x5d\x3b\x5f\x12\x7a\x6f\x54\x2e\x65\xb3\xc1\x8f\xdb\xdb\xac\xf7\x39\xa9\x5b\x6a\x46\x07\xc6\x68\x73\x14\x9d\x3b\x68\x46\xd1\xce\x56\xe3\xdc\xfe\xdb\xd8\xbf\x55\xe5\xbf\x85\x9a\x5e\xdb\xcb\xe7\x32\x77\x7c\x85\x6f\x47\xac\xd7\xf4\x80\x2a\x6a\x92\x2d\x41\xe7\xc8\xb7\x76\xba\x8d\x7b\xff\x87\xf5\x7b\xbd\x9e\xc7\xb7\x4d\xc8\xc3\x8b\x3f\x01\x00\x00\xff\xff\x4f\xa7\xa4\xe9\x05\x0a\x00\x00")
func staticJsGottyJsBytes() ([]byte, error) {
return bindataRead(
......@@ -126,7 +126,7 @@ func staticJsGottyJs() (*asset, error) {
return nil, err
}
info := bindataFileInfo{name: "static/js/gotty.js", size: 2525, mode: os.FileMode(436), modTime: time.Unix(1440919623, 0)}
info := bindataFileInfo{name: "static/js/gotty.js", size: 2565, mode: os.FileMode(436), modTime: time.Unix(1441003850, 0)}
a := &asset{bytes: bytes, info: info}
return a, nil
}
......
......@@ -10,6 +10,8 @@
var term;
ws.onopen = function(event) {
ws.send(gotty_auth_token);
hterm.defaultStorage = new lib.Storage.Local();
hterm.defaultStorage.clear();
......
......@@ -8,6 +8,7 @@
<body>
<div id="terminal"></div>
<script src="./js/hterm.js"></script>
<script src="./auth_token.js"></script>
<script src="./js/gotty.js"></script>
</body>
</html>
Markdown is supported
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!