diff --git a/README.md b/README.md index cfc3bad..de87e07 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ starting ssh server on port 2222... ## How to use +### First Time + After the gortal service is started, an sshd service will be started on port `2222`. You can also set the startup port through `-p`. After the service is started, you only need to use the `ssh` command to access the service. @@ -54,12 +56,29 @@ The default user password for the first access is `newuser`, and then the comman ```shell $ ssh root@127.0.0.1 -p 2222 root@127.0.0.1's password: -Use the arrow keys to navigate: ↓ ↑ → ← -? Please select the function you need: +Use the arrow keys to navigate: ↓ ↑ → ← +? Please select the function you need: ▸ List servers Edit users Edit servers + Edit personal info Quit ``` -You can use it after logging in with your password again. \ No newline at end of file +You can use it after logging in with your password again. + +### Copy files to server via jumpserver + +If you want to copy files to the server via a springboard, you can use the `scp` command in the following format: + +```shell +$ scp -P 2222 ~/Desktop/README.md gzp@jumpserver:gzp@server2:~/Desktop/README1.md +README.md 100% 9279 73.9KB/s 00:00 +``` + +Note the use of `:` after `gzp@jumpserver` plus the `key` and `username` of the server you need to transfer, and finally write the destination path. +Folder transfer is currently not supported. Please compress the file and upload it. + +### Copy files from server via jumpserver + +> To do diff --git a/config/config.go b/config/config.go index 05dd646..919da0f 100644 --- a/config/config.go +++ b/config/config.go @@ -164,10 +164,15 @@ func (c *Config) GetUserByUsername(username string) *User { return nil } -// // GetUsers GetUsers -// func (c *Config) GetUsers() []*User { - -// } +// GetServerByName GetServerByName +func (c *Config) GetServerByName(name string) *Server { + for _, server := range *c.Servers { + if server.Name == name { + return server + } + } + return nil +} // GetUserServers get user servers list func (c *Config) GetUserServers(user *User) map[string]*Server { diff --git a/core/pui/server.go b/core/pui/server.go index 472414e..862d2b5 100644 --- a/core/pui/server.go +++ b/core/pui/server.go @@ -289,7 +289,7 @@ func GetServersMenu() func(int, *MenuItem, *ssh.Session, []*MenuItem) *[]*MenuIt menu = append( menu, &MenuItem{ - Label: server.Name, + Label: fmt.Sprintf("%s: %s", serverKey, server.Name), Info: info, SubMenuTitle: fmt.Sprintf("Please select ssh user to login '%s'", server.Name), GetSubMenu: GetServerSSHUsersMenu(server), @@ -323,7 +323,7 @@ func GetEditedServersMenu( menu = append( menu, &MenuItem{ - Label: server.Name, + Label: fmt.Sprintf("%s: %s", serverKey, server.Name), Info: info, SubMenuTitle: fmt.Sprintf("Please select. "), GetSubMenu: getSubMenu, @@ -522,6 +522,9 @@ func DelSSHUser(server *config.Server, sshUserKey string, sess *ssh.Session) err // GetEditedSSHUsersMenu GetEditedSSHUsersMenu func GetEditedSSHUsersMenu(server *config.Server) *[]*MenuItem { menu := make([]*MenuItem, 0) + if server.SSHUsers == nil { + return nil + } for sshUserKey, sshUser := range *server.SSHUsers { info := make(map[string]string) diff --git a/core/sshd/scp.go b/core/sshd/scp.go new file mode 100644 index 0000000..03f1596 --- /dev/null +++ b/core/sshd/scp.go @@ -0,0 +1,445 @@ +package sshd + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/TNK-Studio/gortal/config" + "github.com/gliderlabs/ssh" + gossh "golang.org/x/crypto/ssh" +) + +const ( + flagCopyFile = "C" + flagStartDirectory = "D" + flagEndDirectory = "E" + flagTime = "T" +) + +const ( + responseOk uint8 = 0 + responseError uint8 = 1 + responseFailError uint8 = 2 +) + +type response struct { + Type uint8 + Message string +} + +// ParseResponse Reads from the given reader (assuming it is the output of the remote) and parses it into a Response structure +func parseResponse(reader io.Reader) (response, error) { + buffer := make([]uint8, 1) + _, err := reader.Read(buffer) + if err != nil { + return response{}, err + } + + responseType := buffer[0] + message := "" + if responseType > 0 { + bufferedRader := bufio.NewReader(reader) + message, err = bufferedRader.ReadString('\n') + if err != nil { + return response{}, err + } + } + + return response{responseType, message}, nil +} + +func (r *response) IsOk() bool { + return r.Type == responseOk +} + +func (r *response) IsError() bool { + return r.Type == responseError +} + +// Returns true when the remote responded with an error +func (r *response) FailError() bool { + return r.Type == responseFailError +} + +// Returns true when the remote answered with a warning or an error +func (r *response) IsFailure() bool { + return r.Type > 0 +} + +// Returns the message the remote sent back +func (r *response) GetMessage() string { + return r.Message +} + +// ExecuteSCP ExecuteSCP +func ExecuteSCP(args []string, sess *ssh.Session) error { + err := replyOk(sess) + if err != nil { + return err + } + + bufferedReader := bufio.NewReader(*sess) + message, err := bufferedReader.ReadString('\n') + if err != nil { + replyErr(sess, err) + return err + } + + flag, perm, size, filename, err := parseMsg(message) + if err != nil { + replyErr(sess, err) + return err + } + switch flag { + case flagCopyFile: + err = copyFileToServer(bufferedReader, size, filename, args[1], perm, sess) + if err != nil { + replyErr(sess, err) + return err + } + case flagEndDirectory: + case flagStartDirectory: + replyErr(sess, errors.New("Folder transfer is not yet supported. You can try to compress the folder and upload it. ")) + default: + return nil + } + + return nil +} + +func parseServerPath(fullPath, filename, currentUsername string) (*config.SSHUser, *config.Server, string, error) { + args := strings.SplitN(fullPath, ":", 2) + invaildPathErr := errors.New( + "Please input your server key before your target path, like 'scp -P 2222 /tmp/tmp.file user@jumpserver:user@server1:/tmp/tmp.file'", + ) + + invaildSSHUserErr := errors.New("Please make sure you have ssh user to access this server. ") + + if len(args) < 2 { + return nil, nil, "", invaildPathErr + } + + inputServer, remotePath := args[0], args[1] + serverArgs := strings.SplitN(inputServer, "@", 2) + if len(serverArgs) < 2 { + return nil, nil, "", invaildPathErr + } + + sshUsername, serverKey := serverArgs[0], serverArgs[1] + server := (*config.Conf.Servers)[serverKey] + if server == nil { + err := fmt.Errorf("Server key '%s' of server not found. ", serverKey) + return nil, nil, "", err + } + + if *server.SSHUsers == nil { + return nil, nil, "", invaildSSHUserErr + } + + var user *config.SSHUser + +loop: + for _, sshUser := range *server.SSHUsers { + if (*sshUser).SSHUsername == sshUsername { + + if sshUser.AllowUsers == nil || len(*sshUser.AllowUsers) < 1 { + user = sshUser + break loop + } + + for _, allowUser := range *sshUser.AllowUsers { + if allowUser == currentUsername { + user = sshUser + break loop + } + } + } + } + + if user == nil { + return nil, nil, "", invaildSSHUserErr + } + + return user, server, remotePath, nil +} + +// waitTimeout waits for the waitgroup for the specified max timeout. +// Returns true if waiting timed out. +func waitTimeout(wg *sync.WaitGroup, timeout time.Duration) bool { + c := make(chan struct{}) + go func() { + defer close(c) + wg.Wait() + }() + select { + case <-c: + return false // completed normally + case <-time.After(timeout): + return true // timed out + } +} + +func checkResponse(r io.Reader) error { + response, err := parseResponse(r) + if err != nil { + return err + } + + if response.IsFailure() { + return errors.New(response.GetMessage()) + } + + return nil + +} + +func copyFileToServer(bfReader *bufio.Reader, size int64, filename, filePath string, perm string, sess *ssh.Session) error { + sshUser, server, filePath, err := parseServerPath(filePath, filename, (*sess).User()) + if err != nil { + return err + } + err = replyOk(sess) + if err != nil { + return err + } + + client, err := NewSSHClient(server, sshUser) + if err != nil { + return err + } + + clientSess, err := client.NewSession() + if err != nil { + return err + } + + err = copyToSession(bfReader, clientSess, perm, filePath, filename, size) + if err != nil { + return err + } + + err = replyOk(sess) + if err != nil { + return err + } + + return nil +} + +func copyToSession(r *bufio.Reader, clientSess *gossh.Session, perm, filePath, filename string, size int64) error { + wg := sync.WaitGroup{} + wg.Add(2) + + errCh := make(chan error, 2) + defer func() { + select { + case <-errCh: + return + default: + } + close(errCh) + }() + + stdout, err := clientSess.StdoutPipe() + if err != nil { + return err + } + + go func() { + defer wg.Done() + w, err := clientSess.StdinPipe() + if err != nil { + errCh <- err + return + } + + defer w.Close() + if err != nil { + errCh <- err + return + } + + if err = checkResponse(stdout); err != nil { + errCh <- err + return + } + + _, err = fmt.Fprintln(w, flagCopyFile+perm, size, filename) + if err != nil { + errCh <- err + return + } + + if err = checkResponse(stdout); err != nil { + errCh <- err + return + } + + // Create a temp file + tmp, err := createTmpFile(r, perm, size) + if err != nil { + errCh <- err + return + } + + tmpReader := bufio.NewReader(tmp) + io.Copy(w, tmpReader) + + _, err = fmt.Fprint(w, "\x00") + if err != nil { + errCh <- err + return + } + + if err = checkResponse(stdout); err != nil { + errCh <- err + return + } + }() + + go func() { + defer wg.Done() + if err := clientSess.Run(fmt.Sprintf("scp -t %s", filePath)); err == nil { + return + } + + if err = checkResponse(stdout); err != nil { + errCh <- err + return + } + + }() + + // if waitTimeout(&wg, time.Second*300) { + // return errors.New("timeout when upload files") + // } + // + + // Todo Timeout Handling + wg.Wait() + + close(errCh) + for err := range errCh { + if err != nil { + return err + } + } + + return nil +} + +func createTmpFile(r *bufio.Reader, perm string, size int64) (*os.File, error) { + fileMode, err := strconv.ParseUint(perm, 8, 0) + if err != nil { + return nil, err + } + + tmpFilePath := fmt.Sprintf("/tmp/gortal-tmp-file-%d", time.Now().UnixNano()) + f, err := os.OpenFile(tmpFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.FileMode(fileMode)) + if err != nil { + return nil, err + } + + defer func() { + if err := f.Close(); err != nil { + panic(err) + } + }() + + var off int64 + buf := make([]byte, 2048) + for { + n, err := r.Read(buf) + buffSize := int64(n) + + if err != nil && err != io.EOF { + return nil, err + } + + if off+buffSize > size && buf[n-1] == '\x00' { + _, err := f.WriteAt(buf[:n-1], off) + if err != nil { + return nil, err + } + break + } else if off+buffSize > size && buf[n-1] != '\x00' { + return nil, errors.New("File size not match. ") + } + + _, err = f.WriteAt(buf, off) + if err != nil { + return nil, err + } + off = off + buffSize + } + + tmp, err := os.Open(tmpFilePath) + if err != nil { + return nil, err + } + + return tmp, nil +} + +func parseMsg(msg string) (string, string, int64, string, error) { + strs := strings.Split(msg, " ") + if len(strs) < 3 { + return "", "0644", 0, "", errors.New("Message parsed error") + } + + size, err := strconv.Atoi(strs[1]) + if err != nil { + return "", "0644", 0, "", errors.New("Message parsed error") + } + + permissions, filename := strs[0], strs[2] + + flag := permissions[0:1] + permissions = permissions[1:] + filename = filename[:len(filename)-1] + + if err != nil { + return "", "0644", 0, "", errors.New("Message parsed error") + } + + return flag, permissions, int64(size), filename, nil +} + +func replyOk(sess *ssh.Session) error { + bufferedWriter := bufio.NewWriter(*sess) + _, err := bufferedWriter.Write([]byte{responseOk}) + + if err != nil { + return err + } + + err = bufferedWriter.Flush() + if err != nil { + return err + } + return nil +} + +func replyErr(sess *ssh.Session, replyErr error) error { + bufferedWriter := bufio.NewWriter(*sess) + _, err := bufferedWriter.Write([]byte{responseError}) + _, err = bufferedWriter.Write([]byte(strings.ReplaceAll(replyErr.Error(), "\n", " "))) + _, err = bufferedWriter.Write([]byte{'\n'}) + + if err != nil { + return err + } + + err = bufferedWriter.Flush() + if err != nil { + return err + } + return nil +} diff --git a/core/sshd/sshd.go b/core/sshd/sshd.go index 3e8445e..fcd6425 100644 --- a/core/sshd/sshd.go +++ b/core/sshd/sshd.go @@ -1,12 +1,19 @@ package sshd import ( + "errors" "fmt" + "io/ioutil" + "net" + "strings" + "github.com/TNK-Studio/gortal/config" "github.com/TNK-Studio/gortal/utils" + "github.com/TNK-Studio/gortal/utils/logger" "github.com/fatih/color" "github.com/gliderlabs/ssh" "github.com/helloyi/go-sshclient" + gossh "golang.org/x/crypto/ssh" ) // GetClientByPasswd GetClientByPasswd @@ -46,6 +53,56 @@ func Connect(host string, port int, username string, privKeyFile string, sess *s return nil } +// NewSSHClient NewSSHClient +func NewSSHClient(server *config.Server, sshUser *config.SSHUser) (*gossh.Client, error) { + if !utils.FileExited(sshUser.IdentityFile) { + return nil, errors.New("Jumpserver can not find the identity file of the target server. ") + } + + key, err := ioutil.ReadFile(utils.FilePath(sshUser.IdentityFile)) + if err != nil { + logger.Logger.Error(err) + return nil, err + } + + signer, err := gossh.ParsePrivateKey(key) + if err != nil { + logger.Logger.Error(err) + return nil, err + } + + config := &gossh.ClientConfig{ + User: sshUser.SSHUsername, + Auth: []gossh.AuthMethod{ + gossh.PublicKeys(signer), + }, + HostKeyCallback: gossh.HostKeyCallback(func(hostname string, remote net.Addr, key gossh.PublicKey) error { return nil }), + } + + addr := fmt.Sprintf("%s:%d", server.Host, server.Port) + client, err := gossh.Dial("tcp", addr, config) + if err != nil { + logger.Logger.Error(err) + return nil, err + } + return client, nil +} + +// ParseRawCommand ParseRawCommand +func ParseRawCommand(command string) (string, []string, error) { + parts := strings.Split(command, " ") + + if len(parts) < 1 { + return "", nil, errors.New("No command in payload: " + command) + } + + if len(parts) < 2 { + return parts[0], []string{}, nil + } + + return parts[0], parts[1:], nil +} + // ErrorInfo ErrorInfo func ErrorInfo(err error, sess *ssh.Session) { read := color.New(color.FgRed) diff --git a/doc/README_CN.md b/doc/README_CN.md index 79ef7d2..c6a4cbd 100644 --- a/doc/README_CN.md +++ b/doc/README_CN.md @@ -3,7 +3,7 @@ [![Actions Status](https://img.shields.io/github/workflow/status/TNK-Studio/gortal/Build%20release)](https://github.com/TNK-Studio/gortal/actions)[![Docker build](https://img.shields.io/docker/cloud/build/elfgzp/gortal)](https://hub.docker.com/repository/docker/elfgzp/gortal)[![Docker build automated](https://img.shields.io/docker/cloud/automated/elfgzp/gortal)](https://hub.docker.com/repository/docker/elfgzp/gortal) [![Docker pull](https://img.shields.io/docker/pulls/elfgzp/gortal)](https://hub.docker.com/repository/docker/elfgzp/gortal)[![Release Download](https://img.shields.io/github/downloads/TNK-Studio/gortal/total)](https://github.com/TNK-Studio/gortal/releases) -一个使用 `Go` 语言开发的,超级轻量的堡垒机(跳板机)服务。[English Document](../README.md) | [中文文档](./doc/README_CN.md) +一个使用 `Go` 语言开发的,超级轻量的跳板机服务。[English Document](../README.md) | [中文文档](./doc/README_CN.md) ![gortal](./gortal.gif) @@ -35,6 +35,8 @@ starting ssh server on port 2222... ## 使用说明 +### 第一次使用 + gortal 服务启动后会在 `2222` 端口开启一个 `sshd` 服务,你也可以通过 `-p` 设置启动端口。 服务启动后,你只需要使用 `ssh` 命令访问该服务,注意访问的为你跳板机的 ip 或域名,这里演示本地 ip。 @@ -54,12 +56,28 @@ Shared connection to 127.0.0.1 closed. ```shell $ ssh root@127.0.0.1 -p 2222 root@127.0.0.1's password: -Use the arrow keys to navigate: ↓ ↑ → ← -? Please select the function you need: +Use the arrow keys to navigate: ↓ ↑ → ← +? Please select the function you need: ▸ List servers Edit users Edit servers + Edit personal info Quit ``` 再次使用你的密码登录后就可以使用了。 + +### 通过跳板机复制文件到服务器 + +如果你想通过跳板机复制文件到服务器,你可以通过 `scp` 命令按照以下格式: + +```shell +$ scp -P 2222 ~/Desktop/README.md gzp@jumpserver:gzp@server2:~/Desktop/README1.md +README.md 100% 9279 73.9KB/s 00:00 +``` + +注意在 `gzp@jumpserver` 后面用 `:` 加上你需要传输的服务器的 `key` 和 `username`,最后写上目标路径。目前不支持文件夹复制,请压缩文件后在上传。 + +### 通过跳板机从服务器复制文件 + +> 功能待开发 diff --git a/doc/gortal.gif b/doc/gortal.gif index f34ddf7..8d3473b 100644 Binary files a/doc/gortal.gif and b/doc/gortal.gif differ diff --git a/main.go b/main.go index 5e3e3c4..3bd53a2 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "github.com/TNK-Studio/gortal/core/jump" "github.com/TNK-Studio/gortal/core/sshd" "github.com/TNK-Studio/gortal/utils" + "github.com/TNK-Studio/gortal/utils/logger" "github.com/gliderlabs/ssh" ) @@ -25,6 +26,49 @@ func init() { hostKeyFile = flag.String("hk", "~/.ssh/id_rsa", "Host key file") } +func passwordAuth(ctx ssh.Context, pass string) bool { + config.Conf.ReadFrom(*config.ConfPath) + var success bool + if (len(*config.Conf.Users)) < 1 { + success = (pass == "newuser") + } else { + success = jump.VarifyUser(ctx, pass) + } + if !success { + time.Sleep(time.Second * 3) + } + return success +} + +func sessionHandler(sess *ssh.Session) { + defer func() { + (*sess).Close() + }() + + rawCmd := (*sess).RawCommand() + cmd, args, err := sshd.ParseRawCommand(rawCmd) + if err != nil { + sshd.ErrorInfo(err, sess) + return + } + + switch cmd { + case "scp": + sshd.ExecuteSCP(args, sess) + default: + sshHandler(sess) + } +} + +func sshHandler(sess *ssh.Session) { + jps := jump.Service{} + jps.Run(sess) +} + +func scpHandler(args []string, sess *ssh.Session) { + sshd.ExecuteSCP(args, sess) +} + func main() { flag.Parse() @@ -32,31 +76,20 @@ func main() { sshd.GenKey(*hostKeyFile) } - ssh.Handle(func(s ssh.Session) { + ssh.Handle(func(sess ssh.Session) { defer func() { - s.Close() + if e, ok := recover().(error); ok { + logger.Logger.Panic(e) + } }() - jps := jump.Service{} - jps.Run(&s) + sessionHandler(&sess) }) log.Printf("starting ssh server on port %d...\n", *Port) log.Fatal(ssh.ListenAndServe( fmt.Sprintf(":%d", *Port), nil, - ssh.PasswordAuth(func(ctx ssh.Context, pass string) bool { - config.Conf.ReadFrom(*config.ConfPath) - var success bool - if (len(*config.Conf.Users)) < 1 { - success = (pass == "newuser") - } else { - success = jump.VarifyUser(ctx, pass) - } - if !success { - time.Sleep(time.Second * 3) - } - return success - }), + ssh.PasswordAuth(passwordAuth), ssh.HostKeyFile(utils.FilePath(*hostKeyFile)), ), )