Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 51 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:`;
Expand All @@ -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`)
```
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.1.1
v0.1.2
25 changes: 16 additions & 9 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 35 additions & 5 deletions docs/login-stream.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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

Expand Down
34 changes: 23 additions & 11 deletions login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}>$`,
)
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand Down
Loading
Loading