Files
oneterm/backend/pkg/proto/ssh/handler/interactive.go
2024-02-01 20:53:29 +08:00

796 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package handler
/**
Copyright (c) The Authors.
* @Author: feng.xiang
* @Date: 2023/12/13 09:50
* @Desc:
*/
package handler
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
"strings"
"sync"
"time"
"github.com/BurntSushi/toml"
"github.com/c-bata/go-prompt"
"github.com/chzyer/readline"
gossh "github.com/gliderlabs/ssh"
"github.com/mattn/go-runewidth"
"github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/olekukonko/tablewriter"
"github.com/patrickmn/go-cache"
"github.com/spf13/cast"
"github.com/veops/go-ansiterm"
"go.uber.org/zap"
"golang.org/x/crypto/ssh"
"golang.org/x/text/language"
myi18n "github.com/veops/oneterm/pkg/i18n"
"github.com/veops/oneterm/pkg/logger"
"github.com/veops/oneterm/pkg/proto/ssh/client"
"github.com/veops/oneterm/pkg/proto/ssh/config"
"github.com/veops/oneterm/pkg/server/model"
"github.com/veops/oneterm/pkg/util"
)
type InteractiveHandler struct {
Locker *sync.RWMutex
Session gossh.Session
//Term *term.Terminal
Term *readline.Instance
Prompt *prompt.Prompt
Localizer *i18n.Localizer
SshType int
pty *gossh.Pty
Sshd *sshdServer
Pty gossh.Pty
Language int
Assets []*model.Asset
Accounts map[int]*model.Account
Commands map[int]*model.Command
HistoryInput []string
SshClient *ssh.Client
SshSession map[string]*client.Connection
GatewayCloseChan chan struct{}
SelectedAsset *model.Asset
SessionReq *model.SshReq
AccountInfo *model.Account
NeedAccount bool
Parser *Parser
GatewayListener net.Listener
MessageChan chan string
AccountsForSelect []*model.Account
Cache *cache.Cache
}
type Parser struct {
Input *ansiterm.ByteStream
Output *ansiterm.ByteStream
InputData []byte
OutputData []byte
Ps1 string
Ps2 string
}
var (
Bundle = i18n.NewBundle(language.Chinese)
TotalSession = map[string]*client.Connection{}
)
func I18nInit(path string) {
Bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
files, err := util.ListFiles(path)
if err != nil {
logger.L.Error(err.Error(), zap.String("module", "i18n"))
}
for _, f := range files {
_, err = Bundle.LoadMessageFile(f)
if err != nil {
logger.L.Warn(err.Error(), zap.String("module", "i18n"))
}
}
}
func NewInteractiveHandler(s gossh.Session, ss *sshdServer, pty gossh.Pty) *InteractiveHandler {
//t := term.NewTerminal(s, "> ")
t, err := readline.NewEx(&readline.Config{
Stdin: s,
Stdout: s,
Prompt: ">",
})
if err != nil {
logger.L.Error(err.Error())
}
ih := &InteractiveHandler{
Locker: new(sync.RWMutex),
Term: t,
Session: s,
Sshd: ss,
SessionReq: &model.SshReq{},
SshSession: map[string]*client.Connection{},
Pty: pty,
MessageChan: make(chan string, 128),
Cache: cache.New(time.Minute, time.Minute*5),
}
ih.Language = 1
ih.Localizer = i18n.NewLocalizer(Bundle)
width := 120
height := 40
if pty.Window.Width != 0 {
width = pty.Window.Width
}
if pty.Window.Height != 0 {
height = pty.Window.Height
}
ih.Parser = &Parser{
Input: NewParser(width, height),
Output: NewParser(width, height),
}
return ih
}
func completer(d prompt.Document) []prompt.Suggest {
// 这里可以根据用户的实时输入来动态生成建议
suggestions := []prompt.Suggest{
{Text: "users", Description: "Store the username"},
{Text: "articles", Description: "Store the article text posted by user"},
}
// 只有当用户输入为空,或者以 'u' 开始时,才显示建议
if d.TextBeforeCursor() == "" || d.GetWordBeforeCursorWithSpace() == "u" {
return prompt.FilterHasPrefix(suggestions, d.GetWordBeforeCursor(), true)
}
// 其他情况不显示任何建议
return []prompt.Suggest{}
}
func NewParser(width, height int) *ansiterm.ByteStream {
screen := ansiterm.NewScreen(width, height)
stream := ansiterm.InitByteStream(screen, false)
stream.Attach(screen)
return stream
}
func (i *InteractiveHandler) WatchWinSize(winChan <-chan gossh.Window) {
for {
select {
case <-i.Session.Context().Done():
return
case win, ok := <-winChan:
if !ok {
return
}
for _, v := range i.SshSession {
client.ResizeSshClient(v.Session, win.Height, win.Width)
_ = v.Record.Resize(win.Height, win.Width)
}
}
}
}
func (i *InteractiveHandler) SwitchLanguage(lang string) {
languages := []string{"zh", "en"}
switch len(lang) {
case 0:
length := len(languages)
if length <= 1 {
return
}
if i.Language >= length {
i.Language = 1
} else {
i.Language += 1
}
default:
for index, v := range languages {
if v == lang {
i.Language = index + 1
}
}
}
i.Localizer = i18n.NewLocalizer(Bundle, languages[i.Language-1])
}
func (i *InteractiveHandler) SwitchLang(lang string) {
languages := []string{"zh", "en"}
switch len(lang) {
case 0:
length := len(languages)
if length <= 1 {
return
}
if i.Language >= length {
i.Language = 1
} else {
i.Language += 1
}
default:
for index, v := range languages {
if v == lang {
i.Language = index + 1
}
}
}
i.Localizer = i18n.NewLocalizer(Bundle, languages[i.Language-1])
i.PrintMessage(myi18n.MsgSshWelcome, map[string]any{"User": i.Session.User()})
}
func (i *InteractiveHandler) output(msg string) {
_, _ = io.WriteString(i.Session, msg)
}
func (i *InteractiveHandler) HostInfo(id int) (asset *model.Asset, err error) {
if id < 0 {
return
}
cookie, ok := i.Session.Context().Value("cookie").(string)
if !ok {
err = fmt.Errorf("no cookie")
return
}
res, er := i.Sshd.Core.Asset.Lists(cookie, "", id)
if er != nil {
err = er
return
}
if res.Count != 1 {
er = fmt.Errorf("found %d hosts: not unique", res.Count)
return
}
bs, er := json.Marshal(res.List[0])
if er != nil {
err = er
return
}
err = json.Unmarshal(bs, &asset)
if err != nil {
return
}
return asset, nil
}
func (i *InteractiveHandler) Check(id int, host *model.Asset) (asset *model.Asset, state bool, err error) {
assets, er := i.AcquireAssets("", id)
if er != nil {
err = err
return
}
if len(assets) == 0 {
return
}
asset = assets[0]
state = i.Sshd.Core.Asset.HasPermission(asset.AccessAuth)
return
}
func (i *InteractiveHandler) generateSessionRecord(conn *client.Connection, status int) (res *model.Session, err error) {
res = &model.Session{
SessionType: cast.ToInt(i.Session.Context().Value("sshType")),
}
if i.SessionReq != nil && i.SessionReq.SessionId != "" {
err = util.DecodeStruct(&res, i.SessionReq)
if err != nil {
return
}
res.Uid = i.SessionReq.Uid
} else {
res.ClientIp = i.Session.RemoteAddr().String()
res.UserName = i.Session.Context().User()
res.AccountInfo = fmt.Sprintf("%s(%s)", i.AccountInfo.Name, i.AccountInfo.Account)
res.Protocol = i.SessionReq.Protocol
}
s, er := i.Sshd.Core.Auth.AclInfo(i.Session.Context().Value("cookie").(string))
if er != nil {
logger.L.Warn(er.Error(), zap.String("session", "add"))
} else if s != nil {
res.Uid = s.Uid
res.UserName = s.UserName
}
res.Status = status
res.AssetInfo = fmt.Sprintf("%s(%s)", i.SelectedAsset.Name, i.SelectedAsset.Ip)
res.SessionId = conn.SessionId
res.GatewayId = i.SelectedAsset.GatewayId
if conn.Gateway != nil {
res.GatewayInfo = fmt.Sprintf("%s:%d", conn.Gateway.Host, conn.Gateway.Port)
}
res.AssetId = i.SelectedAsset.Id
res.AccountId = i.AccountInfo.Id
if status == model.SESSIONSTATUS_OFFLINE {
t := time.Now()
res.ClosedAt = &t
}
return
}
func readLine(s gossh.Session) string {
buf := make([]byte, 1)
var in []byte
for {
_, _ = s.Read(buf)
switch buf[0] {
case []byte("\r")[0], []byte("\r\n")[0]:
return string(in)
default:
in = append(in, buf[0])
}
}
}
func (i *InteractiveHandler) Schedule(pty *gossh.Pty) {
i.pty = pty
var err error
var line string
if st, ok := i.Session.Context().Value("sshType").(int); ok && st == model.SESSIONTYPE_WEB {
//line, err = i.Term.ReadLine()
line = readLine(i.Session)
if err != nil {
logger.L.Debug("connection closed", zap.String("msg", err.Error()))
return
}
var r *model.SshReq
err = json.Unmarshal([]byte(line), &r)
if err != nil {
logger.L.Warn(err.Error())
return
}
// "Accept-Language")
//i.Localizer = i18n.NewLocalizer(conf.Bundle, lang, accept)
i.Session.Context().SetValue("cookie", r.Cookie)
i.SessionReq = r
// monitor
{
if i.SessionReq.SessionId != "" {
switch i.SessionReq.Action {
case model.SESSIONACTION_MONITOR:
i.wrapJsonResponse(i.SessionReq.SessionId, 0, "success")
RegisterMonitorSession(i.SessionReq.SessionId, i.Session)
return
case model.SESSIONACTION_CLOSE:
if v, ok := config.TotalHostSession.Load(i.SessionReq.SessionId); ok {
err = v.(*client.Connection).Session.Close()
if err != nil {
logger.L.Warn(err.Error())
i.wrapJsonResponse(i.SessionReq.SessionId, 1, "failed")
return
}
close(v.(*client.Connection).Exit)
}
i.wrapJsonResponse(i.SessionReq.SessionId, 0, "success")
return
}
}
}
host, ok, err := i.Check(r.AssetId, nil)
if err != nil {
logger.L.Warn(err.Error())
i.wrapJsonResponse("", 1, err.Error())
return
}
if !ok {
i.wrapJsonResponse("", 1, fmt.Sprintf("invalid status for %v", r.AssetId))
return
}
i.SelectedAsset = host
commands, er := i.AcquireCommands()
if er != nil {
return
}
i.Commands = commands
_, err = i.Proxy(host.Ip, r.AccountId)
if err != nil {
logger.L.Error(err.Error(), zap.String("module", "proxy"))
}
return
} else {
if config.SSHConfig.PlainMode {
i.SwitchLang("zh")
for {
//line, err = i.Term.ReadLine()
line, err = i.Term.Readline()
if err != nil {
logger.L.Debug("connection closed", zap.String("msg", err.Error()))
break
}
if strings.TrimSpace(line) == "" {
continue
}
if i.HandleInput(strings.TrimSpace(line)) {
break
}
}
} else {
tm := InitAndRunTerm(i)
_, err := tm.Run()
if err != nil {
logger.L.Error(err.Error(), zap.String("module", "schedule"))
}
}
}
}
func (i *InteractiveHandler) HandleInput(line string) (exit bool) {
switch strings.TrimSpace(line) {
case "/*":
i.SelectedAsset = nil
assets, er := i.AcquireAssets("", 0)
if er != nil {
return
}
accounts, er := i.AcquireAccounts()
if er != nil {
return
}
commands, er := i.AcquireCommands()
if er != nil {
return
}
i.Locker.Lock()
i.Assets = assets
i.Accounts = accounts
i.Commands = commands
i.Locker.Unlock()
i.showResult(assets)
return
case "/?", "/":
i.PrintMessage(myi18n.MsgSshWelcome, map[string]any{"User": i.Session.User()})
return
case "/s":
i.SwitchLang("")
return
case "/q":
i.Session.Close()
return
default:
switch {
case line == "exit":
logger.L.Info("exit", zap.String("user", i.Session.User()), zap.String("input", line))
i.Session.Close()
return
}
}
_, er := i.Proxy(line, -1)
if er != nil {
logger.L.Info(er.Error())
}
if st, ok := i.Session.Context().Value("sshType").(int); ok && st == model.SESSIONTYPE_WEB {
exit = true
}
return
}
func (i *InteractiveHandler) AcquireAndStoreAssets(search string, id int) (selectedHosts, likeHosts []*model.Asset, err error) {
i.Locker.RLock()
count := len(i.Assets)
i.Locker.RUnlock()
var find = func(assets []*model.Asset) (selectedHosts, likeHosts []*model.Asset) {
if search == "" {
return
}
for _, v := range assets {
if v.Ip == search || v.Name == search {
selectedHosts = append(selectedHosts, v)
} else if strings.Contains(v.Ip, search) || strings.Contains(v.Name, search) {
likeHosts = append(likeHosts, v)
}
}
return
}
if count == 0 {
res, er := i.AcquireAssets(search, id)
if er != nil {
err = er
return
}
selectedHosts, likeHosts = find(res)
i.Locker.Lock()
i.Assets = res
i.Locker.Unlock()
return
} else {
i.Locker.Lock()
selectedHosts, likeHosts = find(i.Assets)
i.Locker.Unlock()
return
}
}
func (i *InteractiveHandler) AcquireAssets(search string, id int) (assets []*model.Asset, err error) {
if search == "" && id <= 0 {
if v, ok := i.Cache.Get("assets"); ok {
return v.([]*model.Asset), nil
} else {
defer func() {
if err == nil {
i.Cache.Set("assets", assets, 0)
}
}()
}
}
if totalAssets, ok := i.Cache.Get("assets"); ok {
for _, v := range totalAssets.([]*model.Asset) {
if id > 0 {
if id == v.Id {
assets = append(assets, v)
return
} else {
continue
}
} else {
if strings.Contains(v.Name, search) {
assets = append(assets, v)
}
}
}
} else {
cookie, ok := i.Session.Context().Value("cookie").(string)
if ok {
res, er := i.Sshd.Core.Asset.Lists(cookie, search, id)
if er != nil {
err = er
return
}
if res != nil {
for _, v := range res.List {
var v1 model.Asset
_ = util.DecodeStruct(&v1, v)
bs, _ := json.Marshal(v.(map[string]interface{})["authorization"])
er = json.Unmarshal(bs, &v1.Authorization)
if er != nil {
logger.L.Warn(er.Error())
}
assets = append(assets, &v1)
}
}
} else {
err = fmt.Errorf("no cookies")
}
}
return
}
func (i *InteractiveHandler) AcquireAccounts() (accounts map[int]*model.Account, err error) {
accounts = map[int]*model.Account{}
cookie, ok := i.Session.Context().Value("cookie").(string)
if ok {
res, er := i.Sshd.Core.Auth.Accounts(cookie)
if er != nil {
err = er
return
}
for _, v := range res {
var v1 model.Account
_ = util.DecodeStruct(&v1, v)
accounts[v1.Id] = &v1
}
} else {
err = fmt.Errorf("no cookies")
}
return
}
func (i *InteractiveHandler) AcquireAccountInfo(id int, name string) (res *model.Account, err error) {
cookie, ok := i.Session.Context().Value("cookie").(string)
if ok {
return i.Sshd.Core.Auth.AccountInfo(cookie, id, name)
} else {
err = fmt.Errorf("no cookies")
}
return
}
func (i *InteractiveHandler) AcquireCommands() (commands map[int]*model.Command, err error) {
commands = map[int]*model.Command{}
cookie, ok := i.Session.Context().Value("cookie").(string)
if ok {
res, er := i.Sshd.Core.Asset.Commands(cookie)
if er != nil {
err = er
return
}
for _, v := range res {
var v1 model.Command
_ = util.DecodeStruct(&v1, v)
commands[v1.Id] = &v1
}
} else {
err = fmt.Errorf("no cookies")
}
return
}
func (i *InteractiveHandler) AcquireConfig() (config *model.Config, err error) {
config = &model.Config{}
cookie, ok := i.Session.Context().Value("cookie").(string)
if ok {
res, er := i.Sshd.Core.Asset.Config(cookie)
if er != nil {
err = er
return
}
config = res
} else {
err = fmt.Errorf("no cookies")
}
return
}
func (i *InteractiveHandler) showResult(data []*model.Asset) {
i.Term.SetPrompt("host> ")
var hosts []string
for _, d := range data {
hosts = append(hosts, d.Name)
}
var templateData = map[string]interface{}{
"Count": len(data),
"Msg": "",
}
if data != nil {
templateData["Msg"] = i.tableData(hosts)
}
i.PrintMessage(myi18n.MsgSshShowAssetResults, templateData)
}
func (i *InteractiveHandler) tableData(data []string) string {
chunkData := i.chunkData(data)
buf := &bytes.Buffer{}
tw := tablewriter.NewWriter(buf)
tw.SetAutoWrapText(false)
tw.SetColumnSeparator(" ")
tw.SetNoWhiteSpace(false)
tw.SetBorder(false)
tw.SetAlignment(tablewriter.ALIGN_LEFT)
tw.AppendBulk(chunkData)
tw.Render()
return buf.String()
}
func (i *InteractiveHandler) chunkData(data []string) (res [][]string) {
width := 80
if i.pty != nil {
width = i.pty.Window.Width
}
n := len(data)
chunk := n
for ; chunk >= 1; chunk -= 1 {
ok := true
for i := 0; i < n && ok; i += chunk {
w := chunk*3 + 4
r := i + chunk
if r > n {
r = n
}
for _, s := range data[i:r] {
w += runewidth.StringWidth(s)
}
ok = ok && w <= width
}
if ok {
t := i.getChunk(data, chunk)
maxLen := make(map[int]int)
for _, c := range t {
for i, v := range c {
l := runewidth.StringWidth(v)
if l > maxLen[i] {
maxLen[i] = l
}
}
}
for _, row := range t {
w := chunk*3 + 4
for i := range row {
w += maxLen[i]
}
ok = ok && w <= width
}
}
if ok {
break
}
}
if chunk < 1 {
chunk = 1
}
res = i.getChunk(data, chunk)
return
}
func (i *InteractiveHandler) getChunk(data []string, chunk int) (res [][]string) {
n := len(data)
for i := 0; i < n; i += chunk {
r := i + chunk
if r > n {
r = n
}
res = append(res, data[i:r])
}
return
}
func (i *InteractiveHandler) wrapJsonResponse(sessionId string, code int, message string) {
if st, ok := i.Session.Context().Value("sshType").(int); ok && st != model.SESSIONTYPE_WEB {
return
}
res, er := json.Marshal(model.SshResp{
Code: code,
Message: message,
SessionId: sessionId,
Uid: i.SessionReq.Uid,
UserName: i.SessionReq.UserName,
})
if er != nil {
logger.L.Error(er.Error())
}
i.output(string(append(res, []byte("\r")...)))
}
func (i *InteractiveHandler) NewSession(account *model.Account, gateway *model.Gateway) (conn *client.Connection, err error) {
i.Locker.Lock()
defer i.Locker.Unlock()
if i.SshClient == nil {
con, ch, er := client.NewSShClient(strings.ReplaceAll(i.SessionReq.Protocol, "ssh", i.SelectedAsset.Ip), account, gateway)
if er != nil {
err = er
return
}
i.SshClient = con
i.GatewayCloseChan = ch
}
i.AccountInfo = account
conn, err = client.NewSShSession(i.SshClient, i.Pty, i.GatewayCloseChan)
if err != nil {
return
}
conn.AssetId = i.SelectedAsset.Id
conn.AccountId = account.Id
conn.Gateway = gateway
i.SshSession[conn.SessionId] = conn
return
}
func (i *InteractiveHandler) UpsertSession(conn *client.Connection, status int) error {
resp, err := i.generateSessionRecord(conn, status)
if err != nil {
return err
}
return i.Sshd.Core.Audit.NewSession(resp)
}