package sshd import ( "context" "errors" "fmt" "io" "next-terminal/server/api" "next-terminal/server/config" "next-terminal/server/constant" "next-terminal/server/global/cache" "next-terminal/server/global/session" "next-terminal/server/guacd" "next-terminal/server/log" "next-terminal/server/model" "next-terminal/server/repository" "next-terminal/server/service" "next-terminal/server/term" "next-terminal/server/totp" "next-terminal/server/utils" "path" "strings" "github.com/gliderlabs/ssh" "github.com/manifoldco/promptui" ) type Gui struct { } func (gui Gui) MainUI(sess *ssh.Session, user model.User) { prompt := promptui.Select{ Label: "欢迎使用 Next Terminal,请选择您要使用的功能", Items: []string{"我的资产", "退出系统"}, Stdin: *sess, Stdout: *sess, } MainLoop: for { _, result, err := prompt.Run() if err != nil { fmt.Printf("Prompt failed %v\n", err) return } switch result { case "我的资产": gui.AssetUI(sess, user) case "退出系统": break MainLoop } } } func (gui Gui) AssetUI(sess *ssh.Session, user model.User) { assets, err := repository.AssetRepository.FindByProtocolAndUser(context.TODO(), constant.SSH, user) if err != nil { return } quitItem := model.Asset{ID: "quit", Name: "返回上级菜单", Description: "这里是返回上级菜单的选项"} assets = append([]model.Asset{quitItem}, assets...) templates := &promptui.SelectTemplates{ Label: "{{ . }}?", Active: "\U0001F336 {{ .Name | cyan }} ({{ .IP | red }}:{{ .Port | red }})", Inactive: " {{ .Name | cyan }} ({{ .IP | red }}:{{ .Port | red }})", Selected: "\U0001F336 {{ .Name | red | cyan }}", Details: ` --------- 详细信息 ---------- {{ "名称:" | faint }} {{ .Name }} {{ "主机:" | faint }} {{ .IP }} {{ "端口:" | faint }} {{ .Port }} {{ "标签:" | faint }} {{ .Tags }} {{ "备注:" | faint }} {{ .Description }} `, } searcher := func(input string, index int) bool { asset := assets[index] name := strings.Replace(strings.ToLower(asset.Name), " ", "", -1) input = strings.Replace(strings.ToLower(input), " ", "", -1) return strings.Contains(name, input) } prompt := promptui.Select{ Label: "请选择您要访问的资产", Items: assets, Templates: templates, Size: 4, Searcher: searcher, Stdin: *sess, Stdout: *sess, } AssetUILoop: for { i, _, err := prompt.Run() if err != nil { fmt.Printf("Prompt failed %v\n", err) return } chooseAssetId := assets[i].ID switch chooseAssetId { case "quit": break AssetUILoop default: if err := gui.createSession(sess, chooseAssetId, user.ID); err != nil { _, _ = io.WriteString(*sess, err.Error()+"\r\n") return } } } } func (gui Gui) createSession(sess *ssh.Session, assetId, creator string) (err error) { asset, err := repository.AssetRepository.FindById(context.TODO(), assetId) if err != nil { return err } ClientIP := strings.Split((*sess).RemoteAddr().String(), ":")[0] s := &model.Session{ ID: utils.UUID(), AssetId: asset.ID, Username: asset.Username, Password: asset.Password, PrivateKey: asset.PrivateKey, Passphrase: asset.Passphrase, Protocol: asset.Protocol, IP: asset.IP, Port: asset.Port, Status: constant.NoConnect, Creator: creator, ClientIP: ClientIP, Mode: constant.Terminal, Upload: "0", Download: "0", Delete: "0", Rename: "0", StorageId: "", AccessGatewayId: asset.AccessGatewayId, } if asset.AccountType == "credential" { credential, err := repository.CredentialRepository.FindById(context.TODO(), asset.CredentialId) if err != nil { return nil } if credential.Type == constant.Custom { s.Username = credential.Username s.Password = credential.Password } else { s.Username = credential.Username s.PrivateKey = credential.PrivateKey s.Passphrase = credential.Passphrase } } if err := repository.SessionRepository.Create(context.TODO(), s); err != nil { return err } return gui.handleAccessAsset(sess, s.ID) } func (gui Gui) handleAccessAsset(sess *ssh.Session, sessionId string) (err error) { s, err := service.SessionService.FindByIdAndDecrypt(context.TODO(), sessionId) if err != nil { return err } var ( username = s.Username password = s.Password privateKey = s.PrivateKey passphrase = s.Passphrase ip = s.IP port = s.Port ) if s.AccessGatewayId != "" && s.AccessGatewayId != "-" { g, err := service.GatewayService.GetGatewayAndReconnectById(s.AccessGatewayId) if err != nil { return errors.New("获取接入网关失败:" + err.Error()) } if !g.Connected { return errors.New("接入网关不可用:" + g.Message) } exposedIP, exposedPort, err := g.OpenSshTunnel(s.ID, ip, port) if err != nil { return errors.New("开启SSH隧道失败:" + err.Error()) } defer g.CloseSshTunnel(s.ID) ip = exposedIP port = exposedPort } pty, winCh, isPty := (*sess).Pty() if !isPty { return errors.New("No PTY requested.\n") } recording := "" property, err := repository.PropertyRepository.FindByName(context.TODO(), guacd.EnableRecording) if err == nil && property.Value == "true" { recording = path.Join(config.GlobalCfg.Guacd.Recording, sessionId, "recording.cast") } nextTerminal, err := term.NewNextTerminal(ip, port, username, password, privateKey, passphrase, pty.Window.Height, pty.Window.Width, recording, pty.Term, false) if err != nil { return err } sshSession := nextTerminal.SshSession writer := NewWriter(sessionId, sess, nextTerminal.Recorder) sshSession.Stdout = writer sshSession.Stdin = *sess sshSession.Stderr = *sess if err := nextTerminal.RequestPty(pty.Term, pty.Window.Height, pty.Window.Width); err != nil { return err } if err := nextTerminal.Shell(); err != nil { return err } go func() { log.Debugf("开启窗口大小监控...") for win := range winCh { _ = sshSession.WindowChange(win.Height, win.Width) } log.Debugf("退出窗口大小监控") // ==== 修改数据库中的会话状态为已断开,修复用户直接关闭窗口时会话状态不正确的问题 ==== service.SessionService.CloseSessionById(sessionId, api.Normal, "用户正常退出") // ==== 修改数据库中的会话状态为已断开,修复用户直接关闭窗口时会话状态不正确的问题 ==== }() // ==== 修改数据库中的会话状态为已连接 ==== sessionForUpdate := model.Session{} sessionForUpdate.ID = sessionId sessionForUpdate.Status = constant.Connected sessionForUpdate.Recording = recording sessionForUpdate.ConnectedTime = utils.NowJsonTime() if sessionForUpdate.Recording == "" { // 未录屏时无需审计 sessionForUpdate.Reviewed = true } if err := repository.SessionRepository.UpdateById(context.TODO(), &sessionForUpdate, sessionId); err != nil { return err } // ==== 修改数据库中的会话状态为已连接 ==== nextSession := &session.Session{ ID: s.ID, Protocol: s.Protocol, Mode: s.Mode, NextTerminal: nextTerminal, Observer: session.NewObserver(s.ID), } go nextSession.Observer.Run() session.GlobalSessionManager.Add <- nextSession if err := sshSession.Wait(); err != nil { return err } // ==== 修改数据库中的会话状态为已断开 ==== service.SessionService.CloseSessionById(sessionId, api.Normal, "用户正常退出") // ==== 修改数据库中的会话状态为已断开 ==== return nil } func (gui Gui) totpUI(sess *ssh.Session, user model.User, remoteAddr string, username string) { validate := func(input string) error { if len(input) < 6 { return errors.New("双因素认证授权码必须为6个数字") } return nil } prompt := promptui.Prompt{ Label: "请输入双因素认证授权码", Validate: validate, Mask: '*', Stdin: *sess, Stdout: *sess, } var success = false for i := 0; i < 5; i++ { result, err := prompt.Run() if err != nil { fmt.Printf("Prompt failed %v\n", err) return } loginFailCountKey := remoteAddr + username v, ok := cache.LoginFailedKeyManager.Get(loginFailCountKey) if !ok { v = 1 } count := v.(int) if count >= 5 { _, _ = io.WriteString(*sess, "登录失败次数过多,请等待5分钟后再试\r\n") continue } if !totp.Validate(result, user.TOTPSecret) { count++ println(count) cache.LoginFailedKeyManager.Set(loginFailCountKey, count, cache.LoginLockExpiration) // 保存登录日志 _ = service.UserService.SaveLoginLog(remoteAddr, "terminal", username, false, false, "", "双因素认证授权码不正确") _, _ = io.WriteString(*sess, "您输入的双因素认证授权码不匹配\r\n") continue } success = true break } if success { // 保存登录日志 _ = service.UserService.SaveLoginLog(remoteAddr, "terminal", username, true, false, utils.UUID(), "") gui.MainUI(sess, user) } }