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
11 changes: 9 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,22 @@ permissions:
jobs:
test:
strategy:
fail-fast: false
matrix:
os: ["ubuntu-latest", "windows-latest"]
go-version: ["1.20.x", "stable"]
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
- run: go test -race ./...
- run: go test -race -timeout=3m ./...
- run: go vet ./...
- name: Verify interactive CLI with a real PTY and local TCP server
if: runner.os == 'Linux'
run: |
go build -o "$RUNNER_TEMP/tclient" ./cmd/tclient
python3 scripts/test_cli_pty.py "$RUNNER_TEMP/tclient"
- run: cd _example/telnet_client && go test ./...
- run: cd _example/monitor && go test ./...
61 changes: 55 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,31 @@

`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 authenticated client, application reads and
writes never expose protocol framing, and protocol controls use explicit methods.
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.

## Command-line client

Install the interactive client with Go 1.20 or later:

```bash
go install github.com/LeeEirc/tclientlib/cmd/tclient@latest
```

Or build the current checkout:

```bash
go build ./cmd/tclient
./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 23
```

The 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](docs/cli.md)
for options, exit codes, tracing, and compatibility boundaries.

## API

Expand Down Expand Up @@ -37,20 +60,39 @@ 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() TerminalMode
```

There is deliberately one I/O path:

- `Client.Write` always writes terminal application data and escapes Telnet
`IAC` bytes internally.
- `Client.Read` returns terminal application data only.
- `Client.Read` returns terminal application data only, removing the protocol
NUL after a carriage return in the non-BINARY receive direction.
- `SendCommand` sends Telnet controls such as `CommandBreak`.
- `Dial` and `NewClientConn` perform 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.

## Basic usage

```go
Expand Down Expand Up @@ -219,13 +261,20 @@ 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 example client can write a trace directly:
The CLI can write a trace explicitly (the file must not already exist):

```bash
cd _example/telnet_client
go run . -ip 192.0.2.10 -port 23 -username admin -trace-file telnet.trace
go run ./cmd/tclient --trace-file telnet.trace 192.0.2.10 23
```

Manual 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`.

`_example/telnet_client` now delegates to the same CLI, with the same flags.
Replace its old `-ip`/`-port` flags with positional `host [port]`, and replace
`-username`/`-password` with `--auto-login --user ... --ask-pass`.

For failures involving another Telnet client, or for observing traffic without
changing application code, use the transparent monitor:

Expand Down
11 changes: 6 additions & 5 deletions _example/telnet_client/go.mod
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
module telnet_client
module github.com/LeeEirc/tclientlib/_example/telnet_client

go 1.20

require github.com/LeeEirc/tclientlib v0.0.0

require (
github.com/LeeEirc/tclientlib v0.0.0
golang.org/x/term v0.10.0
golang.org/x/sys v0.30.0 // indirect
golang.org/x/term v0.29.0 // indirect
golang.org/x/text v0.22.0 // indirect
)

require golang.org/x/sys v0.10.0 // indirect

replace github.com/LeeEirc/tclientlib => ../../
10 changes: 6 additions & 4 deletions _example/telnet_client/go.sum
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c=
golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
87 changes: 5 additions & 82 deletions _example/telnet_client/main.go
Original file line number Diff line number Diff line change
@@ -1,91 +1,14 @@
// This entry point is retained for existing example checkouts. The supported
// client lives in cmd/tclient; both entry points use the same implementation
// and flags (see docs/cli.md).
package main

import (
"flag"
"io"
"log"
"net"
"os"
"os/signal"
"syscall"
"time"

"golang.org/x/term"

"github.com/LeeEirc/tclientlib"
)

var (
ip string
port string
username string
password string
traceFile string
"github.com/LeeEirc/tclientlib/internal/cli"
)

func init() {
flag.StringVar(&ip, "ip", "127.0.0.1", "telnet IP")
flag.StringVar(&port, "port", "23", "telnet port")
flag.StringVar(&username, "username", "", "telnet username")
flag.StringVar(&password, "password", "", "telnet password")
flag.StringVar(&traceFile, "trace-file", "", "write Telnet wire and protocol trace to this file")
}

