From 1ba1e148e5e8500e2e9245d388981caa087178c6 Mon Sep 17 00:00:00 2001 From: Eugene Sokolov Date: Wed, 1 Dec 2021 09:53:01 +0300 Subject: [PATCH] version 0.1 --- README.md | 64 ++++++- console.go | 148 ++++++++++++++++ go.mod | 8 + go.sum | 13 ++ host.go | 22 +++ prompt.go | 35 ++++ reader.go | 119 +++++++++++++ reader_test.go | 92 ++++++++++ ssh.go | 86 +++++++++ telnet.go | 41 +++++ telnet/conn.go | 148 ++++++++++++++++ telnet/data_reader.go | 172 ++++++++++++++++++ telnet/data_reader_test.go | 353 +++++++++++++++++++++++++++++++++++++ telnet/data_writer.go | 142 +++++++++++++++ telnet/data_writer_test.go | 116 ++++++++++++ transport.go | 34 ++++ uri.go | 84 +++++++++ uri_test.go | 73 ++++++++ 18 files changed, 1748 insertions(+), 2 deletions(-) create mode 100644 console.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 host.go create mode 100644 prompt.go create mode 100644 reader.go create mode 100644 reader_test.go create mode 100644 ssh.go create mode 100644 telnet.go create mode 100644 telnet/conn.go create mode 100644 telnet/data_reader.go create mode 100644 telnet/data_reader_test.go create mode 100644 telnet/data_writer.go create mode 100644 telnet/data_writer_test.go create mode 100644 transport.go create mode 100644 uri.go create mode 100644 uri_test.go diff --git a/README.md b/README.md index bf6e312..5dc9f82 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,62 @@ -# console -Uniform interface for interacting with hardware via telnet/ssh +# jgivc/console + +This package provides a uniform interface for interacting with hardware via telnet/ssh +This package uses part of [reiver/go-telnet package](https://github.com/reiver/go-telnet) for handle telnet connection. + +## Usage + +```go +package main + +import ( + "fmt" + "log" + + "github.com/jgivc/console" +) + +func main() { + hosts := []console.Host{ + { + Host: "192.168.1.10", + Port: 22, + TransportType: console.TransportSSH, + Account: console.Account{ + Username: "admin", + Password: "pass", + }, + }, + { + Host: "192.168.1.20", + Port: 22, + TransportType: console.TransportTELNET, + Account: console.Account{ + Username: "admin", + Password: "pass", + }, + }, + } + + for _, h := range hosts { + c := console.New() + if err := c.Open(&h); err != nil { + log.Fatal(err) + } + defer c.Close() + + if err := c.Run("term le 0"); err != nil { + log.Fatal(err) + } + + out, err := c.Execute("sh ver") + if err != nil { + log.Fatal(err) + } + + fmt.Println(out) + + c.Send("q") + } + +} +``` diff --git a/console.go b/console.go new file mode 100644 index 0000000..3262c2a --- /dev/null +++ b/console.go @@ -0,0 +1,148 @@ +package console + +import ( + "strings" + "time" +) + +const ( + promptWaitTimeout = 5 * time.Second + promptMatchLen int = 15 + readBufferSize = 200 + + authPattern = `(?mi)(user\w+|pass\w+|[\w-()\s]+)[#>:]+(?:\s+)?` + promptPattern = `(?mi)([\w-()\s]{2,})[#>]+(?:\s+)?\z` + + userPromptPart = "user" + passwordPromptPart = "pass" +) + +var ( + cmdEnd = []byte("\r") +) + +type Console interface { + Open(host *Host) error + Execute(cmd string) (string, error) + Run(cmd string) error // Just run command and read and omit output + Send(cmd string) error + SetPrompt(pattern string) + Close() error +} + +type console struct { + h *Host + tr transport + pm promptMatcher + buf []byte +} + +func (c *console) tryAuth() error { + c.pm = newPromptRegexpMatcher(authPattern) + for { + _, err := c.readToPrompt() + if err != nil { + return err + } + + m := c.pm.getMatched() + if m != nil { + if ss, ok := m.([]string); ok { + line := ss[1] + line = strings.ToLower(line) + if strings.Contains(line, userPromptPart) { + c.Send(c.h.Username) + } else if strings.Contains(line, passwordPromptPart) { + c.Send(c.h.Password) + } else { + break + } + } + } + } + + c.pm = newPromptRegexpMatcher(promptPattern) + return nil +} + +func (c *console) readToPrompt() (string, error) { + b := make([]byte, 0) + start := time.Now() + + for { + n, err := c.tr.Read(c.buf) + if err != nil { + if err == errorRreadTimeout { + if time.Since(start) < promptWaitTimeout { + + continue + } + + return "", err + } + } + + b = append(b, c.buf[:n]...) + if l := len(b); l > promptMatchLen { + if c.pm.match(string(b[l-promptMatchLen:])) { + break + } + } + } + + return string(b), nil +} + +func (c *console) Open(host *Host) error { + var err error + c.tr, err = newTransport(host.TransportType) + if err != nil { + return err + } + + if err := c.tr.Open(host); err != nil { + return err + } + + c.h = host + + if err := c.tryAuth(); err != nil { + return err + } + + return nil + +} + +func (c *console) Execute(cmd string) (string, error) { + c.Send(cmd) + return c.readToPrompt() +} + +func (c *console) Run(cmd string) error { + c.Send(cmd) + _, err := c.readToPrompt() + + return err +} + +func (c *console) SetPrompt(pattern string) { + c.pm = newPromptRegexpMatcher(pattern) +} + +func (c *console) Close() error { + return c.tr.Close() +} + +func (c *console) Send(cmd string) error { + c.tr.Write([]byte(cmd)) + c.tr.Write(cmdEnd) + + return nil +} + +func New() Console { + return &console{ + buf: make([]byte, readBufferSize), + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3dfc8bb --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/jgivc/console + +go 1.16 + +require ( + github.com/reiver/go-oi v1.0.0 + golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fe7deaf --- /dev/null +++ b/go.sum @@ -0,0 +1,13 @@ +github.com/reiver/go-oi v1.0.0 h1:nvECWD7LF+vOs8leNGV/ww+F2iZKf3EYjYZ527turzM= +github.com/reiver/go-oi v1.0.0/go.mod h1:RrDBct90BAhoDTxB1fenZwfykqeGvhI6LsNfStJoEkI= +golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 h1:/pEO3GD/ABYAjuakUS6xSEmmlyVS4kxBNkeA9tLJiTI= +golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/host.go b/host.go new file mode 100644 index 0000000..dccf198 --- /dev/null +++ b/host.go @@ -0,0 +1,22 @@ +package console + +import "fmt" + +type Account struct { + Username, Password, EnablePassword string +} + +type Host struct { + Host string + Port int + TransportType int + Account +} + +func (h *Host) GetHostPort() string { + return fmt.Sprintf("%s:%d", h.Host, h.Port) +} + +func (h *Host) HasAccount() bool { + return !(h.Account == (Account{})) +} diff --git a/prompt.go b/prompt.go new file mode 100644 index 0000000..a4e4667 --- /dev/null +++ b/prompt.go @@ -0,0 +1,35 @@ +package console + +import ( + "regexp" +) + +type promptMatcher interface { + match(line string) bool + getMatched() interface{} +} + +type promptMatcherRegexp struct { + re *regexp.Regexp + matched interface{} +} + +func (m *promptMatcherRegexp) match(line string) bool { + m.matched = nil + if arr := m.re.FindStringSubmatch(line); arr != nil { + m.matched = arr + return true + } + + return false +} + +func (m *promptMatcherRegexp) getMatched() interface{} { + return m.matched +} + +func newPromptRegexpMatcher(pattern string) promptMatcher { + return &promptMatcherRegexp{ + re: regexp.MustCompile(pattern), + } +} diff --git a/reader.go b/reader.go new file mode 100644 index 0000000..5504c86 --- /dev/null +++ b/reader.go @@ -0,0 +1,119 @@ +package console + +import ( + "bytes" + "fmt" + "io" + "time" +) + +const ( + defaultTimeout = 30 * time.Millisecond +) + +var ( + errorRreadTimeout = fmt.Errorf("read timeout") +) + +type timeoutReader interface { + io.ReadCloser + SetTimeout(t time.Duration) +} + +type rd struct { + b []byte + err error +} + +type tReader struct { + r io.Reader + t time.Duration + ch chan rd + buf bytes.Buffer + closed bool +} + +func (r *tReader) Read(b []byte) (int, error) { + if r.closed { + return 0, fmt.Errorf("read on closed reader") + } + + if r.buf.Len() < 1 { + select { + case bb := <-r.ch: + if bb.err != nil { + return 0, bb.err + } + if err := sCopy(&r.buf, bb.b); err != nil { + return 0, err + } + case <-time.After(r.t): + return 0, errorRreadTimeout + } + + if r.closed { + return 0, fmt.Errorf("channel closed") + } + + } + + return r.buf.Read(b) +} + +func (r *tReader) Close() error { + r.closed = true + return nil +} + +func (r *tReader) SetTimeout(t time.Duration) { + r.t = t +} + +func newTimeoutReader(r io.Reader) timeoutReader { + ch := make(chan rd) + + go func() { + + b := make([]byte, 1024) + + ch <- rd{} + + for { + n, err := r.Read(b) + if err != nil { + ch <- rd{nil, err} + break + } + + tmp := make([]byte, n) + copy(tmp, b[:n]) + + ch <- rd{tmp, nil} + } + + }() + + <-ch + + return &tReader{ + r: r, + t: defaultTimeout, + ch: ch, + } +} + +func sCopy(w io.Writer, b []byte) error { + for { + n, err := w.Write(b) + if err != nil { + return err + } + if n < len(b) { + b = b[n:] + } else { + break + } + } + + return nil +} diff --git a/reader_test.go b/reader_test.go new file mode 100644 index 0000000..1654f58 --- /dev/null +++ b/reader_test.go @@ -0,0 +1,92 @@ +package console + +import ( + "bytes" + "io" + "strings" + "testing" + "time" +) + +type fr struct { + buf *bytes.Buffer + pause time.Duration +} + +func (r *fr) Read(p []byte) (int, error) { + if r.pause > 0 { + time.Sleep(r.pause) + } + + return r.buf.Read(p) +} + +func TestTReader(t *testing.T) { + data := []struct { + data string + timeout time.Duration + }{ + { + "aaaaaaaaaaaa;lk ;;lk;lk43r43r40jrf 4rf45f54 f4f", + 0, + }, + { + "aaaaaaaaaaaa;lk ;;lk;lk43r43r40jrf 4rf45f54 f4f 43rr43 43543 5435 43\n435435 3465 43", + 30 * time.Millisecond, + }, + { + "oi043r43 r435095lkr4m3tv54tv54 t54yt;l54-06mt 4566v 6546b57577n765765 7657657b65765765756765polkp;56;l;l;lk;lk;lk;k;5657bv6576576577657657b657", + 80 * time.Millisecond, + }, + } + + sb := &strings.Builder{} + + for _, d := range data { + r := &fr{buf: bytes.NewBufferString(d.data), pause: d.timeout} + tr := newTimeoutReader(r) + + b := make([]byte, 100) + + if d.timeout > 0 { + start := time.Now() + + tm := d.timeout - 5*time.Millisecond + + tr.SetTimeout(tm) + _, err := tr.Read(b) + if err == nil || err != errorRreadTimeout { + t.Fatalf("Timeout must be") + } + + if tm.Milliseconds() != time.Since(start).Milliseconds() { + t.Fatalf("Want timeout %d, but got %d\n", tm.Milliseconds(), time.Since(start).Milliseconds()) + } + + tr.SetTimeout(d.timeout + 20*time.Millisecond) + } + + sb.Reset() + + for { + n, err := tr.Read(b) + if err != nil { + if err == io.EOF { + break + } + + t.Fatal(err) + } + + if err := sCopy(sb, b[:n]); err != nil { + t.Fatal(err) + } + + } + + if d.data != sb.String() { + t.Fatalf("different strings") + } + + } +} diff --git a/ssh.go b/ssh.go new file mode 100644 index 0000000..96286da --- /dev/null +++ b/ssh.go @@ -0,0 +1,86 @@ +package console + +import ( + "io" + "time" + + "golang.org/x/crypto/ssh" +) + +type sshTransport struct { + client *ssh.Client + session *ssh.Session + stdin io.Writer + r timeoutReader +} + +func (t *sshTransport) Open(host *Host) error { + config := &ssh.ClientConfig{ + User: host.Username, + Auth: []ssh.AuthMethod{ + ssh.Password(host.Password), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + } + + client, err := ssh.Dial(protoTCP, host.GetHostPort(), config) + if err != nil { + return err + } + + session, err := client.NewSession() + if err != nil { + return err + } + + t.client = client + t.session = session + + if t.stdin, err = session.StdinPipe(); err != nil { + return err + } + + stdout, err := session.StdoutPipe() + if err != nil { + return err + } + + t.r = newTimeoutReader(stdout) + + modes := ssh.TerminalModes{ + ssh.ECHO: 0, // disable echoing + ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud + ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud + } + // Request pseudo terminal + if err := session.RequestPty("xterm", 40, 80, modes); err != nil { + return err + } + + err = session.Shell() + if err != nil { + return err + } + + return nil +} + +func (t *sshTransport) Read(b []byte) (int, error) { + return t.r.Read(b) +} + +func (t *sshTransport) Write(b []byte) (int, error) { + return t.stdin.Write(b) +} + +func (t *sshTransport) Close() error { + if t.session != nil { + t.session.Close() + } + + return t.client.Close() +} + +func (t *sshTransport) SetReadTimeout(d time.Duration) { + t.r.SetTimeout(d) +} diff --git a/telnet.go b/telnet.go new file mode 100644 index 0000000..9afca97 --- /dev/null +++ b/telnet.go @@ -0,0 +1,41 @@ +package console + +import ( + "time" + + "github.com/jgivc/console/telnet" +) + +type telnetTransport struct { + conn *telnet.Conn + r timeoutReader +} + +func (t *telnetTransport) Open(host *Host) error { + + conn, err := telnet.DialTo(host.GetHostPort()) + if err != nil { + return err + } + + t.conn = conn + t.r = newTimeoutReader(conn) + + return nil +} + +func (t *telnetTransport) Read(b []byte) (int, error) { + return t.r.Read(b) +} + +func (t *telnetTransport) Write(b []byte) (int, error) { + return t.conn.Write(b) +} + +func (t *telnetTransport) Close() error { + return t.conn.Close() +} + +func (t *telnetTransport) SetReadTimeout(d time.Duration) { + t.r.SetTimeout(d) +} diff --git a/telnet/conn.go b/telnet/conn.go new file mode 100644 index 0000000..31a9659 --- /dev/null +++ b/telnet/conn.go @@ -0,0 +1,148 @@ +package telnet + + +import ( + "crypto/tls" + "net" +) + + +type Conn struct { + conn interface { + Read(b []byte) (n int, err error) + Write(b []byte) (n int, err error) + Close() error + LocalAddr() net.Addr + RemoteAddr() net.Addr + } + dataReader *internalDataReader + dataWriter *internalDataWriter +} + + +// Dial makes a (un-secure) TELNET client connection to the system's 'loopback address' +// (also known as "localhost" or 127.0.0.1). +// +// If a secure connection is desired, use `DialTLS` instead. +func Dial() (*Conn, error) { + return DialTo("") +} + +// DialTo makes a (un-secure) TELNET client connection to the the address specified by +// 'addr'. +// +// If a secure connection is desired, use `DialToTLS` instead. +func DialTo(addr string) (*Conn, error) { + + const network = "tcp" + + if "" == addr { + addr = "127.0.0.1:telnet" + } + + conn, err := net.Dial(network, addr) + if nil != err { + return nil, err + } + + dataReader := newDataReader(conn) + dataWriter := newDataWriter(conn) + + clientConn := Conn{ + conn:conn, + dataReader:dataReader, + dataWriter:dataWriter, + } + + return &clientConn, nil +} + + +// DialTLS makes a (secure) TELNETS client connection to the system's 'loopback address' +// (also known as "localhost" or 127.0.0.1). +func DialTLS(tlsConfig *tls.Config) (*Conn, error) { + return DialToTLS("", tlsConfig) +} + +// DialToTLS makes a (secure) TELNETS client connection to the the address specified by +// 'addr'. +func DialToTLS(addr string, tlsConfig *tls.Config) (*Conn, error) { + + const network = "tcp" + + if "" == addr { + addr = "127.0.0.1:telnets" + } + + conn, err := tls.Dial(network, addr, tlsConfig) + if nil != err { + return nil, err + } + + dataReader := newDataReader(conn) + dataWriter := newDataWriter(conn) + + clientConn := Conn{ + conn:conn, + dataReader:dataReader, + dataWriter:dataWriter, + } + + return &clientConn, nil +} + + + +// Close closes the client connection. +// +// Typical usage might look like: +// +// telnetsClient, err = telnet.DialToTLS(addr, tlsConfig) +// if nil != err { +// //@TODO: Handle error. +// return err +// } +// defer telnetsClient.Close() +func (clientConn *Conn) Close() error { + return clientConn.conn.Close() +} + + +// Read receives `n` bytes sent from the server to the client, +// and "returns" into `p`. +// +// Note that Read can only be used for receiving TELNET (and TELNETS) data from the server. +// +// TELNET (and TELNETS) command codes cannot be received using this method, as Read deals +// with TELNET (and TELNETS) "unescaping", and (when appropriate) filters out TELNET (and TELNETS) +// command codes. +// +// Read makes Client fit the io.Reader interface. +func (clientConn *Conn) Read(p []byte) (n int, err error) { + return clientConn.dataReader.Read(p) +} + + +// Write sends `n` bytes from 'p' to the server. +// +// Note that Write can only be used for sending TELNET (and TELNETS) data to the server. +// +// TELNET (and TELNETS) command codes cannot be sent using this method, as Write deals with +// TELNET (and TELNETS) "escaping", and will properly "escape" anything written with it. +// +// Write makes Conn fit the io.Writer interface. +func (clientConn *Conn) Write(p []byte) (n int, err error) { + return clientConn.dataWriter.Write(p) +} + + +// LocalAddr returns the local network address. +func (clientConn *Conn) LocalAddr() net.Addr { + return clientConn.conn.LocalAddr() +} + + +// RemoteAddr returns the remote network address. +func (clientConn *Conn) RemoteAddr() net.Addr { + return clientConn.conn.RemoteAddr() +} diff --git a/telnet/data_reader.go b/telnet/data_reader.go new file mode 100644 index 0000000..9828368 --- /dev/null +++ b/telnet/data_reader.go @@ -0,0 +1,172 @@ +package telnet + +import ( + "bufio" + "errors" + "io" +) + +var ( + errCorrupted = errors.New("Corrupted") +) + +// An internalDataReader deals with "un-escaping" according to the TELNET protocol. +// +// In the TELNET protocol byte value 255 is special. +// +// The TELNET protocol calls byte value 255: "IAC". Which is short for "interpret as command". +// +// The TELNET protocol also has a distinction between 'data' and 'commands'. +// +//(DataReader is targetted toward TELNET 'data', not TELNET 'commands'.) +// +// If a byte with value 255 (=IAC) appears in the data, then it must be escaped. +// +// Escaping byte value 255 (=IAC) in the data is done by putting 2 of them in a row. +// +// So, for example: +// +// []byte{255} -> []byte{255, 255} +// +// Or, for a more complete example, if we started with the following: +// +// []byte{1, 55, 2, 155, 3, 255, 4, 40, 255, 30, 20} +// +// ... TELNET escaping would produce the following: +// +// []byte{1, 55, 2, 155, 3, 255, 255, 4, 40, 255, 255, 30, 20} +// +// (Notice that each "255" in the original byte array became 2 "255"s in a row.) +// +// DataReader deals with "un-escaping". In other words, it un-does what was shown +// in the examples. +// +// So, for example, it does this: +// +// []byte{255, 255} -> []byte{255} +// +// And, for example, goes from this: +// +// []byte{1, 55, 2, 155, 3, 255, 255, 4, 40, 255, 255, 30, 20} +// +// ... to this: +// +// []byte{1, 55, 2, 155, 3, 255, 4, 40, 255, 30, 20} +type internalDataReader struct { + wrapped io.Reader + buffered *bufio.Reader +} + +// newDataReader creates a new DataReader reading from 'r'. +func newDataReader(r io.Reader) *internalDataReader { + buffered := bufio.NewReader(r) + + reader := internalDataReader{ + wrapped: r, + buffered: buffered, + } + + return &reader +} + +// Read reads the TELNET escaped data from the wrapped io.Reader, and "un-escapes" it into 'data'. +func (r *internalDataReader) Read(data []byte) (n int, err error) { + + const IAC = 255 + + const SB = 250 + const SE = 240 + + const WILL = 251 + const WONT = 252 + const DO = 253 + const DONT = 254 + + p := data + + for len(p) > 0 { + var b byte + + if n > 0 && r.buffered.Buffered() < 1 { + break + } + + b, err = r.buffered.ReadByte() + if nil != err { + return n, err + } + + if IAC == b { + var peeked []byte + + peeked, err = r.buffered.Peek(1) + if nil != err { + return n, err + } + + switch peeked[0] { + case WILL, WONT, DO, DONT: + _, err = r.buffered.Discard(2) + if nil != err { + return n, err + } + case IAC: + p[0] = IAC + n++ + p = p[1:] + + _, err = r.buffered.Discard(1) + if nil != err { + return n, err + } + case SB: + for { + var b2 byte + b2, err = r.buffered.ReadByte() + if nil != err { + return n, err + } + + if IAC == b2 { + peeked, err = r.buffered.Peek(1) + if nil != err { + return n, err + } + + if IAC == peeked[0] { + _, err = r.buffered.Discard(1) + if nil != err { + return n, err + } + } + + if SE == peeked[0] { + _, err = r.buffered.Discard(1) + if nil != err { + return n, err + } + break + } + } + } + case SE: + _, err = r.buffered.Discard(1) + if nil != err { + return n, err + } + default: + // If we get in here, this is not following the TELNET protocol. + //@TODO: Make a better error. + err = errCorrupted + return n, err + } + } else { + + p[0] = b + n++ + p = p[1:] + } + } + + return n, nil +} diff --git a/telnet/data_reader_test.go b/telnet/data_reader_test.go new file mode 100644 index 0000000..f1d942d --- /dev/null +++ b/telnet/data_reader_test.go @@ -0,0 +1,353 @@ +package telnet + + +import ( + "bytes" + "io" + + "testing" +) + + +func TestDataReader(t *testing.T) { + + tests := []struct{ + Bytes []byte + Expected []byte + }{ + { + Bytes: []byte{}, + Expected: []byte{}, + }, + + + + { + Bytes: []byte("a"), + Expected: []byte("a"), + }, + { + Bytes: []byte("b"), + Expected: []byte("b"), + }, + { + Bytes: []byte("c"), + Expected: []byte("c"), + }, + + + + { + Bytes: []byte("apple"), + Expected: []byte("apple"), + }, + { + Bytes: []byte("banana"), + Expected: []byte("banana"), + }, + { + Bytes: []byte("cherry"), + Expected: []byte("cherry"), + }, + + + + { + Bytes: []byte("apple banana cherry"), + Expected: []byte("apple banana cherry"), + }, + + + + { + Bytes: []byte{255,255}, + Expected: []byte{255}, + }, + { + Bytes: []byte{255,255,255,255}, + Expected: []byte{255,255}, + }, + { + Bytes: []byte{255,255,255,255,255,255}, + Expected: []byte{255,255,255}, + }, + { + Bytes: []byte{255,255,255,255,255,255,255,255}, + Expected: []byte{255,255,255,255}, + }, + { + Bytes: []byte{255,255,255,255,255,255,255,255,255,255}, + Expected: []byte{255,255,255,255,255}, + }, + + + + { + Bytes: []byte("apple\xff\xffbanana\xff\xffcherry"), + Expected: []byte("apple\xffbanana\xffcherry"), + }, + { + Bytes: []byte("\xff\xffapple\xff\xffbanana\xff\xffcherry\xff\xff"), + Expected: []byte("\xffapple\xffbanana\xffcherry\xff"), + }, + + + + + { + Bytes: []byte("apple\xff\xff\xff\xffbanana\xff\xff\xff\xffcherry"), + Expected: []byte("apple\xff\xffbanana\xff\xffcherry"), + }, + { + Bytes: []byte("\xff\xff\xff\xffapple\xff\xff\xff\xffbanana\xff\xff\xff\xffcherry\xff\xff\xff\xff"), + Expected: []byte("\xff\xffapple\xff\xffbanana\xff\xffcherry\xff\xff"), + }, + + + + { + Bytes: []byte{255,251,24}, // IAC WILL TERMINAL-TYPE + Expected: []byte{}, + }, + { + Bytes: []byte{255,252,24}, // IAC WON'T TERMINAL-TYPE + Expected: []byte{}, + }, + { + Bytes: []byte{255,253,24}, // IAC DO TERMINAL-TYPE + Expected: []byte{}, + }, + { + Bytes: []byte{255,254,24}, // IAC DON'T TERMINAL-TYPE + Expected: []byte{}, + }, + + + + { + Bytes: []byte{67, 255,251,24}, // 'C' IAC WILL TERMINAL-TYPE + Expected: []byte{67}, + }, + { + Bytes: []byte{67, 255,252,24}, // 'C' IAC WON'T TERMINAL-TYPE + Expected: []byte{67}, + }, + { + Bytes: []byte{67, 255,253,24}, // 'C' IAC DO TERMINAL-TYPE + Expected: []byte{67}, + }, + { + Bytes: []byte{67, 255,254,24}, // 'C' IAC DON'T TERMINAL-TYPE + Expected: []byte{67}, + }, + + + + { + Bytes: []byte{255,251,24, 68}, // IAC WILL TERMINAL-TYPE 'D' + Expected: []byte{68}, + }, + { + Bytes: []byte{255,252,24, 68}, // IAC WON'T TERMINAL-TYPE 'D' + Expected: []byte{68}, + }, + { + Bytes: []byte{255,253,24, 68}, // IAC DO TERMINAL-TYPE 'D' + Expected: []byte{68}, + }, + { + Bytes: []byte{255,254,24, 68}, // IAC DON'T TERMINAL-TYPE 'D' + Expected: []byte{68}, + }, + + + { + Bytes: []byte{67, 255,251,24, 68}, // 'C' IAC WILL TERMINAL-TYPE 'D' + Expected: []byte{67,68}, + }, + { + Bytes: []byte{67, 255,252,24, 68}, // 'C' IAC WON'T TERMINAL-TYPE 'D' + Expected: []byte{67,68}, + }, + { + Bytes: []byte{67, 255,253,24, 68}, // 'C' IAC DO TERMINAL-TYPE 'D' + Expected: []byte{67,68}, + }, + { + Bytes: []byte{67, 255,254,24, 68}, // 'C' IAC DON'T TERMINAL-TYPE 'D' + Expected: []byte{67,68}, + }, + + + + { + Bytes: []byte{255, 250, 24, 1, 255, 240}, // IAC SB TERMINAL-TYPE SEND IAC SE + Expected: []byte{}, + }, + { + Bytes: []byte{255, 250, 24, 0, 68,69,67,45,86,84,53,50 ,255, 240}, // IAC SB TERMINAL-TYPE IS "DEC-VT52" IAC SE + Expected: []byte{}, + }, + + + + { + Bytes: []byte{67, 255, 250, 24, 1, 255, 240}, // 'C' IAC SB TERMINAL-TYPE SEND IAC SE + Expected: []byte{67}, + }, + { + Bytes: []byte{67, 255, 250, 24, 0, 68,69,67,45,86,84,53,50 ,255, 240}, // 'C' IAC SB TERMINAL-TYPE IS "DEC-VT52" IAC SE + Expected: []byte{67}, + }, + + + + { + Bytes: []byte{255, 250, 24, 1, 255, 240, 68}, // IAC SB TERMINAL-TYPE SEND IAC SE 'D' + Expected: []byte{68}, + }, + { + Bytes: []byte{255, 250, 24, 0, 68,69,67,45,86,84,53,50 ,255, 240, 68}, // IAC SB TERMINAL-TYPE IS "DEC-VT52" IAC SE 'D' + Expected: []byte{68}, + }, + + + + { + Bytes: []byte{67, 255, 250, 24, 1, 255, 240, 68}, // 'C' IAC SB TERMINAL-TYPE SEND IAC SE 'D' + Expected: []byte{67, 68}, + }, + { + Bytes: []byte{67, 255, 250, 24, 0, 68,69,67,45,86,84,53,50 ,255, 240, 68}, // 'C' IAC SB TERMINAL-TYPE IS "DEC-VT52" IAC SE 'D' + Expected: []byte{67, 68}, + }, + + + + { + Bytes: []byte{255,250, 0,1,2,3,4,5,6,7,8,9,10,11,12,13 ,255,240}, // IAC SB 0 1 2 3 4 5 6 7 8 9 10 11 12 13 IAC SE + Expected: []byte{}, + }, + { + Bytes: []byte{67, 255,250, 0,1,2,3,4,5,6,7,8,9,10,11,12,13 ,255,240}, // 'C' IAC SB 0 1 2 3 4 5 6 7 8 9 10 11 12 13 IAC SE + Expected: []byte{67}, + }, + { + Bytes: []byte{255,250, 0,1,2,3,4,5,6,7,8,9,10,11,12,13 ,255,240, 68}, // IAC SB 0 1 2 3 4 5 6 7 8 9 10 11 12 13 IAC SE 'D' + Expected: []byte{68}, + }, + { + Bytes: []byte{67, 255,250, 0,1,2,3,4,5,6,7,8,9,10,11,12,13 ,255,240, 68}, // 'C' IAC SB 0 1 2 3 4 5 6 7 8 9 10 11 12 13 IAC SE 'D' + Expected: []byte{67,68}, + }, + + + +//@TODO: Is this correct? Can IAC appear between thee 'IAC SB' and ''IAC SE'?... and if "yes", do escaping rules apply? + { + Bytes: []byte{ 255,250, 255,255,240 ,255,240}, // IAC SB 255 255 240 IAC SE = IAC SB IAC IAC SE IAC SE + Expected: []byte{}, + }, + { + Bytes: []byte{67, 255,250, 255,255,240 ,255,240}, // 'C' IAC SB 255 255 240 IAC SE = IAC SB IAC IAC SE IAC SE + Expected: []byte{67}, + }, + { + Bytes: []byte{ 255,250, 255,255,240 ,255,240, 68}, // IAC SB 255 255 240 IAC SE = IAC SB IAC IAC SE IAC SE 'D' + Expected: []byte{68}, + }, + { + Bytes: []byte{67, 255,250, 255,255,240 ,255,240, 68}, // 'C' IAC SB 255 255 240 IAC SE = IAC SB IAC IAC SE IAC SE 'D' + Expected: []byte{67,68}, + }, + + + +//@TODO: Is this correct? Can IAC appear between thee 'IAC SB' and ''IAC SE'?... and if "yes", do escaping rules apply? + { + Bytes: []byte{ 255,250, 71,255,255,240 ,255,240}, // IAC SB 'G' 255 255 240 IAC SE = IAC SB 'G' IAC IAC SE IAC SE + Expected: []byte{}, + }, + { + Bytes: []byte{67, 255,250, 71,255,255,240 ,255,240}, // 'C' IAC SB 'G' 255 255 240 IAC SE = IAC SB 'G' IAC IAC SE IAC SE + Expected: []byte{67}, + }, + { + Bytes: []byte{ 255,250, 71,255,255,240 ,255,240, 68}, // IAC SB 'G' 255 255 240 IAC SE = IAC SB 'G' IAC IAC SE IAC SE 'D' + Expected: []byte{68}, + }, + { + Bytes: []byte{67, 255,250, 71,255,255,240 ,255,240, 68}, // 'C' IAC SB 'G' 255 255 240 IAC SE = IAC 'G' SB IAC IAC SE IAC SE 'D' + Expected: []byte{67,68}, + }, + + + +//@TODO: Is this correct? Can IAC appear between thee 'IAC SB' and ''IAC SE'?... and if "yes", do escaping rules apply? + { + Bytes: []byte{ 255,250, 255,255,240,72 ,255,240}, // IAC SB 255 255 240 'H' IAC SE = IAC SB IAC IAC SE 'H' IAC SE + Expected: []byte{}, + }, + { + Bytes: []byte{67, 255,250, 255,255,240,72 ,255,240}, // 'C' IAC SB 255 255 240 'H' IAC SE = IAC SB IAC IAC SE 'H' IAC SE + Expected: []byte{67}, + }, + { + Bytes: []byte{ 255,250, 255,255,240,72 ,255,240, 68}, // IAC SB 255 255 240 'H' IAC SE = IAC SB IAC IAC SE 'H' IAC SE 'D' + Expected: []byte{68}, + }, + { + Bytes: []byte{67, 255,250, 255,255,240,72 ,255,240, 68}, // 'C' IAC SB 255 255 240 'H' IAC SE = IAC SB IAC IAC SE 'H' IAC SE 'D' + Expected: []byte{67,68}, + }, + + + +//@TODO: Is this correct? Can IAC appear between thee 'IAC SB' and ''IAC SE'?... and if "yes", do escaping rules apply? + { + Bytes: []byte{ 255,250, 71,255,255,240,72 ,255,240}, // IAC SB 'G' 255 255 240 'H' IAC SE = IAC SB 'G' IAC IAC SE 'H' IAC SE + Expected: []byte{}, + }, + { + Bytes: []byte{67, 255,250, 71,255,255,240,72 ,255,240}, // 'C' IAC SB 'G' 255 255 240 'H' IAC SE = IAC SB 'G' IAC IAC SE 'H' IAC SE + Expected: []byte{67}, + }, + { + Bytes: []byte{ 255,250, 71,255,255,240,72 ,255,240, 68}, // IAC SB 'G' 255 255 240 'H' IAC SE = IAC SB 'G' IAC IAC SE 'H' IAC SE 'D' + Expected: []byte{68}, + }, + { + Bytes: []byte{67, 255,250, 71,255,255,240,72 ,255,240, 68}, // 'C' IAC SB 'G' 255 255 240 'H' IAC SE = IAC 'G' SB IAC IAC SE 'H' IAC SE 'D' + Expected: []byte{67,68}, + }, + + + } + +//@TODO: Add random tests. + + + for testNumber, test := range tests { + + subReader := bytes.NewReader(test.Bytes) + + reader := newDataReader(subReader) + + buffer := make([]byte, 2*len(test.Bytes)) + n, err := reader.Read(buffer) + if nil != err && io.EOF != err { + t.Errorf("For test #%d, did not expected an error, but actually got one: (%T) %v; for %q -> %q.", testNumber, err, err, string(test.Bytes), string(test.Expected)) + continue + } + + if expected, actual := len(test.Expected), n; expected != actual { + t.Errorf("For test #%d, expected %d, but actually got %d (and %q); for %q -> %q.", testNumber, expected, actual, string(buffer[:n]), string(test.Bytes), string(test.Expected)) + continue + } + + if expected, actual := string(test.Expected), string(buffer[:n]); expected != actual { + t.Errorf("For test #%d, expected %q, but actually got %q; for %q -> %q.", testNumber, expected, actual, string(test.Bytes), string(test.Expected)) + continue + } + } +} diff --git a/telnet/data_writer.go b/telnet/data_writer.go new file mode 100644 index 0000000..af99557 --- /dev/null +++ b/telnet/data_writer.go @@ -0,0 +1,142 @@ +package telnet + + +import ( + "github.com/reiver/go-oi" + + "bytes" + "errors" + "io" +) + + +var iaciac []byte = []byte{255, 255} + +var errOverflow = errors.New("Overflow") +var errPartialIACIACWrite = errors.New("Partial IAC IAC write.") + + +// An internalDataWriter deals with "escaping" according to the TELNET (and TELNETS) protocol. +// +// In the TELNET (and TELNETS) protocol byte value 255 is special. +// +// The TELNET (and TELNETS) protocol calls byte value 255: "IAC". Which is short for "interpret as command". +// +// The TELNET (and TELNETS) protocol also has a distinction between 'data' and 'commands'. +// +//(DataWriter is targetted toward TELNET (and TELNETS) 'data', not TELNET (and TELNETS) 'commands'.) +// +// If a byte with value 255 (=IAC) appears in the data, then it must be escaped. +// +// Escaping byte value 255 (=IAC) in the data is done by putting 2 of them in a row. +// +// So, for example: +// +// []byte{255} -> []byte{255, 255} +// +// Or, for a more complete example, if we started with the following: +// +// []byte{1, 55, 2, 155, 3, 255, 4, 40, 255, 30, 20} +// +// ... TELNET escaping would produce the following: +// +// []byte{1, 55, 2, 155, 3, 255, 255, 4, 40, 255, 255, 30, 20} +// +// (Notice that each "255" in the original byte array became 2 "255"s in a row.) +// +// internalDataWriter takes care of all this for you, so you do not have to do it. +type internalDataWriter struct { + wrapped io.Writer +} + + +// newDataWriter creates a new internalDataWriter writing to 'w'. +// +// 'w' receives what is written to the *internalDataWriter but escaped according to +// the TELNET (and TELNETS) protocol. +// +// I.e., byte 255 (= IAC) gets encoded as 255, 255. +// +// For example, if the following it written to the *internalDataWriter's Write method: +// +// []byte{1, 55, 2, 155, 3, 255, 4, 40, 255, 30, 20} +// +// ... then (conceptually) the following is written to 'w's Write method: +// +// []byte{1, 55, 2, 155, 3, 255, 255, 4, 40, 255, 255, 30, 20} +// +// (Notice that each "255" in the original byte array became 2 "255"s in a row.) +// +// *internalDataWriter takes care of all this for you, so you do not have to do it. +func newDataWriter(w io.Writer) *internalDataWriter { + writer := internalDataWriter{ + wrapped:w, + } + + return &writer +} + + +// Write writes the TELNET (and TELNETS) escaped data for of the data in 'data' to the wrapped io.Writer. +func (w *internalDataWriter) Write(data []byte) (n int, err error) { + var n64 int64 + + n64, err = w.write64(data) + n = int(n64) + if int64(n) != n64 { + panic(errOverflow) + } + + return n, err +} + + +func (w *internalDataWriter) write64(data []byte) (n int64, err error) { + + if len(data) <= 0 { + return 0, nil + } + + const IAC = 255 + + var buffer bytes.Buffer + for _, datum := range data { + + if IAC == datum { + + if buffer.Len() > 0 { + var numWritten int64 + + numWritten, err = oi.LongWrite(w.wrapped, buffer.Bytes()) + n += numWritten + if nil != err { + return n, err + } + buffer.Reset() + } + + + var numWritten int64 + //@TODO: Should we worry about "iaciac" potentially being modified by the .Write()? + numWritten, err = oi.LongWrite(w.wrapped, iaciac) + if int64(len(iaciac)) != numWritten { + //@TODO: Do we really want to panic() here? + panic(errPartialIACIACWrite) + } + n += 1 + if nil != err { + return n, err + } + } else { + buffer.WriteByte(datum) // The returned error is always nil, so we ignore it. + } + } + + if buffer.Len() > 0 { + var numWritten int64 + numWritten, err = oi.LongWrite(w.wrapped, buffer.Bytes()) + n += numWritten + } + + return n, err +} diff --git a/telnet/data_writer_test.go b/telnet/data_writer_test.go new file mode 100644 index 0000000..0a3e199 --- /dev/null +++ b/telnet/data_writer_test.go @@ -0,0 +1,116 @@ +package telnet + + +import ( + "bytes" + + "testing" +) + + +func TestDataWriter(t *testing.T) { + + tests := []struct{ + Bytes []byte + Expected []byte + }{ + { + Bytes: []byte{}, + Expected: []byte{}, + }, + + + + { + Bytes: []byte("apple"), + Expected: []byte("apple"), + }, + { + Bytes: []byte("banana"), + Expected: []byte("banana"), + }, + { + Bytes: []byte("cherry"), + Expected: []byte("cherry"), + }, + + + + { + Bytes: []byte("apple banana cherry"), + Expected: []byte("apple banana cherry"), + }, + + + + { + Bytes: []byte{255}, + Expected: []byte{255,255}, + }, + { + Bytes: []byte{255,255}, + Expected: []byte{255,255,255,255}, + }, + { + Bytes: []byte{255,255,255}, + Expected: []byte{255,255,255,255,255,255}, + }, + { + Bytes: []byte{255,255,255,255}, + Expected: []byte{255,255,255,255,255,255,255,255}, + }, + { + Bytes: []byte{255,255,255,255,255}, + Expected: []byte{255,255,255,255,255,255,255,255,255,255}, + }, + + + + { + Bytes: []byte("apple\xffbanana\xffcherry"), + Expected: []byte("apple\xff\xffbanana\xff\xffcherry"), + }, + { + Bytes: []byte("\xffapple\xffbanana\xffcherry\xff"), + Expected: []byte("\xff\xffapple\xff\xffbanana\xff\xffcherry\xff\xff"), + }, + + + + + { + Bytes: []byte("apple\xff\xffbanana\xff\xffcherry"), + Expected: []byte("apple\xff\xff\xff\xffbanana\xff\xff\xff\xffcherry"), + }, + { + Bytes: []byte("\xff\xffapple\xff\xffbanana\xff\xffcherry\xff\xff"), + Expected: []byte("\xff\xff\xff\xffapple\xff\xff\xff\xffbanana\xff\xff\xff\xffcherry\xff\xff\xff\xff"), + }, + } + +//@TODO: Add random tests. + + + for testNumber, test := range tests { + + subWriter := new(bytes.Buffer) + + writer := newDataWriter(subWriter) + + n, err := writer.Write(test.Bytes) + if nil != err { + t.Errorf("For test #%d, did not expected an error, but actually got one: (%T) %v; for %q -> %q.", testNumber, err, err, string(test.Bytes), string(test.Expected)) + continue + } + + if expected, actual := len(test.Bytes), n; expected != actual { + t.Errorf("For test #%d, expected %d, but actually got %d; for %q -> %q.", testNumber, expected, actual, string(test.Bytes), string(test.Expected)) + continue + } + + if expected, actual := string(test.Expected), subWriter.String(); expected != actual { + t.Errorf("For test #%d, expected %q, but actually got %q; for %q -> %q.", testNumber, expected, actual, string(test.Bytes), string(test.Expected)) + continue + } + } +} diff --git a/transport.go b/transport.go new file mode 100644 index 0000000..6deb00a --- /dev/null +++ b/transport.go @@ -0,0 +1,34 @@ +package console + +import ( + "fmt" + "io" + "time" +) + +const ( + protoTCP = "tcp" + + defaultSSHPort = 22 + defaultTelnetPort = 23 + + TransportSSH = iota + TransportTELNET +) + +type transport interface { + Open(host *Host) error + SetReadTimeout(t time.Duration) + io.ReadWriteCloser +} + +func newTransport(t int) (transport, error) { + switch t { + case TransportSSH: + return &sshTransport{}, nil + case TransportTELNET: + return &telnetTransport{}, nil + } + + return nil, fmt.Errorf("unknown transport type") +} diff --git a/uri.go b/uri.go new file mode 100644 index 0000000..b9d78fc --- /dev/null +++ b/uri.go @@ -0,0 +1,84 @@ +package console + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +const ( + defaultTransport = TransportTELNET +) + +/** +ssh://user:pass:enablepass@host:port +telnet://user:pass:enablepass@host:port +user:pass:enablepass@host:port +user:pass:enablepass@host +user:pass@host +host +*/ +type URI string + +var ( + uriRegexp = regexp.MustCompile(`(?i)^(?:(?P\w+)://)?(?:(?P[\w.-]+):(?P[^:]+)(?::(?P[^@]+))?@)?(?P[\w.-]+)(?::(?P\d{2,5}))?$`) +) + +func (u URI) ToHost() (*Host, error) { + m := uriRegexp.FindStringSubmatch(string(u)) + if m == nil { + return nil, fmt.Errorf("cannot convert") + } + + var h Host + + for i, name := range uriRegexp.SubexpNames() { + switch name { + case "schema": + s := strings.ToLower(m[i]) + switch s { + case "ssh": + h.TransportType = TransportSSH + case "telnet": + h.TransportType = TransportTELNET + case "": + h.TransportType = defaultTransport + default: + return nil, fmt.Errorf("unknown scheme") + } + case "user": + h.Account.Username = m[i] + case "pass": + h.Account.Password = m[i] + case "enable": + h.Account.EnablePassword = m[i] + case "host": + h.Host = m[i] + case "port": + if m[i] == "" { + h.Port = -1 + } else { + p, err := strconv.Atoi(m[i]) + if err != nil { + return nil, err + } + + h.Port = p + } + } + } + + if h.Port < 0 { + switch h.TransportType { + case TransportTELNET: + h.Port = defaultTelnetPort + case TransportSSH: + h.Port = defaultSSHPort + default: + return nil, fmt.Errorf("unknown scheme") + } + } + + return &h, nil +} diff --git a/uri_test.go b/uri_test.go new file mode 100644 index 0000000..5a04985 --- /dev/null +++ b/uri_test.go @@ -0,0 +1,73 @@ +package console + +import ( + "reflect" + "testing" +) + +func TestURI(t *testing.T) { + data := map[string]*Host{ + "10.1.1.1": { + Host: "10.1.1.1", + Port: defaultTelnetPort, + TransportType: TransportTELNET, + }, + "ssh://10.1.1.1": { + Host: "10.1.1.1", + Port: defaultSSHPort, + TransportType: TransportSSH, + }, + "telnet://10.1.1.1": { + Host: "10.1.1.1", + Port: defaultTelnetPort, + TransportType: TransportTELNET, + }, + "ssh://10.1.1.1:12345": { + Host: "10.1.1.1", + Port: 12345, + TransportType: TransportSSH, + }, + "user:pass@10.1.1.1": { + Host: "10.1.1.1", + Port: defaultTelnetPort, + TransportType: TransportTELNET, + Account: Account{ + Username: "user", + Password: "pass", + }, + }, + "user:pass:enable@10.1.1.1": { + Host: "10.1.1.1", + Port: defaultTelnetPort, + TransportType: TransportTELNET, + Account: Account{ + Username: "user", + Password: "pass", + EnablePassword: "enable", + }, + }, + "ssh://user:pass:enable@10.1.1.1": { + Host: "10.1.1.1", + Port: defaultSSHPort, + TransportType: TransportSSH, + Account: Account{ + Username: "user", + Password: "pass", + EnablePassword: "enable", + }, + }, + } + + for k := range data { + u := URI(k) + h, err := u.ToHost() + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(h, data[k]) { + t.Fatalf("error for uri: '%s', want: %v but got: %v", k, data[k], h) + } + } + +}