diff --git a/README.md b/README.md index b996369..4948f8e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ This program allows you to use terminal in the browser. Simply run the program a This repository contains a recorded session in the ```assets/extra``` directory ([M1NXZvHdvA8vSCKp_61e5d60f.rec](extra/M1NXZvHdvA8vSCKp_61e5d60f.rec)) that shows me upgrading pihole. Just put the file under the ```records``` directory, run the server, you should find the recording in the ```Recorded Session``` tab. -3. More features are planned, including user authentication. Suggestions are welcome. +3. More features are planned, suggestions are welcome. You can use WiTTY to run any command line programs, such as ```bash```, ```htop```, ```vi```, ```ssh```. This following screenshot shows that three interactive session running ```zsh``` on macOS Monterey. @@ -32,11 +32,7 @@ gifsicle -O3 .\output.gif -o replay.gif --> -To use the program, you need to provide a TLS cert. You can request a free [Let's Encrypt](https://letsencrypt.org/) cert or use a self-signed cert. The program currently does not support user authentication. Therefore, do not run it in untrusted networks or leave it running. A probably safe use of the program is to run ```ssh```. Please ensure that you do not automatically login to the ssh server (e.g., via key authentication). - -__AGAIN, Do NOT run this in an untrusted network. You will expose your -shell to anyone that can access your network and Do NOT leave -the server running.__ +To use the program, you need to provide a TLS cert. You can request a free [Let's Encrypt](https://letsencrypt.org/) cert or use a self-signed cert. By default, WiTTY authenticate users with username and password. You can add a new user using ```witty adduser ```, and delete an existing user with ```witty deluser ```. It is also possible to disable user authentication with ```-n/-naked``` to the run command. For example, ```witty run -n zsh``` will run zsh without user authentication. This program is written in the [go programming language](https://go.dev/), using the [Gin web framework](https://github.com/gin-gonic/gin), [gorilla/websocket](https://github.com/gorilla/websocket), [pty](https://github.com/creack/pty), and the wonderful [xterm.js](https://xtermjs.org/)! @@ -66,13 +62,21 @@ Most icons were provided by [fontawesome](https://fontawesome.com/) under this [ ```go build .``` -5. Start the server and give it the command to run. The server listens on 8080, for example: +5. Add a user to the user accounts, follow the instructions on screen to provide the password - ```./witty htop``` or + ```./witty adduser ``` - ```./witty ssh -l ``` +6. Start the server and give it the command to run. The server listens on 8080, for example: + + ```./witty run htop``` or -6. Connect to the server, for example + ```./witty run ssh -l ``` + + If so desired, you can disable user authenticate with ```-n/-naked```, (not recommended) for example: + + ```./witty run -naked htop``` + +7. Connect to the server, for example ```https://:8080``` diff --git a/assets/template/index.html b/assets/template/index.html index fa999a2..da753a6 100644 --- a/assets/template/index.html +++ b/assets/template/index.html @@ -32,7 +32,7 @@ target="_blank" role="button"> New Session - + Logout diff --git a/main.go b/main.go index f196052..18b5c41 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,8 @@ package main import ( + "flag" + "fmt" "log" "os" @@ -15,17 +17,48 @@ func main() { log.SetOutput(fp) } - // parse the arguments. User can pass the command to execute - // by default, we use bash, but macos users might want to use zsh - // you can also run single program, such as pstree, htop... - // but program might misbehave (htop seems to be fine) - var cmdToExec = []string{"bash"} - args := os.Args - - if len(args) > 1 { - cmdToExec = args[1:] - log.Println(cmdToExec) + if len(os.Args) < 2 { + fmt.Println("witty (adduser|deluser|run)") + return + } + + var naked bool + runCmd := flag.NewFlagSet("run", flag.ExitOnError) + runCmd.BoolVar(&naked, "n", false, "Run WiTTY without user authentication") + runCmd.BoolVar(&naked, "naked", false, "Run WiTTY without user authentication") + + switch os.Args[1] { + case "adduser": + if len(os.Args) != 3 { + fmt.Println("witty adduser ") + return + } + web.AddUser(os.Args[2]) + + case "deluser": + if len(os.Args) != 3 { + fmt.Println("witty deluser ") + return + } + web.DelUser(os.Args[2]) + + case "run": + runCmd.Parse(os.Args[2:]) + + var cmdToExec []string + + args := runCmd.Args() + if len(args) > 0 { + cmdToExec = args + } else { + cmdToExec = []string{"bash"} + } + + web.StartWeb(fp, cmdToExec, naked) + + default: + fmt.Println("witty (adduser|deluser|run)") + return } - web.StartWeb(fp, cmdToExec) } diff --git a/web/auth.go b/web/auth.go index d3fad53..1affae8 100644 --- a/web/auth.go +++ b/web/auth.go @@ -33,7 +33,7 @@ func login(c *gin.Context) { } // Check for username and password match, usually from a database - if username != "hello" || passwd != "world" { + if !ValidateUser([]byte(username), []byte(passwd)) { leftLoginMsg(c, "Username/password does not match") c.Redirect(http.StatusSeeOther, "/login") return diff --git a/web/interactive.go b/web/interactive.go index 2b8962b..f8c23e4 100644 --- a/web/interactive.go +++ b/web/interactive.go @@ -28,8 +28,13 @@ func collectSessions(c *gin.Context, cmd string) (players []InteractiveSession) func indexPage(c *gin.Context) { host = &c.Request.Host + var disabled = "" - c.HTML(http.StatusOK, "index.html", gin.H{}) + if noAuth { + disabled = "disabled" + } + + c.HTML(http.StatusOK, "index.html", gin.H{"disabled": disabled}) } func updateIndex(c *gin.Context) { diff --git a/web/routing.go b/web/routing.go index 831f044..ae9874b 100644 --- a/web/routing.go +++ b/web/routing.go @@ -14,6 +14,7 @@ import ( var host *string = nil var cmdToExec []string +var noAuth bool // simple function to check origin func checkOrigin(r *http.Request) bool { @@ -31,8 +32,9 @@ func checkOrigin(r *http.Request) bool { return (host != nil) && (*host == h.Host) } -func StartWeb(fp *os.File, cmd []string) { +func StartWeb(fp *os.File, cmd []string, naked bool) { cmdToExec = cmd + noAuth = naked if fp != nil { gin.DefaultWriter = fp @@ -55,7 +57,11 @@ func StartWeb(fp *os.File, cmd []string) { rt.GET("/login", loginPage) rt.POST("/login", login) - g1 := rt.Group("/", AuthRequired) + g1 := rt.Group("/") + + if !naked { + g1.Use(AuthRequired) + } // Fill in the index page g1.GET("/", indexPage) diff --git a/web/user.go b/web/user.go new file mode 100644 index 0000000..8a1ab41 --- /dev/null +++ b/web/user.go @@ -0,0 +1,152 @@ +package web + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "os" + + "github.com/dchest/uniuri" + "golang.org/x/term" +) + +const ( + userFileName = "user.db" +) + +type UserRecord struct { + User []byte `json:"Username"` + Seed []byte `json:"Seed"` + Passwd [32]byte `json:"Password"` +} + +func hashPassword(seed []byte, passwd []byte) [32]byte { + input := append(seed, passwd...) + return sha256.Sum256(input) +} + +func addUser(username []byte, passwd []byte) { + var users []UserRecord + var err error + + seed := []byte(uniuri.NewLen(64)) + hashed := hashPassword(seed, passwd) + + exist := false + file, err := os.ReadFile(userFileName) + + if (err == nil) && (json.Unmarshal(file, users) == nil) { + // update the existing user if it exists + for _, u := range users { + if bytes.Equal(u.User, username) { + u.Seed = seed + u.Passwd = hashed + exist = true + break + } + } + } + + if !exist { + users = append(users, UserRecord{username, seed, hashed}) + } + + output, err := json.Marshal(users) + if err != nil { + fmt.Println("Failed to marshal passwords", err) + return + } + + os.WriteFile(userFileName, output, 0660) +} + +func AddUser(username string) { + fmt.Println("Please type your password (it will not be echoed back):") + passwd, err := term.ReadPassword(int(os.Stdin.Fd())) + + if err != nil { + fmt.Println("Failed to read password", err) + return + } + + fmt.Println("Please type your password again:") + passwd2, err := term.ReadPassword(int(os.Stdin.Fd())) + + if err != nil { + fmt.Println("Failed to read password", err) + return + } + + if !bytes.Equal(passwd, passwd2) { + fmt.Println("Password mismatch, try again") + return + } + + addUser([]byte(username), passwd) +} + +func DelUser(username string) { + var users []UserRecord + var err error + + exist := false + file, err := os.ReadFile(userFileName) + if err != nil { + fmt.Println("Failed to read users file", err) + return + } + + err = json.Unmarshal(file, &users) + + if err != nil { + fmt.Println("Failed to parse json format", err) + return + } + // update the existing user if it exists + for i, u := range users { + if bytes.Equal(u.User, []byte(username)) { + users = append(users[:i], users[i+1:]...) + exist = true + break + } + } + + if exist { + output, err := json.Marshal(users) + if err != nil { + fmt.Println("Failed to marshal passwords", err) + return + } + + os.WriteFile(userFileName, output, 0660) + } +} + +func ValidateUser(username []byte, passwd []byte) bool { + var users []UserRecord + var err error + + file, err := os.ReadFile(userFileName) + if err != nil { + fmt.Println("Failed to read users file", err) + return false + } + + err = json.Unmarshal(file, &users) + + if err != nil { + fmt.Println("Failed to parse json format", err) + return false + } + + // update the existing user if it exists + for _, u := range users { + if bytes.Equal(u.User, []byte(username)) { + hashed := hashPassword(u.Seed, passwd) + return bytes.Equal(hashed[:], u.Passwd[:]) + } + } + + return false +}