tclientlib is a small Go Telnet client for interactive host and network-device
sessions. Its public API follows the same principle as golang.org/x/crypto/ssh:
constructors return a ready-to-use client (with optional prompt-based login),
application reads and writes never expose protocol framing, and protocol controls
use explicit methods.
Upgrading from v0.0.x? Read the changelog and migration guide,
including the changed WindowChange(height, width) argument order and
configuration API.
Download a versioned binary from GitHub Releases. Archives are available for Linux, macOS, and Windows on amd64 and arm64, together with SHA-256 checksums and GitHub build attestations. See the installation and verification instructions.
Alternatively, install the interactive client with Go 1.20 or later:
go install github.com/LeeEirc/tclientlib/cmd/tclient@latestOr build the current checkout:
go build ./cmd/tclient
./tclient --version
./tclient 192.0.2.10 23
./tclient --auto-login --user admin --ask-pass 192.0.2.10 23
./tclient --charset gbk --line-ending crnul 192.0.2.10 23The default is manual interaction. Ctrl+] opens local commands such as help,
status, send brk, and quit. The client handles terminal restoration, window
changes, negotiated remote echo, and redirected input. See the CLI guide
for options, exit codes, tracing, and compatibility boundaries.
type ClientConfig struct {
User string
Password string
Timeout time.Duration
TerminalType string
Width int
Height int
LineEnding LineEnding
UsernamePrompt *regexp.Regexp
PasswordPrompt *regexp.Regexp
ShellPrompt *regexp.Regexp
FailurePrompt *regexp.Regexp
PasswordChangePrompt *regexp.Regexp
ContinuePrompt *regexp.Regexp
WrapStream func(io.Reader, io.Writer) (io.Reader, io.Writer)
Trace io.Writer
}
func Dial(network, addr string, config *ClientConfig) (*Client, error)
func NewClientConn(conn net.Conn, config *ClientConfig) (*Client, error)
func (*Client) Read([]byte) (int, error)
func (*Client) Write([]byte) (int, error)
func (*Client) Close() error
func (*Client) WindowChange(height, width int) error
func (*Client) SendCommand(Command) error
func (*Client) TerminalMode() TerminalModeThere is deliberately one I/O path:
Client.Writealways writes terminal application data and escapes TelnetIACbytes internally.Client.Readreturns terminal application data only, removing the protocol NUL after a carriage return in the non-BINARY receive direction.SendCommandsends Telnet controls such asCommandBreak.DialandNewClientConnperform initial prompt-based login when credentials are configured.
The library does not expose raw writes, a second data writer, or a separate public login lifecycle.
TerminalMode() returns a concurrency-safe snapshot of RemoteEcho,
LocalSuppressGA, RemoteSuppressGA, SendBinary, and ReceiveBinary. Modes
change as Read consumes negotiation packets. The library never changes the
caller's local terminal and does not start a background reader.
Receive NVT decoding follows the exact position of BINARY negotiation in the
stream, including changes within one network read. CR LF and standalone NUL
are retained; CR NUL becomes CR outside BINARY mode. Client.Write continues
to preserve caller-supplied CR/LF/NUL bytes and escape IAC. The caller is
responsible for choosing appropriate outgoing line endings; the CLI maps TTY
Enter keys and automatic login uses LineEnding.
The client accepts SGA in both directions. Environment negotiation exposes only
the explicitly configured User: requested variables are returned in order,
unknown variables are marked undefined, and environment control bytes are
escaped. Process environment variables are never exported.
config := &tclientlib.ClientConfig{
User: "admin",
Password: "secret",
Timeout: 15 * time.Second,
TerminalType: "xterm-256color",
Width: 120,
Height: 40,
}
client, err := tclientlib.Dial("tcp", "192.0.2.10:23", config)
if err != nil {
return err
}
defer client.Close()
go io.Copy(os.Stdout, client)
_, err = io.Copy(client, os.Stdin)WindowChange follows ssh.Session.WindowChange and accepts height before
width:
err := client.WindowChange(height, width)Send a Telnet break without constructing raw protocol bytes:
err := client.SendCommand(tclientlib.CommandBreak)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 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:, andLogin as:; - password prompts include
Password:,Password for admin:,Please input your password:, andPassword (required):; - default shell detection supports common Unix and network-device forms such as
router#,my device #,host%,switch(config)#,<HUAWEI>,[HUAWEI], and[user@host ~]$; - explicit failures may include a bounded suffix such as
please try againorplease retry, while login-history counters remain ordinary banners; - prompts such as
Press RETURN to get started!andPress ENTER to continuereceive an Enter key and then continue waiting for the real shell prompt.
Automatic continuation is limited to three responses per connection, preventing a malformed or hostile endpoint from causing an infinite response loop.
Before matching, login output is normalized into logical terminal lines:
- Telnet framing has already been removed;
- an optional charset wrapper has already decoded the bytes;
- NUL and BEL are ignored;
- backspace and carriage-return overwrite behavior is applied;
- common ANSI CSI and OSC sequences are removed.
Username, password, shell, failure, password-change, and continuation
expressions are matched against the current normalized line with surrounding
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.
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:
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`)Custom failure and password-change narratives still wait for a completed line, 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.
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:
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 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
beyond the accepted line remain buffered for the next Client.Read. A substring
match accepts the current candidate line as a whole; it does not preserve the
same-line prefix or suffix around the regexp match. The accepted prompt line
itself is not replayed.
Automatic usernames, passwords, and continue responses use CR LF by default.
This does not rewrite data passed to Client.Write. Devices with non-standard
input behavior can select another terminator:
config.LineEnding = tclientlib.LineEndingCRNUL // CR NUL
config.LineEnding = tclientlib.LineEndingCR // bare CR
config.LineEnding = tclientlib.LineEndingLF // bare LFSupported values are:
LineEndingCRLF— default Telnet NVT newline;LineEndingCRNUL— carriage return without a line feed;LineEndingCR— bare carriage return;LineEndingLF— bare line feed.
When the remote side requires a password change, constructors return an error
that matches ErrPasswordChangeRequired:
client, err := tclientlib.Dial("tcp", address, config)
if errors.Is(err, tclientlib.ErrPasswordChangeRequired) {
// Handle password rotation in the calling application's credential workflow.
}The built-in password-change matcher recognizes direct requirements such as
Your password has expired, Current UNIX password:, New password:, and
Retype new password:. Informational text such as Your password will expire in 7 days or Password expired policy: disabled remains a banner. The library
deliberately does not choose or submit a new password.
The same wrapped stream is used for both initial login and the interactive session. This lets callers decode non-UTF-8 prompts before matching them without introducing a second constructor or login API:
config.WrapStream = func(reader io.Reader, writer io.Writer) (io.Reader, io.Writer) {
return transform.NewReader(reader, decoder), transform.NewWriter(writer, encoder)
}Set ClientConfig.Trace to an io.Writer to enable line-oriented diagnostics:
traceFile, err := os.Create("telnet.trace")
if err != nil {
return err
}
defer traceFile.Close()
config.Trace = traceFileThe trace includes:
- raw wire bytes in both directions;
- decoded Telnet application data;
- parsed
WILL,WONT,DO,DONT, command, and subnegotiation events; - generated negotiation responses;
- login state transitions and terminal resize events.
Tracing is disabled when Trace is nil. Each byte dump is bounded, so a large
terminal read does not produce an unbounded log line. Both received and sent byte
dumps are replaced by <redacted> while the initial login state machine is
active; semantic negotiation and login-state events remain visible.
Warning: post-login commands and terminal output may still contain sensitive information. Store traces securely and remove them after troubleshooting.
The CLI can write a trace explicitly (the file must not already exist):
go run ./cmd/tclient --trace-file telnet.trace 192.0.2.10 23Manual login does not run the automatic-login redaction phase. A trace can
therefore contain manually typed passwords as well as commands and output. The
CLI warns when tracing is enabled and creates the file with mode 0600.
For failures involving another Telnet client, or for observing traffic without changing application code, use the transparent monitor:
cd _example/monitor
go run . -listen 127.0.0.1:9999 -target 192.0.2.10:23Then point the client at 127.0.0.1:9999. The monitor records raw bidirectional
stream chunks and does not depend on exported parser internals.
The monitor and Trace observe the TCP byte stream. User-space Read boundaries
are not the original TCP packet boundaries. Use tcpdump or Wireshark when the
actual network packet layout, retransmission, or timing is relevant.
The Go library and cmd/tclient share one semantic-version tag. To release,
open a PR that updates VERSION and CHANGELOG.md.
Merging the version change into master starts the release workflow for that
commit. The workflow runs the test matrix, builds all six platform archives,
uploads a draft, and publishes it only after build attestations succeed.
PR checks and ordinary commits that leave VERSION unchanged do not publish a
release. Manual GitHub Actions runs and pushed version tags are also supported;
see the release guide.
tclientlib owns Telnet framing, option negotiation, initial account login,
terminal resize, and application data transport. Device-specific privilege
switching remains above the transport because it is CLI behavior rather than
part of the Telnet protocol.
JumpServer Koko currently performs these operations after the client connects:
- Cisco:
enable - Huawei:
super 15 - H3C:
super level-15 - Linux/Unix:
su,sudo,only_su, andonly_sudo
This list records historical project testing and is not a vendor allowlist:
- H3C
- S7508E
- S7506E
- S7503E
- Huawei
- S5720
- S6720
- S12708
The protocol implementation is vendor-neutral and is also used by Koko for other platforms, including Cisco devices.