From 03942085c25981daf3dc2f07d1f7fd1d546c2055 Mon Sep 17 00:00:00 2001 From: Eric_Lee Date: Wed, 4 Nov 2026 17:06:12 +0800 Subject: [PATCH] fix: preserve built-in login prompts with custom rules --- CHANGELOG.md | 39 +++++++ README.md | 65 ++++++++--- VERSION | 2 +- config.go | 25 ++-- docs/login-stream.md | 40 ++++++- login.go | 34 ++++-- login_additive_prompt_test.go | 209 ++++++++++++++++++++++++++++++++++ login_compat_test.go | 14 ++- login_custom_prompt_test.go | 15 +-- login_test.go | 10 +- 10 files changed, 400 insertions(+), 53 deletions(-) create mode 100644 login_additive_prompt_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index cb27f5c..8be683d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,45 @@ The Go library and `tclient` command share the same version. Entries describe user-visible changes; the GitHub Release also lists the commits since the previous tag. +## v0.1.2 + +### Changed + +- Custom `UsernamePrompt`, `PasswordPrompt`, and `ShellPrompt` now supplement + their built-in matchers. Either can recognize a prompt, so a shared platform + configuration can add device-specific alternatives while retaining standard + login prompts. Callers no longer need to combine built-in and custom rules. + The built-in shell branch retains its shape, length, and banner checks. +- Built-in credential matching also recognizes short prompts without a colon + on completed CRLF/LF lines: `Login`, `User`, `Username`, `User Name`, `Name`, + `用户名`, `账号`, `Password`, `Passwd`, `Passcode`, `密码`, and `口令`. These are + bounded, case-insensitive whole-line matches. Unfinished built-in prompts + and longer forms such as `Password for admin:` still require prompt + punctuation, so a fragmented `Username:` or password-policy banner is not + mistaken for a bare credential prompt. Devices that wait at a bare prompt + without a newline can use an explicit custom expression. No timer or delay + is added. Login continues to match the current normalized logical line + rather than the full received transcript. +- Custom expressions keep ordinary `regexp.Match` semantics, including + substring matching, explicit anchors, and case flags. No matching-mode + parameter is added. `FailurePrompt`, `PasswordChangePrompt`, and + `ContinuePrompt` still replace their respective built-in expressions, with + the existing diagnostic completion and precedence rules unchanged. + +### Upgrade notes + +- Unlike v0.1.0 and v0.1.1, setting a username, password, or shell expression + no longer disables that event's built-in matcher. Anchors limit only the + custom branch: `ShellPrompt: ^READY>$` also accepts built-in prompts such as + `router#`. Review configurations that used custom expressions to narrow + default recognition. +- The library does not restore broad built-in success keywords such as + `Last login`, `success`, or `成功`. Adapters such as Koko should still migrate + exact known legacy default values to the current defaults and retain their + application-side validation of expressions that match the empty string. + Genuine custom expressions can be passed directly without a `.*` wrapper or + a copy of the built-in rules. + ## v0.1.1 ### Changed diff --git a/README.md b/README.md index fd80a26..9051a0f 100644 --- a/README.md +++ b/README.md @@ -140,12 +140,12 @@ err := client.SendCommand(tclientlib.CommandBreak) ## Login matching -Telnet does not define an authentication protocol. The client therefore treats +Textual username and password prompts are device-specific. The client treats login as a small expect-style state machine instead of searching an entire banner for broad success or failure words. -The state machine keeps authentication decisions strict while allowing common -prompt wording: +The state machine recognizes common prompt wording while giving explicit +failure and password-change messages precedence: - username prompts include forms such as `Login:`, `Enter username:`, `Please enter your username:`, and `Login as:`; @@ -172,21 +172,36 @@ Before matching, login output is normalized into logical terminal lines: Username, password, shell, failure, password-change, and continuation expressions are matched against the current normalized line with surrounding -whitespace trimmed. A custom expression replaces the built-in expression for -that event and uses normal `regexp.Match` semantics: an unanchored expression -can match a substring, while `^` and `$` control its boundaries. For example, -`READY>` matches `console READY>`; `^READY>$` requires the whole normalized -line to be `READY>`. No `.*` wrapper or matching-mode option is needed. +whitespace trimmed. Every custom expression uses normal `regexp.Match` +semantics: an unanchored expression can match a substring, while `^` and `$` +control its boundaries. For example, `READY>` matches `console READY>`; +`^READY>$` requires the whole normalized line to be `READY>` for that expression +to match. No `.*` wrapper or matching-mode option is needed. + +| Fields | Custom rule behavior | +| --- | --- | +| `UsernamePrompt`, `PasswordPrompt`, `ShellPrompt` | Supplement the built-in matcher. Either rule can recognize the prompt. | +| `FailurePrompt`, `PasswordChangePrompt`, `ContinuePrompt` | Replace the built-in expression for that event. | + +Supplemental rules allow one platform configuration to recognize both standard +and device-specific prompts. For example, setting `UsernamePrompt` to +`^Account:$` still allows the built-in `Login:` prompt, and setting `ShellPrompt` +to `^READY>$` still allows a built-in prompt such as `router#`. Anchors and case +flags constrain the custom expression only; these three fields cannot disable +their built-in matchers. A nil field uses the corresponding built-in behavior. Built-in rules keep their own anchors and recognition boundaries. `ShellPrompt` should describe the final interactive prompt, not a success message in a banner. -With the built-in rules, text such as `Last login`, `Login successful`, or `成功` -does not establish successful authentication. +The built-in shell branch retains its shape, length, and banner checks even +when `ShellPrompt` is set. With the built-in rules, text such as `Last login`, +`Login successful`, or `成功` does not establish successful authentication. For example: ```go -config.ShellPrompt = regexp.MustCompile(`^(?:<[^>]+>|router(?:\([^)]*\))?[#>])$`) +config.UsernamePrompt = regexp.MustCompile(`^Account:$`) +config.PasswordPrompt = regexp.MustCompile(`Access code:`) +config.ShellPrompt = regexp.MustCompile(`^READY>$`) config.FailurePrompt = regexp.MustCompile(`(?:Login incorrect|Access denied)`) config.ContinuePrompt = regexp.MustCompile(`Press Enter to open the console`) ``` @@ -196,9 +211,31 @@ EOF, or the login timeout, and retain precedence over interactive prompt actions Matching stays within one normalized line; it does not search previous lines or the whole login transcript. -Built-in username and password prompts deliberately require prompt punctuation. -An unusual device that emits a bare prompt without `:` or `:` should provide a -platform-specific expression rather than making the global fallback ambiguous. +For compatibility, the built-in credential rules also recognize these short +prompts without `:` or `:` on a completed `CRLF` or `LF` line: + +- usernames: `Login`, `User`, `Username`, `User Name`, `Name`, `用户名`, and `账号`; +- passwords: `Password`, `Passwd`, `Passcode`, `密码`, and `口令`. + +These matches are case-insensitive and must fill the completed normalized line; +spacing is allowed in forms such as `User Name`, `账 号`, and `密 码`. Longer +forms such as `Password for admin:` and `Please input your password:` still +require their colon. + +An unfinished built-in credential prompt still requires `:` or `:`. This lets +a read ending at `User` continue into `Username:`, or `Password` into +`Password expired policy: disabled`, without prematurely sending credentials. +For a device that waits at a bare prompt without a newline, configure an +explicit expression: + +```go +config.PasswordPrompt = regexp.MustCompile(`(?i)^Password$`) +``` + +Such a custom rule can match an unfinished candidate using the normal regexp +semantics; no timer, delay, or matching-mode option is added. See the +[login stream boundaries](docs/login-stream.md) for the remaining ambiguity of +delimiter-less prompts. Login and the interactive session share one buffered reader. The login state machine stops as soon as a final shell prompt line is recognized. Unread bytes diff --git a/VERSION b/VERSION index 8308b63..5366600 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.1.1 +v0.1.2 diff --git a/config.go b/config.go index 6d109f0..c5aa12e 100644 --- a/config.go +++ b/config.go @@ -32,16 +32,23 @@ type ClientConfig struct { // not rewritten and remains an application-data writer. LineEnding LineEnding - // Prompt expressions replace the built-in expression for that event. They - // use regexp.Match semantics on the current normalized logical line with - // surrounding whitespace trimmed. Unanchored expressions may match a - // substring; use ^ and $ to require a full-line match. + // UsernamePrompt, PasswordPrompt, and ShellPrompt supplement the built-in + // rules: either the custom expression or the corresponding built-in + // matcher may recognize a prompt. They use regexp.Match semantics on the + // current normalized logical line with surrounding whitespace trimmed. + // Unanchored expressions may match a substring; ^ and $ constrain only + // the custom expression and do not disable the built-in matcher. // ShellPrompt describes the final interactive prompt, not a success - // message in a login banner. ContinuePrompt describes an intermediate prompt - // that should receive an Enter key before login continues. - UsernamePrompt *regexp.Regexp - PasswordPrompt *regexp.Regexp - ShellPrompt *regexp.Regexp + // message in a login banner. + UsernamePrompt *regexp.Regexp + PasswordPrompt *regexp.Regexp + ShellPrompt *regexp.Regexp + + // FailurePrompt, PasswordChangePrompt, and ContinuePrompt replace the + // corresponding built-in expression when non-nil. They use the same + // regexp.Match semantics and current logical-line scope as the prompts + // above. ContinuePrompt describes an intermediate prompt that should + // receive an Enter key before login continues. FailurePrompt *regexp.Regexp PasswordChangePrompt *regexp.Regexp ContinuePrompt *regexp.Regexp diff --git a/docs/login-stream.md b/docs/login-stream.md index b6f14a3..b1b9e04 100644 --- a/docs/login-stream.md +++ b/docs/login-stream.md @@ -35,12 +35,23 @@ An unfinished password-change input must end in `:` or `:` before being classified. `New password:` is rejected without submitting the managed old password. Custom `PasswordChangePrompt` and `FailurePrompt` still replace their respective defaults; their narrative matches obey the same completion rules. + +Custom `UsernamePrompt`, `PasswordPrompt`, and `ShellPrompt` supplement their +built-in matchers: either the built-in rule or the custom expression may accept +the current prompt. This keeps common prompts working when a platform also +configures device-specific alternatives. `ContinuePrompt`, like the two +diagnostic fields, still replaces its default. + All custom prompt expressions use normal `regexp.Match` semantics on the current normalized line, with surrounding whitespace trimmed. An unanchored expression may match a substring; use `^` and `$` when a whole-line match is required. Expressions never search across logical lines. Built-in expressions -retain their own anchors, and the default shell-shape and banner checks remain -in effect when no custom shell expression is configured. +retain their own anchors. Custom anchors and case flags apply only to that +expression, not to the built-in alternative: `ShellPrompt: ^READY>$` still +accepts a built-in `router#` prompt. The three supplemental fields do not +disable built-in recognition. The built-in shell branch always retains its +shape, length, and banner checks, including when a custom shell expression is +configured. An incomplete line that matches a failure or password-change narrative is **pending**, not eligible for another prompt interpretation. For example, @@ -55,6 +66,24 @@ CRLF/LF line may use the logical-line budget (64 KiB), so longer custom prompts remain supported without increasing repeated partial-line scanning. Default shell prompts still have their separate 128-byte limit. +On a completed CRLF/LF line, built-in credential matching also accepts a bounded +set of short prompts with no colon. Username forms are `Login`, `User`, +`Username`, `User Name`, `Name`, `用户名`, and `账号`; password forms are +`Password`, `Passwd`, `Passcode`, `密码`, and `口令`. These rules are +case-insensitive, allow spacing in `User Name`, `账 号`, and `密 码`, and require +the whole completed normalized line to match. Longer forms such as +`Password for admin:` still require `:` or `:`. + +An unfinished built-in credential prompt still requires its colon. A read +ending at `User` can be followed by `name:`, and one ending at `Password` can +be followed by ` expired policy: disabled`; neither read boundary establishes +a bare prompt. No extra timer or delay is used to guess when these words are +complete. A device that waits at a bare prompt without a newline can use an +explicit custom expression, such as `(?i)^Password$`, which remains eligible +for partial-line matching. This is a device-specific choice that retains the +prefix ambiguity of an unfinished textual prompt. The new defaults do not +restore arbitrary banner substring matching or the old broad success words. + Default shell detection recognizes a structured bracketed Unix prompt such as `[admin@login ~]$` or `[admin@router /var/log/error]#` before applying the banner word filter. User, host, and path components are not narrative labels. Plain @@ -80,9 +109,10 @@ not reconstructed. The interactive stream itself is not sanitized. A delimiter-less textual prompt can always be the prefix of a longer message. No TCP buffer size or fixed delay can remove that ambiguity. The defaults use -bounded prompt shapes and known-banner filtering; unusual assets should use -precise platform-specific prompt expressions. This is not a protocol-level -proof of authentication. +bounded prompt shapes and known-banner filtering. Custom username, password, +and shell expressions should describe additional device-specific prompts +precisely; they do not disable the built-in alternatives. This is not a +protocol-level proof of authentication. ## Diagnostics and malformed subnegotiations diff --git a/login.go b/login.go index 892d59f..22e2f52 100644 --- a/login.go +++ b/login.go @@ -29,6 +29,16 @@ var ( defaultPasswordPrompt = regexp.MustCompile( `(?i)^\s*(?:(?:(?:please\s+)?(?:enter|input)\s+(?:your\s+)?(?:password|passwd|passcode)(?:\s+for\s+[^::\r\n]+)?)|(?:password|passwd|passcode)(?:\s+for\s+[^::\r\n]+)?|(?:login|user)\s+password|口令|(?:[^::\r\n]*的\s*)?密\s*码|(?:请\s*)?(?:输入|键入)(?:登录\s*)?(?:密\s*码|口令))\s*(?:\([^()\r\n]{1,64}\))?\s*[::]\s*$`, ) + // A bare word is authoritative only on a completed logical line. At a + // read boundary, "User" can still become "Username:" and "Password" can + // still become a policy banner. Explicit custom expressions may recognize + // devices whose bare prompts deliberately omit a line ending. + defaultBareUsernamePrompt = regexp.MustCompile( + `(?i)^(?:login|user(?:\s*name)?|name|用户名|账\s*号)$`, + ) + defaultBarePasswordPrompt = regexp.MustCompile( + `(?i)^(?:password|passwd|passcode|口令|密\s*码)$`, + ) defaultAngleShellPrompt = regexp.MustCompile( `^<[\pL\pN_.:@/\\~()\-]{1,126}>$`, ) @@ -123,8 +133,8 @@ type loginPatterns struct { func patternsFor(config clientConfig) loginPatterns { return loginPatterns{ - username: selectPattern(config.UsernamePrompt, defaultUsernamePrompt), - password: selectPattern(config.PasswordPrompt, defaultPasswordPrompt), + username: config.UsernamePrompt, + password: config.PasswordPrompt, shell: config.ShellPrompt, failure: selectPattern(config.FailurePrompt, defaultFailurePrompt), passwordChange: selectPattern(config.PasswordChangePrompt, defaultPasswordChangePrompt), @@ -199,7 +209,7 @@ func (m *loginMachine) handleLine(writer io.Writer, line []byte, trace *tracer) if !utf8.Valid(line) { return loginPending, nil } - return m.handlePrompt(writer, line, trace) + return m.handlePrompt(writer, line, true, trace) } // Interactive prompts normally have no newline. Check them only at the end of @@ -229,12 +239,13 @@ func (m *loginMachine) handlePartialLine(writer io.Writer, line []byte, trace *t if matchPrompt(m.patterns.failure, line) { return loginPending, nil } - return m.handlePrompt(writer, line, trace) + return m.handlePrompt(writer, line, false, trace) } // handlePrompt runs only after the caller has validated the appropriate line // limit and given diagnostic messages precedence over interactive actions. -func (m *loginMachine) handlePrompt(writer io.Writer, line []byte, trace *tracer) (loginResult, error) { +// complete permits the bounded bare-word defaults only after CRLF or LF. +func (m *loginMachine) handlePrompt(writer io.Writer, line []byte, complete bool, trace *tracer) (loginResult, error) { line = bytes.TrimSpace(line) if len(line) == 0 { return loginPending, nil @@ -252,7 +263,9 @@ func (m *loginMachine) handlePrompt(writer io.Writer, line []byte, trace *tracer return loginContinueSent, nil } - if matchPrompt(m.patterns.username, line) { + if matchPrompt(m.patterns.username, line) || + matchPrompt(defaultUsernamePrompt, line) || + (complete && matchPrompt(defaultBareUsernamePrompt, line)) { if m.phase != loginAwaitPrompt || m.config.User == "" { trace.printf("login username prompt repeated or credential unavailable phase=%s", m.phase) return loginFailed, nil @@ -265,7 +278,9 @@ func (m *loginMachine) handlePrompt(writer io.Writer, line []byte, trace *tracer return loginCredentialSent, nil } - if matchPrompt(m.patterns.password, line) { + if matchPrompt(m.patterns.password, line) || + matchPrompt(defaultPasswordPrompt, line) || + (complete && matchPrompt(defaultBarePasswordPrompt, line)) { if m.phase == loginAwaitResult || m.config.Password == "" { trace.printf("login password prompt repeated or credential unavailable phase=%s", m.phase) return loginFailed, nil @@ -287,10 +302,7 @@ func (m *loginMachine) handlePrompt(writer io.Writer, line []byte, trace *tracer } func (m *loginMachine) shellPromptMatches(line []byte) bool { - if m.patterns.shell != nil { - return matchPrompt(m.patterns.shell, line) - } - return defaultShellPromptMatches(line) + return matchPrompt(m.patterns.shell, line) || defaultShellPromptMatches(line) } func defaultShellPromptMatches(line []byte) bool { diff --git a/login_additive_prompt_test.go b/login_additive_prompt_test.go new file mode 100644 index 0000000..118ce65 --- /dev/null +++ b/login_additive_prompt_test.go @@ -0,0 +1,209 @@ +package tclientlib + +import ( + "bytes" + "fmt" + "io" + "regexp" + "strings" + "testing" + "time" +) + +func TestAdditivePromptsConnectMixedDevices(t *testing.T) { + // One platform configuration must support both built-in and extra device + // prompts, including sessions that use a mixture of the two sets. + cfg := ClientConfig{ + User: "admin", Password: "secret", Timeout: time.Second, + UsernamePrompt: regexp.MustCompile(`^Account ID:$`), + PasswordPrompt: regexp.MustCompile(`^Access code:$`), + ShellPrompt: regexp.MustCompile(`^SESSION READY$`), + } + for choices := 0; choices < 8; choices++ { + username, password, shell := "Username:", "Password for admin:", "router#" + if choices&1 != 0 { + username = "Account ID:" + } + if choices&2 != 0 { + password = "Access code:" + } + if choices&4 != 0 { + shell = "SESSION READY" + } + wire := []byte("\x1b[32m" + username + "\x1b[0m\r\n" + + "\x1b[33m" + password + "\x1b[0m\r\n" + + "Password expired policy: disabled\r\nLast login failed: 0\r\n" + + shell + "\r\nTAIL\r\n") + for _, size := range []int{1, 2, 7, len(wire)} { + t.Run(fmt.Sprintf("custom-%03b/chunk-%d", choices, size), func(t *testing.T) { + conn := &streamTestConn{chunks: streamChunks(wire, size), lastError: io.EOF} + client, err := NewClientConn(conn, &cfg) + if err != nil { + t.Fatalf("login: %v; writes=%q", err, conn.writes.String()) + } + defer client.Close() + tail, err := io.ReadAll(client) + if err != nil { + t.Fatal(err) + } + if string(tail) != "\r\nTAIL\r\n" || conn.writes.String() != "admin\r\nsecret\r\n" { + t.Fatalf("tail=%q writes=%q", tail, conn.writes.String()) + } + }) + } + } +} + +func TestBareCredentialPromptsWaitForLineEnd(t *testing.T) { + for _, tc := range []struct { + prompt string + password bool + }{ + {"Login", false}, {"User", false}, {"Username", false}, + {"User name", false}, {"Name", false}, {"用户名", false}, {"账 号", false}, + {"Password", true}, {"Passwd", true}, {"Passcode", true}, {"密 码", true}, {"口令", true}, + } { + for _, ending := range []string{"\n", "\r\n"} { + t.Run(fmt.Sprintf("%s/%q", tc.prompt, ending), func(t *testing.T) { + machine, text := reviewLoginMachine(t, ClientConfig{ + User: "admin", Password: "secret", + UsernamePrompt: regexp.MustCompile(`^Account ID:$`), + PasswordPrompt: regexp.MustCompile(`^Access code:$`), + }) + var sent bytes.Buffer + parts := []string{tc.prompt} + if ending == "\r\n" { + parts = append(parts, "\r") + } + for _, part := range parts { + _, result, _, err := machine.consumeChunk(&sent, text, []byte(part), nil) + if err != nil || result != loginPending || sent.Len() != 0 || machine.phase != loginAwaitPrompt || string(text.current()) != tc.prompt { + t.Fatalf("partial %q: result=%v err=%v writes=%q phase=%v text=%q", part, result, err, sent.String(), machine.phase, text.current()) + } + } + _, result, _, err := machine.consumeChunk(&sent, text, []byte("\n"), nil) + wantWrites, wantPhase := "admin\r\n", loginAwaitPasswordOrResult + if tc.password { + wantWrites, wantPhase = "secret\r\n", loginAwaitResult + } + if err != nil || result != loginPending || sent.String() != wantWrites || machine.phase != wantPhase { + t.Fatalf("completed: result=%v err=%v writes=%q phase=%v; want %q phase=%v", result, err, sent.String(), machine.phase, wantWrites, wantPhase) + } + }) + } + } +} + +func TestBareCredentialPrefixesDoNotTriggerActions(t *testing.T) { + for _, tc := range []struct { + prefix string + suffix string + result loginResult + writes string + phase loginPhase + }{ + {"User", "name:", loginCredentialSent, "admin\r\n", loginAwaitPasswordOrResult}, + {"User", " name:", loginCredentialSent, "admin\r\n", loginAwaitPasswordOrResult}, + {"Password", ": ", loginCredentialSent, "secret\r\n", loginAwaitResult}, + {"User", " name policy: enabled\r\n", loginPending, "", loginAwaitPrompt}, + {"Login", " failed attempts: 0\r\n", loginPending, "", loginAwaitPrompt}, + {"Password", " expired policy: disabled\r\n", loginPending, "", loginAwaitPrompt}, + {"Password", " expired\r\n", loginPasswordChangeRequired, "", loginAwaitPrompt}, + } { + t.Run(tc.prefix+tc.suffix, func(t *testing.T) { + machine, text := reviewLoginMachine(t, ClientConfig{User: "admin", Password: "secret"}) + var sent bytes.Buffer + _, result, _, err := machine.consumeChunk(&sent, text, []byte(tc.prefix), nil) + if err != nil || result != loginPending || sent.Len() != 0 || string(text.current()) != tc.prefix { + t.Fatalf("prefix acted on: result=%v err=%v writes=%q text=%q", result, err, sent.String(), text.current()) + } + _, result, _, err = machine.consumeChunk(&sent, text, []byte(tc.suffix), nil) + if err != nil || result != tc.result || sent.String() != tc.writes || machine.phase != tc.phase { + t.Fatalf("result=%v err=%v writes=%q phase=%v; want %v %q phase=%v", result, err, sent.String(), machine.phase, tc.result, tc.writes, tc.phase) + } + }) + } +} + +func TestExplicitBareCustomPromptsDoNotNeedLineEnd(t *testing.T) { + for _, password := range []bool{false, true} { + cfg := ClientConfig{User: "admin", Password: "secret"} + prompt, wantWrites, wantPhase := "User", "admin\r\n", loginAwaitPasswordOrResult + if password { + cfg.PasswordPrompt = regexp.MustCompile(`^Password$`) + prompt, wantWrites, wantPhase = "Password", "secret\r\n", loginAwaitResult + } else { + cfg.UsernamePrompt = regexp.MustCompile(`^User$`) + } + t.Run(prompt, func(t *testing.T) { + machine, text := reviewLoginMachine(t, cfg) + var sent bytes.Buffer + _, result, _, err := machine.consumeChunk(&sent, text, []byte(prompt), nil) + if err != nil || result != loginCredentialSent || sent.String() != wantWrites || machine.phase != wantPhase { + t.Fatalf("result=%v err=%v writes=%q phase=%v", result, err, sent.String(), machine.phase) + } + _, result, _, err = machine.consumeChunk(&sent, text, []byte("\r\n"), nil) + if err != nil || result != loginPending || sent.String() != wantWrites || machine.phase != wantPhase { + t.Fatalf("line ending repeated action: result=%v err=%v writes=%q phase=%v", result, err, sent.String(), machine.phase) + } + }) + } +} + +func TestAdditiveShellKeepsDefaultBoundaries(t *testing.T) { + for _, line := range []string{ + "[Warning]#", "[ERROR]%", "", "Login successful >", + "Last login: yesterday", "success", "成功", "prefix SESSION READY suffix", + strings.Repeat("x", maxDefaultPromptBytes) + "#", + } { + for _, ending := range []string{"", "\r\n"} { + t.Run(fmt.Sprintf("%s/%q", line, ending), func(t *testing.T) { + machine, text := reviewLoginMachine(t, ClientConfig{ + Password: "secret", ShellPrompt: regexp.MustCompile(`^SESSION READY$`), + }) + machine.phase = loginAwaitResult + var sent bytes.Buffer + _, result, _, err := machine.consumeChunk(&sent, text, []byte(line+ending), nil) + if err != nil || result != loginPending || sent.Len() != 0 { + t.Fatalf("banner/oversized line acted on: result=%v err=%v writes=%q", result, err, sent.String()) + } + }) + } + } +} + +func TestAdditivePromptsKeepBuiltinDiagnosticsFirst(t *testing.T) { + for _, tc := range []struct { + line string + result loginResult + immediatePartial bool + }{ + {"Login incorrect", loginFailed, false}, + {"Invalid user>", loginFailed, false}, + {"Your password has expired", loginPasswordChangeRequired, false}, + {"New password:", loginPasswordChangeRequired, true}, + } { + t.Run(tc.line, func(t *testing.T) { + anyText := regexp.MustCompile(`.+`) + machine, text := reviewLoginMachine(t, ClientConfig{ + User: "admin", Password: "secret", + UsernamePrompt: anyText, PasswordPrompt: anyText, ShellPrompt: anyText, + }) + var sent bytes.Buffer + _, result, _, err := machine.consumeChunk(&sent, text, []byte(tc.line), nil) + want := loginPending + if tc.immediatePartial { + want = tc.result + } + if err != nil || result != want || sent.Len() != 0 || machine.phase != loginAwaitPrompt { + t.Fatalf("partial diagnostic: result=%v err=%v writes=%q phase=%v", result, err, sent.String(), machine.phase) + } + if !tc.immediatePartial { + _, result, _, err = machine.consumeChunk(&sent, text, []byte("\r\nrouter#"), nil) + if err != nil || result != tc.result || sent.Len() != 0 || machine.phase != loginAwaitPrompt { + t.Fatalf("completed diagnostic: result=%v err=%v writes=%q phase=%v", result, err, sent.String(), machine.phase) + } + } + }) + } +} diff --git a/login_compat_test.go b/login_compat_test.go index ebf8bd2..d715ea5 100644 --- a/login_compat_test.go +++ b/login_compat_test.go @@ -17,6 +17,13 @@ func TestCompatibleCredentialPromptVariants(t *testing.T) { "Login as:", "User Name:", "请输入用户名:", + "Login", + "User", + "Username", + "User name", + "Name", + "用户名", + "账 号", } for _, prompt := range usernamePrompts { t.Run("username-"+prompt, func(t *testing.T) { @@ -45,6 +52,11 @@ func TestCompatibleCredentialPromptVariants(t *testing.T) { "Password (required):", "Login password:", "请输入密码:", + "Password", + "Passwd", + "Passcode", + "密 码", + "口令", } for _, prompt := range passwordPrompts { t.Run("password-"+prompt, func(t *testing.T) { @@ -71,7 +83,7 @@ func TestCompatibleCredentialPromptVariants(t *testing.T) { t.Fatal(err) } machine := newLoginMachine(config) - for _, banner := range []string{"Last login:", "Invalid user:", "Password"} { + for _, banner := range []string{"Last login:", "Invalid user:", "Password policy: enabled", "User name policy: enabled"} { result, err := machine.handleLine(io.Discard, []byte(banner), nil) if err != nil { t.Fatal(err) diff --git a/login_custom_prompt_test.go b/login_custom_prompt_test.go index 4ee3033..0b7f154 100644 --- a/login_custom_prompt_test.go +++ b/login_custom_prompt_test.go @@ -13,17 +13,18 @@ func TestCustomPromptsUseRegexpMatchSemantics(t *testing.T) { name string set func(*ClientConfig, *regexp.Regexp) defaultPrompt string + additive bool result loginResult writes string phase loginPhase continues int }{ - {"username", func(c *ClientConfig, p *regexp.Regexp) { c.UsernamePrompt = p }, "Login:", loginPending, "admin\r\n", loginAwaitPasswordOrResult, 0}, - {"password", func(c *ClientConfig, p *regexp.Regexp) { c.PasswordPrompt = p }, "Password:", loginPending, "secret\r\n", loginAwaitResult, 0}, - {"shell", func(c *ClientConfig, p *regexp.Regexp) { c.ShellPrompt = p }, "router#", loginSuccess, "", loginAwaitPrompt, 0}, - {"failure", func(c *ClientConfig, p *regexp.Regexp) { c.FailurePrompt = p }, "Access denied", loginFailed, "", loginAwaitPrompt, 0}, - {"password-change", func(c *ClientConfig, p *regexp.Regexp) { c.PasswordChangePrompt = p }, "New password:", loginPasswordChangeRequired, "", loginAwaitPrompt, 0}, - {"continue", func(c *ClientConfig, p *regexp.Regexp) { c.ContinuePrompt = p }, "Press Enter to continue", loginPending, "\r\n", loginAwaitPrompt, 1}, + {"username", func(c *ClientConfig, p *regexp.Regexp) { c.UsernamePrompt = p }, "Login:", true, loginPending, "admin\r\n", loginAwaitPasswordOrResult, 0}, + {"password", func(c *ClientConfig, p *regexp.Regexp) { c.PasswordPrompt = p }, "Password:", true, loginPending, "secret\r\n", loginAwaitResult, 0}, + {"shell", func(c *ClientConfig, p *regexp.Regexp) { c.ShellPrompt = p }, "router#", true, loginSuccess, "", loginAwaitPrompt, 0}, + {"failure", func(c *ClientConfig, p *regexp.Regexp) { c.FailurePrompt = p }, "Access denied", false, loginFailed, "", loginAwaitPrompt, 0}, + {"password-change", func(c *ClientConfig, p *regexp.Regexp) { c.PasswordChangePrompt = p }, "New password:", false, loginPasswordChangeRequired, "", loginAwaitPrompt, 0}, + {"continue", func(c *ClientConfig, p *regexp.Regexp) { c.ContinuePrompt = p }, "Press Enter to continue", false, loginPending, "\r\n", loginAwaitPrompt, 1}, } { t.Run(kind.name, func(t *testing.T) { for _, tc := range []struct { @@ -42,7 +43,7 @@ func TestCustomPromptsUseRegexpMatchSemantics(t *testing.T) { {"start-anchor-rejects-prefix", `^TOKEN`, "prefix TOKEN", false}, {"end-anchor-allows-prefix", `TOKEN$`, "prefix TOKEN", true}, {"end-anchor-rejects-suffix", `TOKEN$`, "TOKEN suffix", false}, - {"replaces-default", `TOKEN`, kind.defaultPrompt, false}, + {"default-with-custom", `^TOKEN$`, kind.defaultPrompt, kind.additive}, } { t.Run(tc.name, func(t *testing.T) { cfg := ClientConfig{User: "admin", Password: "secret"} diff --git a/login_test.go b/login_test.go index 3f953db..9b85867 100644 --- a/login_test.go +++ b/login_test.go @@ -245,10 +245,10 @@ func TestAnchoredCustomPromptMatchesWholeLine(t *testing.T) { } } -func TestCustomShellPromptReplacesDefault(t *testing.T) { +func TestCustomShellPromptSupplementsDefault(t *testing.T) { config, err := prepareConfig(&ClientConfig{ Password: "secret", - ShellPrompt: regexp.MustCompile(`READY>`), + ShellPrompt: regexp.MustCompile(`^SESSION READY$`), }) if err != nil { t.Fatal(err) @@ -260,10 +260,10 @@ func TestCustomShellPromptReplacesDefault(t *testing.T) { if err != nil { t.Fatal(err) } - if result != loginPending { - t.Fatalf("default shell prompt remained active: %v", result) + if result != loginSuccess { + t.Fatalf("custom shell prompt disabled the default: %v", result) } - result, err = machine.handleLine(io.Discard, []byte("READY>"), nil) + result, err = machine.handleLine(io.Discard, []byte("SESSION READY"), nil) if err != nil { t.Fatal(err) }