Skip to content

Commit

Permalink
perf:兼容ssh登录时的密码修改提示&修复把用户名或密码输入提示误判为命令结束提示符的问题
Browse files Browse the repository at this point in the history
  • Loading branch information
3th1nk committed Apr 12, 2024
1 parent 1beae8a commit 4ff4312
Show file tree
Hide file tree
Showing 13 changed files with 284 additions and 141 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* 支持自定义提示符匹配规则,大多数情况下使用默认提示符规则即可,使用默认提示符规则时可开启自动纠正(基于默认规则首次匹配结果,默认关闭)
* 支持自定义解码器,默认自动识别GB18030编码并转换成UTF8
* 支持自定义字符过滤器,默认自动处理退格、CRLF自动转换为LF,并剔除CSI控制字符(部分情况未处理,如:ISO 8613-3和ISO 8613-6中24位前景色和背景色设置)
* 支持自定义内容拦截器,内置拦截器包括密码交互(Password)、问答交互(Yes/No)、网络设备自动翻页(More)、网络设备继续执行(Continue)
* 支持自定义内容拦截器,内置拦截器包括密码交互(Password)、选项交互(Yes/No)、网络设备自动翻页(More)、网络设备继续执行(Continue)
* 支持延迟返回输出内容,可指定超过一定时间 或 内容大小 后返回
* 支持记录原始输出内容和回放,用于调试

Expand Down
18 changes: 18 additions & 0 deletions core/pattern.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package core

import (
"github.com/3th1nk/easyshell/pkg/interceptor"
"regexp"
)

const (
DefaultPromptTailChars = `$#%>\]:`
DefaultPromptSuffixPattern = `.*[` + DefaultPromptTailChars + `]\s*$`
)

var (
DefaultPromptRegex = regexp.MustCompile(`\S+` + DefaultPromptSuffixPattern)
FlexibleOptionPromptRegex = regexp.MustCompile(interceptor.FlexibleOptionPromptPattern)
UsernameRegex = regexp.MustCompile(`(?i).*(login|user(name)?):\s*$`)
PasswordRegex = regexp.MustCompile(`(?i).*pass(word)?:\s*$`)
)
103 changes: 54 additions & 49 deletions core/read_writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,10 @@ import (
"time"
)

const (
DefaultPromptTailChars = `$#%>\]:`
DefaultPromptSuffix = `[\s\S]*[` + DefaultPromptTailChars + `]\s*$`
)

var (
defaultInterceptors = []interceptor.Interceptor{
interceptor.More(),
interceptor.Continue(),
}
defaultPromptRegex = regexp.MustCompile(`\S+` + DefaultPromptSuffix)
)
var defaultInterceptors = []interceptor.Interceptor{
interceptor.More(),
interceptor.Continue(),
}