func main() {
flag.Parse()

var trace io.Writer
var traceCloser io.Closer
if traceFile != "" {
file, err := os.Create(traceFile)
if err != nil {
log.Fatal(err)
}
trace = file
traceCloser = file
defer traceCloser.Close()
log.Printf("warning: trace file may contain credentials and terminal data: %s", traceFile)
}

fd := int(os.Stdin.Fd())
state, err := term.MakeRaw(fd)
if err != nil {
log.Fatal(err)
}
defer term.Restore(fd, state)

width, height, _ := term.GetSize(fd)
client, err := tclientlib.Dial("tcp", net.JoinHostPort(ip, port), &tclientlib.ClientConfig{
User: username, Password: password, Timeout: 30 * time.Second,
TerminalType: "xterm", Width: width, Height: height,
Trace: trace,
})
if err != nil {
log.Fatal(err)
}
defer client.Close()

done := make(chan struct{}, 2)
go func() {
_, _ = io.Copy(os.Stdout, client)
done <- struct{}{}
}()
go func() {
_, _ = io.Copy(client, os.Stdin)
done <- struct{}{}
}()

resize := make(chan os.Signal, 1)
signal.Notify(resize, syscall.SIGWINCH)
for {
select {
case <-done:
return
case <-resize:
width, height, _ = term.GetSize(fd)
if err := client.WindowChange(height, width); err != nil {
log.Printf("window change: %v", err)
}
}
}
os.Exit(cli.Run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
}
12 changes: 9 additions & 3 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ func Dial(network, addr string, config *ClientConfig) (*Client, error) {
return newClientConn(conn, fullConfig)
}

// NewClientConn creates an authenticated Telnet client over an existing
// net.Conn. This is the counterpart of Dial for gateway and tunneled links.
// NewClientConn creates a Telnet client over an existing net.Conn, performing
// prompt-based login when User or Password is configured. This is the
// counterpart of Dial for gateway and tunneled links.
func NewClientConn(conn net.Conn, config *ClientConfig) (*Client, error) {
if conn == nil {
return nil, fmt.Errorf("%w: connection is nil", ErrInvalidConfig)
Expand Down Expand Up @@ -87,12 +88,17 @@ func newClientConn(conn net.Conn, config clientConfig) (*Client, error) {
return client, nil
}

// Read receives terminal application data after Telnet decoding and optional
// stream wrappers. In non-BINARY mode the transport removes a NUL immediately
// following CR, while preserving CR LF and unrelated NUL bytes.
func (c *Client) Read(p []byte) (int, error) {
return c.reader.Read(p)
}

// Write sends terminal application data. Telnet IAC bytes are escaped by the
// transport after optional stream wrappers have encoded the data.
// transport after optional stream wrappers have encoded the data. Line endings
// are preserved; callers choose CR LF or CR NUL sequences for NVT mode, or a
// device-specific terminator when required.
func (c *Client) Write(p []byte) (int, error) {
return c.writer.Write(p)
}
Expand Down
11 changes: 11 additions & 0 deletions cmd/tclient/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package main

import (
"os"

"github.com/LeeEirc/tclientlib/internal/cli"
)

func main() {
os.Exit(cli.Run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
}
8 changes: 6 additions & 2 deletions decoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ type telnetEvent struct {
command byte
option byte
parameters []byte
// dataOffset is the number of application bytes decoded before this
// event in this feed call. It preserves option changes within a read.
dataOffset int
}

type decoder struct {
Expand Down Expand Up @@ -67,7 +70,7 @@ func (d *decoder) feed(input []byte) ([]byte, []telnetEvent, error) {
case sb:
d.state = decoderSubnegotiationOption
case nop, dm, brk, ip, ao, ayt, ec, el, ga:
events = append(events, telnetEvent{typ: commandEvent, command: value})
events = append(events, telnetEvent{typ: commandEvent, command: value, dataOffset: len(data)})
d.state = decoderData
default:
// Unknown Telnet commands are consumed, never exposed as terminal data.
Expand All @@ -76,7 +79,7 @@ func (d *decoder) feed(input []byte) ([]byte, []telnetEvent, error) {

case decoderNegotiationOption:
events = append(events, telnetEvent{
typ: negotiationEvent, command: d.negotiationCommand, option: value,
typ: negotiationEvent, command: d.negotiationCommand, option: value, dataOffset: len(data),
})
d.state = decoderData

Expand All @@ -101,6 +104,7 @@ func (d *decoder) feed(input []byte) ([]byte, []telnetEvent, error) {
typ: subnegotiationEvent,
option: d.subOption,
parameters: append([]byte(nil), d.subData...),
dataOffset: len(data),
})
d.subData = d.subData[:0]
d.state = decoderData
Expand Down
8 changes: 5 additions & 3 deletions doc.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// Package tclientlib provides an SSH-style client API for a single interactive
// Telnet connection. Dial and NewClientConn return an authenticated Client;
// Client.Read and Client.Write exchange terminal application data while Telnet
// framing and option negotiation remain internal.
// Telnet connection. Dial and NewClientConn optionally perform prompt-based
// login when credentials are configured; without credentials they return a
// client for manual interaction. Client.Read and Client.Write exchange terminal
// application data while Telnet framing and option negotiation remain internal.
// TerminalMode reports the negotiated modes without changing the local terminal.
package tclientlib
Loading
Loading