mirror of
https://github.com/veops/oneterm.git
synced 2025-10-08 00:30:12 +08:00
376 lines
9.1 KiB
Go
376 lines
9.1 KiB
Go
package sshsrv
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/help"
|
|
"github.com/charmbracelet/bubbles/key"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/charmbracelet/lipgloss/table"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/gliderlabs/ssh"
|
|
"github.com/samber/lo"
|
|
"github.com/spf13/cast"
|
|
"go.uber.org/zap"
|
|
"golang.org/x/sync/errgroup"
|
|
|
|
"github.com/veops/oneterm/acl"
|
|
"github.com/veops/oneterm/api/controller"
|
|
redis "github.com/veops/oneterm/cache"
|
|
"github.com/veops/oneterm/conf"
|
|
mysql "github.com/veops/oneterm/db"
|
|
"github.com/veops/oneterm/logger"
|
|
"github.com/veops/oneterm/model"
|
|
"github.com/veops/oneterm/session"
|
|
"github.com/veops/oneterm/sshsrv/textinput"
|
|
)
|
|
|
|
const (
|
|
prompt = "> "
|
|
hotPink = lipgloss.Color("#FF06B7")
|
|
darkGray = lipgloss.Color("#767676")
|
|
hisCmdsFmt = "hiscmds-%d"
|
|
)
|
|
|
|
var (
|
|
errStyle = lipgloss.NewStyle().Foreground(hotPink)
|
|
hintStyle = lipgloss.NewStyle().Foreground(darkGray)
|
|
hiddenBorder = lipgloss.HiddenBorder()
|
|
)
|
|
|
|
func init() {
|
|
hiddenBorder.Left = " "
|
|
}
|
|
|
|
type errMsg error
|
|
|
|
type keymap struct{}
|
|
|
|
func (k keymap) ShortHelp() []key.Binding {
|
|
return []key.Binding{
|
|
key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up")),
|
|
key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down")),
|
|
key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "complete")),
|
|
key.NewBinding(key.WithKeys("f5"), key.WithHelp("F5", "refresh")),
|
|
key.NewBinding(key.WithKeys("esc", "ctrl+c"), key.WithHelp("esc/ctrl+c", "quit")),
|
|
}
|
|
}
|
|
func (k keymap) FullHelp() [][]key.Binding {
|
|
return [][]key.Binding{k.ShortHelp()}
|
|
}
|
|
|
|
type view struct {
|
|
Ctx *gin.Context
|
|
Sess ssh.Session
|
|
currentUser *acl.Session
|
|
textinput textinput.Model
|
|
cmds []string
|
|
cmdsIdx int
|
|
combines map[string][3]int
|
|
connecting bool
|
|
help help.Model
|
|
keys keymap
|
|
r io.ReadCloser
|
|
w io.WriteCloser
|
|
gctx context.Context
|
|
}
|
|
|
|
func initialView(ctx *gin.Context, sess ssh.Session, r io.ReadCloser, w io.WriteCloser, gctx context.Context) *view {
|
|
currentUser, _ := acl.GetSessionFromCtx(ctx)
|
|
|
|
ti := textinput.New()
|
|
ti.Placeholder = "ssh"
|
|
ti.Focus()
|
|
ti.Prompt = prompt
|
|
ti.ShowSuggestions = true
|
|
ti.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("63"))
|
|
ti.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63"))
|
|
v := view{
|
|
Ctx: ctx,
|
|
Sess: sess,
|
|
currentUser: currentUser,
|
|
textinput: ti,
|
|
cmds: []string{},
|
|
help: help.New(),
|
|
r: r,
|
|
w: w,
|
|
gctx: gctx,
|
|
}
|
|
v.refresh()
|
|
|
|
return &v
|
|
}
|
|
|
|
func (m *view) Init() tea.Cmd {
|
|
return tea.Println(banner())
|
|
}
|
|
|
|
func (m *view) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
var (
|
|
hisCmd tea.Cmd
|
|
tiCmd tea.Cmd
|
|
)
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
switch msg.Type {
|
|
case tea.KeyCtrlC, tea.KeyEsc:
|
|
return m, tea.Quit
|
|
case tea.KeyEnter:
|
|
cmd := m.textinput.Value()
|
|
m.textinput.Reset()
|
|
if cmd == "" {
|
|
return m, tea.Batch(tea.Printf(prompt))
|
|
}
|
|
hisCmd = tea.Printf("> %s", cmd)
|
|
m.cmds = append(m.cmds, cmd)
|
|
ln := len(m.cmds)
|
|
if ln > 100 {
|
|
m.cmds = m.cmds[ln-100 : ln]
|
|
}
|
|
m.cmdsIdx = len(m.cmds) - 1
|
|
if cmd == "exit" {
|
|
return m, tea.Sequence(hisCmd, tea.Quit)
|
|
} else if strings.HasPrefix(cmd, "ssh") {
|
|
pty, _, _ := m.Sess.Pty()
|
|
m.Ctx.Request.URL.RawQuery = fmt.Sprintf("w=%d&h=%d", pty.Window.Width, pty.Window.Height)
|
|
m.Ctx.Params = nil
|
|
m.Ctx.Params = append(m.Ctx.Params, gin.Param{Key: "account_id", Value: cast.ToString(m.combines[cmd][0])})
|
|
m.Ctx.Params = append(m.Ctx.Params, gin.Param{Key: "asset_id", Value: cast.ToString(m.combines[cmd][1])})
|
|
m.Ctx.Params = append(m.Ctx.Params, gin.Param{Key: "protocol", Value: fmt.Sprintf("ssh:%d", m.combines[cmd][2])})
|
|
m.Ctx = m.Ctx.Copy()
|
|
m.connecting = true
|
|
return m, tea.Sequence(hisCmd, tea.Exec(&connector{Ctx: m.Ctx, Sess: m.Sess, Vw: m, gctx: m.gctx}, func(err error) tea.Msg {
|
|
m.connecting = false
|
|
return err
|
|
}), tea.Printf("%s", prompt), func() tea.Msg {
|
|
m.textinput.ClearMatched()
|
|
return nil
|
|
}, m.magicn)
|
|
}
|
|
case tea.KeyUp:
|
|
ln := len(m.cmds)
|
|
if ln <= 0 {
|
|
return m, nil
|
|
}
|
|
m.cmdsIdx = max(0, m.cmdsIdx-1)
|
|
m.textinput.SetValue(m.cmds[m.cmdsIdx])
|
|
case tea.KeyDown:
|
|
ln := len(m.cmds)
|
|
m.cmdsIdx++
|
|
if m.cmdsIdx >= ln {
|
|
m.cmdsIdx = ln - 1
|
|
m.textinput.SetValue("")
|
|
} else {
|
|
m.textinput.SetValue(m.cmds[m.cmdsIdx])
|
|
}
|
|
case tea.KeyF5:
|
|
m.refresh()
|
|
}
|
|
case errMsg:
|
|
if msg != nil {
|
|
str := msg.Error()
|
|
if ae, ok := msg.(*controller.ApiError); ok {
|
|
str = controller.Err2Msg[ae.Code].One
|
|
}
|
|
return m, tea.Printf(" [ERROR] %s\n\n", errStyle.Render(str))
|
|
}
|
|
}
|
|
m.textinput, tiCmd = m.textinput.Update(msg)
|
|
|
|
return m, tea.Batch(hisCmd, tiCmd)
|
|
}
|
|
|
|
func (m *view) View() string {
|
|
if m.connecting {
|
|
return "\n\n"
|
|
}
|
|
return fmt.Sprintf(
|
|
"%s\n %s\n%s",
|
|
m.textinput.View(),
|
|
m.help.View(m.keys),
|
|
hintStyle.Render(m.possible()),
|
|
) + "\n\n"
|
|
}
|
|
|
|
func (m *view) possible() string {
|
|
ss := m.textinput.MatchedSuggestions()
|
|
ln := len(ss)
|
|
if ln <= 0 {
|
|
return ""
|
|
}
|
|
ss = append(ss[:min(ln, 15)], lo.Ternary(ln > 15, fmt.Sprintf("%d more...", ln-15), ""))
|
|
mw := 0
|
|
for _, s := range ss {
|
|
mw = max(mw, lipgloss.Width(s))
|
|
}
|
|
pty, _, _ := m.Sess.Pty()
|
|
n := 1
|
|
for i := 2; i*mw+(i+1)*1 < pty.Window.Width; i++ {
|
|
n = i
|
|
}
|
|
tb := table.New().
|
|
Border(hiddenBorder).
|
|
StyleFunc(func(row, col int) lipgloss.Style { return hintStyle }).
|
|
Rows(lo.Chunk(ss, n)...)
|
|
return tb.Render()
|
|
}
|
|
|
|
func (m *view) refresh() {
|
|
auths := make([]*model.Authorization, 0)
|
|
assets := make([]*model.Asset, 0)
|
|
accounts := make([]*model.Account, 0)
|
|
dbAuth := mysql.DB.Model(auths)
|
|
dbAsset := mysql.DB.Model(assets)
|
|
dbAccount := mysql.DB.Model(accounts)
|
|
|
|
if !acl.IsAdmin(m.currentUser) {
|
|
rs, err := acl.GetRoleResources(ctx, m.currentUser.Acl.Rid, conf.GetResourceTypeName(conf.RESOURCE_AUTHORIZATION))
|
|
if err != nil {
|
|
logger.L().Error("auths", zap.Error(err))
|
|
return
|
|
}
|
|
dbAuth = dbAuth.Where("resource_id IN ?", lo.Map(rs, func(r *acl.Resource, _ int) int { return r.ResourceId }))
|
|
}
|
|
if err := dbAuth.Find(&auths).Error; err != nil {
|
|
logger.L().Error("auths", zap.Error(err))
|
|
return
|
|
}
|
|
dbAccount = dbAccount.Where("id IN ?", lo.Map(auths, func(a *model.Authorization, _ int) int { return a.AccountId }))
|
|
dbAsset = dbAsset.Where("id IN ?", lo.Map(auths, func(a *model.Authorization, _ int) int { return a.AssetId }))
|
|
|
|
eg := &errgroup.Group{}
|
|
eg.Go(func() error {
|
|
return dbAsset.Find(&assets).Error
|
|
})
|
|
eg.Go(func() error {
|
|
return dbAccount.Find(&accounts).Error
|
|
})
|
|
if err := eg.Wait(); err != nil {
|
|
logger.L().Error("refresh failed", zap.Error(err))
|
|
return
|
|
}
|
|
|
|
assetMap := lo.SliceToMap(assets, func(a *model.Asset) (int, *model.Asset) { return a.Id, a })
|
|
accountMap := lo.SliceToMap(accounts, func(a *model.Account) (int, *model.Account) { return a.Id, a })
|
|
|
|
m.combines = make(map[string][3]int)
|
|
for _, auth := range auths {
|
|
asset, ok := assetMap[auth.AssetId]
|
|
if !ok {
|
|
continue
|
|
}
|
|
account, ok := accountMap[auth.AccountId]
|
|
if !ok {
|
|
continue
|
|
}
|
|
k := fmt.Sprintf("ssh %s@%s", account.Name, asset.Name)
|
|
for _, p := range asset.Protocols {
|
|
if strings.HasPrefix(p, "ssh") {
|
|
ss := strings.Split(p, ":")
|
|
port := cast.ToInt(ss[1])
|
|
if len(ss) != 2 || port == 0 {
|
|
continue
|
|
}
|
|
m.combines[lo.Ternary(port == 22, k, fmt.Sprintf("%s:%s", k, ss[1]))] = [3]int{account.Id, asset.Id, port}
|
|
}
|
|
}
|
|
}
|
|
|
|
eg.Go(func() error {
|
|
var err error
|
|
if len(m.cmds) != 0 {
|
|
return err
|
|
}
|
|
m.cmds, err = redis.RC.LRange(m.Ctx, fmt.Sprintf(hisCmdsFmt, m.currentUser.GetUid()), -100, -1).Result()
|
|
m.cmdsIdx = len(m.cmds) - 1
|
|
return err
|
|
})
|
|
|
|
m.textinput.SetSuggestions(lo.Keys(m.combines))
|
|
}
|
|
|
|
func (m *view) magicn() tea.Msg {
|
|
m.w.Write([]byte("\n"))
|
|
return nil
|
|
}
|
|
|
|
func (m *view) RecordHisCmd() {
|
|
k := fmt.Sprintf(hisCmdsFmt, m.currentUser.GetUid())
|
|
redis.RC.RPush(m.Ctx, k, m.cmds)
|
|
redis.RC.LTrim(m.Ctx, k, -100, -1)
|
|
redis.RC.Expire(m.Ctx, k, time.Hour*24*30)
|
|
}
|
|
|
|
type connector struct {
|
|
Ctx *gin.Context
|
|
Sess ssh.Session
|
|
Vw *view
|
|
stdin io.Reader
|
|
stdout io.Writer
|
|
stderr io.Writer
|
|
gctx context.Context
|
|
}
|
|
|
|
func (conn *connector) SetStdin(r io.Reader) {
|
|
conn.stdin = r
|
|
}
|
|
|
|
func (conn *connector) SetStdout(w io.Writer) {
|
|
conn.stdout = w
|
|
}
|
|
|
|
func (conn *connector) SetStderr(w io.Writer) {
|
|
conn.stderr = w
|
|
}
|
|
|
|
func (conn *connector) Run() error {
|
|
gsess, err := controller.DoConnect(conn.Ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
conn.Vw.magicn()
|
|
|
|
r, w := io.Pipe()
|
|
go func() {
|
|
_, err := io.Copy(w, conn.stdin)
|
|
gsess.Chans.ErrChan <- err
|
|
}()
|
|
|
|
gsess.CliRw = &session.CliRW{
|
|
Reader: bufio.NewReader(r),
|
|
Writer: conn.stdout,
|
|
}
|
|
|
|
_, ch, _ := conn.Sess.Pty()
|
|
gsess.G.Go(func() error {
|
|
defer r.Close()
|
|
defer w.Close()
|
|
for {
|
|
select {
|
|
case <-conn.gctx.Done():
|
|
close(gsess.Chans.AwayChan)
|
|
return nil
|
|
case <-gsess.Gctx.Done():
|
|
return nil
|
|
case w := <-ch:
|
|
gsess.Chans.WindowChan <- w
|
|
}
|
|
}
|
|
})
|
|
controller.HandleSsh(gsess)
|
|
|
|
gsess.G.Wait()
|
|
|
|
conn.stdout.Write([]byte("\n\n"))
|
|
|
|
return nil
|
|
}
|