func New(in io.Writer, out, err io.Reader, cfg Config) *ReadWriter {
if misc.IsNil(in) {
Expand Down Expand Up @@ -153,24 +145,8 @@ func (r *ReadWriter) Read(ctx context.Context, stopOnEndLine bool, onOut func(li
onOut(lines)
}

// 命令行结束提示符
if remaining != "" && r.IsEndLine(remaining) {
r.prompt = remaining
// 仅当默认的提示符匹配规则匹配上 且 AutoPrompt=true 时,尝试自动纠正提示符匹配规则
if len(r.cfg.PromptRegex) == 0 && r.cfg.AutoPrompt {
if re := findPromptRegex(remaining); re != nil {
r.cfg.PromptRegex = append(r.cfg.PromptRegex, re)
util.PrintTimeLn("correct end prompt regex:" + re.String())
}
}
stop = stopOnEndLine
return !r.cfg.ShowPrompt
} else {
stop = false
}

if len(interceptors) != 0 {
// 缓存区所有内容
// 匹配优先级:指定的拦截器规则 > 命令结束提示符规则
if len(interceptors) > 0 {
if outBuf.Len() > 0 {
outBuf.WriteString("\n")
}
Expand All @@ -181,29 +157,49 @@ func (r *ReadWriter) Read(ctx context.Context, stopOnEndLine bool, onOut func(li
}
for _, f := range interceptors {
if match, showOut, input := f(outBuf.String()); match {
//util.PrintTimeLn("interceptor match: %x", remaining)
//util.PrintTimeLn("interceptor matched: %v => %v", outBuf.String(), input)
outBuf.Reset()
// TODO 如果是匹配多行内容的拦截器,前面行的内容总是被返回了,后续优化
if showOut && remaining != "" && onOut != nil {
onOut([]string{remaining})
_ = r.WriteRaw([]byte(input))
return !showOut
}
}
}

stop = false
// 命令行结束提示符
if remaining != "" && r.IsEndLine(remaining) {
if len(r.cfg.PromptRegex) == 0 {
// 如果是默认的提示符匹配规则匹配上,可能是选项提示符,也有可能是主机名提示符
if !FlexibleOptionPromptRegex.MatchString(remaining) {
// 当不是选项提示符且AutoPrompt=true时,尝试自动纠正主机名提示符匹配规则
if r.cfg.AutoPrompt {
if re := findPromptRegex(remaining); re != nil {
r.cfg.PromptRegex = append(r.cfg.PromptRegex, re)
util.PrintTimeLn("prompt:" + remaining + ", correct prompt regex:" + re.String())
}
}
_, _ = r.in.Write([]byte(input))
return true
r.prompt = remaining
stop = stopOnEndLine
return !r.cfg.ShowPrompt
}
} else {
r.prompt = remaining
stop = stopOnEndLine
return !r.cfg.ShowPrompt
}
}

// 最后一行内容
if remaining != "" {
for _, f := range defaultInterceptors {
if match, showOut, input := f(remaining); match {
//util.PrintTimeLn("default interceptor match: %x", remaining)
outBuf.Reset()
if showOut && onOut != nil {
onOut([]string{remaining})
}
_, _ = r.in.Write([]byte(input))
return true
_ = r.WriteRaw([]byte(input))
return !showOut
}
}
}
Expand Down Expand Up @@ -276,21 +272,30 @@ exit:
}

func (r *ReadWriter) IsEndLine(s string) bool {
var matched bool
if len(r.cfg.PromptRegex) != 0 {
for _, v := range r.cfg.PromptRegex {
if v != nil && v.MatchString(s) {
//util.PrintTimeLn("prompt matched:" + s)
return true
// util.PrintTimeLn("prompt matched:" + s)
matched = true
}
}
return false
}

if defaultPromptRegex.MatchString(s) {
//util.PrintTimeLn("default prompt matched:" + s)
return true
if !matched && DefaultPromptRegex.MatchString(s) {
// util.PrintTimeLn("default prompt matched:" + s)
matched = true
}
return false

// 由于尾缀特征字符的缘故,可能误匹配,但目前没有更优的规则,先把已知的误匹配场景排除
// 如:
// [testuser@localhost ~]$ Username:
// [testuser@localhost ~]$ Password:
if matched && (UsernameRegex.MatchString(s) || PasswordRegex.MatchString(s)) {
matched = false
}

return matched
}

// findPromptRegex
Expand Down Expand Up @@ -321,19 +326,19 @@ func findPromptRegex(remaining string) *regexp.Regexp {

var pattern string
if prefix != "" {
pattern = fmt.Sprintf(`(?i)(%v|%v)%v`, hostname, prefix, DefaultPromptSuffix)
pattern = fmt.Sprintf(`(?i)(%v|%v)%v`, hostname, prefix, DefaultPromptSuffixPattern)
} else {
pattern = fmt.Sprintf(`(?i)%v%v`, hostname, DefaultPromptSuffix)
pattern = fmt.Sprintf(`(?i)%v%v`, hostname, DefaultPromptSuffixPattern)
}
if re, err := regexp.Compile(pattern); err == nil {
return re
}

// 主机名中可能包含特殊字符,如果正则编译失败,尝试转义后再次编译
if prefix != "" {
pattern = fmt.Sprintf(`(?i)(%v|%v)%v`, regexp.QuoteMeta(hostname), regexp.QuoteMeta(prefix), DefaultPromptSuffix)
pattern = fmt.Sprintf(`(?i)(%v|%v)%v`, regexp.QuoteMeta(hostname), regexp.QuoteMeta(prefix), DefaultPromptSuffixPattern)
} else {
pattern = fmt.Sprintf(`(?i)%v%v`, regexp.QuoteMeta(hostname), DefaultPromptSuffix)
pattern = fmt.Sprintf(`(?i)%v%v`, regexp.QuoteMeta(hostname), DefaultPromptSuffixPattern)
}
if re, err := regexp.Compile(pattern); err == nil {
return re
Expand Down
12 changes: 9 additions & 3 deletions core/read_writer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import (
)

func TestDefaultPromptRegex(t *testing.T) {
var rw ReadWriter
for _, obj := range []struct {
Prompt string
Matched bool
Prompt string
Expect bool
}{
{"root@test-01 $", true},
{"root@test-01 #", true},
Expand All @@ -33,8 +34,13 @@ func TestDefaultPromptRegex(t *testing.T) {
{"$", false},
{" # ", false},
{"[[email protected] /home/mon]", true},
{"[testuser@localhost ~]$ Login:", false},
{"[testuser@localhost ~]$ Username:", false},
{"[testuser@localhost ~]$ Password:", false},
} {
assert.Equal(t, obj.Matched, defaultPromptRegex.MatchString(obj.Prompt))
if obj.Expect != rw.IsEndLine(obj.Prompt) {
t.Error(obj.Prompt)
}
}
}

Expand Down
7 changes: 4 additions & 3 deletions internal/lineReader/line_reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,14 +158,15 @@ func (lr *LineReader) PopLines(f func(lines []string, remaining string) (dropRem
return 0, nil
}

droppedLines, droppedRemaining := len(lr.lines), 0
var droppedRemaining int
if f(lr.lines, lr.remaining) {
lr.remaining = ""
droppedRemaining = 1
}

if droppedLines != 0 {
lr.lines = make([]string, 0, 4)
droppedLines := len(lr.lines)
if droppedLines > 0 {
lr.lines = lr.lines[:0]
}

return droppedLines + droppedRemaining, lr.err
Expand Down
2 changes: 1 addition & 1 deletion pkg/filter/ansi_escape.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ package filter
//
// TODO 对于修改内容的控制符,应该同时处理对应的内容
//
// 由于当前函数是在每次读操作之后调用,涉及修改的内容可能还未被完整的读取到缓存区,可能导致异常
// 由于当前函数是在每次读操作之后调用,涉及修改的内容可能还未被完整的读取到缓冲区,可能导致异常
func checkAnsiEscape(s []byte, pos int) (bool, int, [2]int) {
length := len(s)
if pos >= length || s[pos] != '\x1b' {
Expand Down
11 changes: 0 additions & 11 deletions pkg/interceptor/answer.go

This file was deleted.

33 changes: 0 additions & 33 deletions pkg/interceptor/answer_test.go

This file was deleted.

56 changes: 56 additions & 0 deletions pkg/interceptor/option.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package interceptor

import (
"github.com/3th1nk/easygo/util/strUtil"
"regexp"
"strings"
)

const (
// DefaultOptionPromptPattern 默认选项的匹配规则
DefaultOptionPromptPattern = `(?i)[\[(]y(es)?[/|]no?[\])][?:]\s*$`
// FlexibleOptionPromptPattern 灵活选项的匹配规则
FlexibleOptionPromptPattern = `(?i)[\[(][a-z]+([/|][a-z\[\]]+)+[\])][?:]\s*$`
)

func AlwaysYes(showOut ...bool) Interceptor {
showOut = append(showOut, false)
return Regexp(regexp.MustCompile(DefaultOptionPromptPattern), AppendLF("y"), LastLine, showOut...)
}

func AlwaysNo(showOut ...bool) Interceptor {
showOut = append(showOut, false)
return Regexp(regexp.MustCompile(DefaultOptionPromptPattern), AppendLF("n"), LastLine, showOut...)
}

func AlwaysOption(optionIndex int, showOut ...bool) Interceptor {
showOut = append(showOut, false)
return func(str string) (bool, bool, string) {
str = LastLine(str)
if re := regexp.MustCompile(FlexibleOptionPromptPattern); re.MatchString(str) {
// 截取选项部分,并去掉前后的括号,例如 [yes/no] -> yes/no
if idx := strings.IndexAny(str, "[("); idx != -1 {
str = str[idx+1:]
}
if idx := strings.IndexAny(str, ")]"); idx != -1 {
str = str[:idx]
}
// 获取所有选项,这里options一定不为空(由匹配正则保证)
options := strUtil.Split(str, "/|", false, func(s string) string {
// 有的选项是可选的,需要去掉括号,例如 yes/no/[fingerprint]
s = strings.TrimPrefix(s, "[")
s = strings.TrimSuffix(s, "]")
return s
})

// 选择的选项不能超出范围
var input string
if optionIndex >= 0 && optionIndex < len(options) {
input = strings.TrimSpace(options[optionIndex])
}
return true, showOut[0], AppendLF(input)
}

return false, false, ""
}
}
Loading

0 comments on commit 4ff4312

Please sign in to comment.