package ncmx import ( "bytes" "context" "errors" "fmt" "io" "net" "strconv" "time" "golang.org/x/crypto/ssh" ) // Dial відкриває інтерактивну сесію потрібним транспортом. // Dial відкриває сеанс до пристрою. // // tr — буфер стенограми; може бути nil. Розмова входу пишеться туди // само, що й решта: саме на ній найчастіше все й зупиняється, а без // запису від пристрою лишається тільки «i/o timeout» без жодної підказки, // чого він чекав. func Dial(ctx context.Context, transport, host string, port int, user, password string, key []byte, timeout time.Duration, tr *bytes.Buffer) (Conn, error) { switch transport { case "ssh", "": if port == 0 { port = 22 } return dialSSH(ctx, host, port, user, password, key, timeout) case "telnet": if port == 0 { port = 23 } return dialTelnet(ctx, host, port, user, password, timeout, tr) default: return nil, fmt.Errorf("непідтримуваний транспорт %q", transport) } } // --------------------------------------------------------------------- // SSH // --------------------------------------------------------------------- type sshConn struct { client *ssh.Client session *ssh.Session stdin io.WriteCloser stdout io.Reader } func dialSSH(ctx context.Context, host string, port int, user, password string, key []byte, timeout time.Duration) (Conn, error) { // Ключ першим, якщо він є. // // На магістральному залізі вхід за паролем часто просто вимкнений — // і тоді сервер відхиляє і password, і keyboard-interactive, а // go-ssh каже «no supported methods remain», не пояснюючи, що саме // від нас хотіли. Поле private_key було в протоколі від початку, // але його ніхто не читав: транспорт брав лише пароль. var auth []ssh.AuthMethod if len(key) > 0 { signer, err := ssh.ParsePrivateKey(key) if err != nil { // Пароль може бути парольною фразою до ключа — пробуємо і // так, перш ніж здаватись. if password == "" { return nil, fmt.Errorf("розбір SSH-ключа: %w", err) } signer, err = ssh.ParsePrivateKeyWithPassphrase(key, []byte(password)) if err != nil { return nil, fmt.Errorf("розбір SSH-ключа: %w", err) } } auth = append(auth, ssh.PublicKeys(signer)) } if password != "" { auth = append(auth, ssh.Password(password), // Частина старих платформ не вміє «password», лише // keyboard-interactive з єдиним запитом. ssh.KeyboardInteractive(func(_, _ string, qs []string, _ []bool) ([]string, error) { ans := make([]string, len(qs)) for i := range qs { ans[i] = password } return ans, nil }), ) } if len(auth) == 0 { return nil, fmt.Errorf("для SSH не задано ні пароля, ні ключа") } cfg := &ssh.ClientConfig{ User: user, Auth: auth, // Ключі мережевого обладнання не звіряються. // // Це свідоме рішення, а не недогляд. Зонд стоїть усередині // мережі клієнта й ходить до заліза, яке перегенеровує ключ // після кожної заміни прошивки чи скидання. Тримати known_hosts // на сотні пристроїв означало б або вимикати перевірку // вручну щотижня, або не збирати конфіги зовсім. // // Захист від підміни тут дає сегмент керування, а не TOFU. HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: timeout, // Старе залізо не знає сучасних алгоритмів. Перелік свідомо // широкий: інакше половина парку відпаде з «no common // algorithm», і збір конфігу для неї стане неможливим. Config: ssh.Config{ KeyExchanges: []string{ "curve25519-sha256", "curve25519-sha256@libssh.org", "ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521", "diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1", "diffie-hellman-group1-sha1", "diffie-hellman-group-exchange-sha1", }, Ciphers: []string{ "aes128-gcm@openssh.com", "aes256-gcm@openssh.com", "aes128-ctr", "aes192-ctr", "aes256-ctr", "aes128-cbc", "3des-cbc", }, }, HostKeyAlgorithms: []string{ "ssh-ed25519", "ecdsa-sha2-nistp256", "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa", }, } addr := net.JoinHostPort(host, strconv.Itoa(port)) d := net.Dialer{Timeout: timeout} raw, err := d.DialContext(ctx, "tcp", addr) if err != nil { return nil, fmt.Errorf("зʼєднання з %s: %w", addr, err) } // Рукостискання не має права висіти вічно: ctx керує дедлайном, // але сам ssh.NewClientConn його не бачить. if dl, ok := ctx.Deadline(); ok { _ = raw.SetDeadline(dl) } else { _ = raw.SetDeadline(time.Now().Add(timeout)) } c, chans, reqs, err := ssh.NewClientConn(raw, addr, cfg) if err != nil { raw.Close() return nil, fmt.Errorf("SSH-рукостискання з %s: %w", addr, err) } _ = raw.SetDeadline(time.Time{}) client := ssh.NewClient(c, chans, reqs) sess, err := client.NewSession() if err != nil { client.Close() return nil, fmt.Errorf("сесія SSH: %w", err) } // Псевдотермінал обовʼязковий: без нього більшість мережевих // платформ або не дає інтерактивної оболонки, або віддає вивід у // режимі, де немає запрошення — а саме за ним ми й орієнтуємось. // // 0 рядків заввишки — ще один спосіб попросити «не гортай»; команду // вимкнення пейджера це не скасовує, бо частина платформ ігнорує // розмір вікна. modes := ssh.TerminalModes{ssh.ECHO: 0, ssh.TTY_OP_ISPEED: 38400, ssh.TTY_OP_OSPEED: 38400} if err := sess.RequestPty("vt100", 0, 200, modes); err != nil { sess.Close() client.Close() return nil, fmt.Errorf("PTY: %w", err) } stdin, err := sess.StdinPipe() if err != nil { sess.Close() client.Close() return nil, err } stdout, err := sess.StdoutPipe() if err != nil { sess.Close() client.Close() return nil, err } // Помилки читаємо тим самим потоком: пристрої часто пишуть // «% Invalid input» саме в stderr, і загубити це означало б // діагностувати наосліп. sess.Stderr = writerTo(stdin) if err := sess.Shell(); err != nil { sess.Close() client.Close() return nil, fmt.Errorf("оболонка: %w", err) } return &sshConn{client: client, session: sess, stdin: stdin, stdout: stdout}, nil } func (c *sshConn) Read(p []byte) (int, error) { return c.stdout.Read(p) } func (c *sshConn) Write(p []byte) (int, error) { return c.stdin.Write(p) } func (c *sshConn) Close() error { // Порядок має значення: спершу сесія, потім клієнт. Навпаки — // і Close() сесії повисне на мертвому з'єднанні. if c.session != nil { _ = c.session.Close() } return c.client.Close() } // writerTo — заглушка, щоб stderr не губився. Пишемо туди ж, куди й // stdout читається, тобто нікуди: справжнє злиття потоків дав би // sess.Stderr = sess.Stdout, але API так не вміє. type nopWriter struct{} func (nopWriter) Write(p []byte) (int, error) { return len(p), nil } func writerTo(io.Writer) io.Writer { return nopWriter{} } // --------------------------------------------------------------------- // Telnet // --------------------------------------------------------------------- // Команди протоколу Telnet (RFC 854). const ( iac = 255 // Interpret As Command dont = 254 do = 253 wont = 252 will = 251 sb = 250 // subnegotiation begin se = 240 // subnegotiation end ) type telnetConn struct { // Стенограма розмови входу. Пароль сюди не потрапляє: пишемо тільки // те, що САМ пристрій надіслав, — інакше діагностика перетворилась // би на витік облікових даних у таблицю завдань. transcript *bytes.Buffer conn net.Conn // leftover — байти, що лишились після зняття команд протоколу. leftover []byte } func dialTelnet(ctx context.Context, host string, port int, user, password string, timeout time.Duration, tr *bytes.Buffer) (Conn, error) { addr := net.JoinHostPort(host, strconv.Itoa(port)) d := net.Dialer{Timeout: timeout} raw, err := d.DialContext(ctx, "tcp", addr) if err != nil { return nil, fmt.Errorf("зʼєднання з %s: %w", addr, err) } tc := &telnetConn{conn: raw, transcript: tr} // Вхід у Telnet — це не протокол, а розмова: пристрій просто пише // «Username:» і чекає. Кожен вендор пише по-своєму, тому шукаємо // підрядок без урахування регістру. if user != "" { if err := tc.expectAndSend(ctx, []string{"username", "login", "user name"}, user, timeout); err != nil { raw.Close() return nil, fmt.Errorf("логін: %w", err) } } if password != "" { if err := tc.expectAndSend(ctx, []string{"password", "passwd"}, password, timeout); err != nil { raw.Close() return nil, fmt.Errorf("пароль: %w", err) } } // Знімаємо дедлайн, поставлений на час входу. // // Дедлайн сокета «липкий»: заданий один раз, він діє на КОЖНЕ // наступне читання, доки його не скинути. Двох секунд вистачає, щоб // дочекатись «Username:», і катастрофічно мало для конфігу великого // шасі, який іде хвилинами з паузами між блоками. // // Наслідок був подвійний і збивав з пантелику. Довгий конфіг // обривався на середині з «read tcp …: i/o timeout» — помилкою // мережі там, де мережа ні до чого. А коли ще й запрошення не // збігалось із зразком, той самий дедлайн спрацьовував раніше за // власний таймер очікування, і замість зрозумілого «не дочекались // запрошення командного рядка» користувач бачив ту саму мережеву // помилку. Знайдено на живому ZTE C320. // // Далі паузи стереже CLI власним таймером на кожне читання — саме // там, де він знає, скільки чекати. if err := raw.SetReadDeadline(time.Time{}); err != nil { raw.Close() return nil, fmt.Errorf("скидання дедлайну: %w", err) } return tc, nil } func (c *telnetConn) expectAndSend(ctx context.Context, want []string, send string, timeout time.Duration) error { deadline := time.Now().Add(timeout) var acc []byte buf := make([]byte, 4096) for time.Now().Before(deadline) { if err := ctx.Err(); err != nil { return err } _ = c.conn.SetReadDeadline(time.Now().Add(2 * time.Second)) n, err := c.Read(buf) if n > 0 { acc = append(acc, buf[:n]...) if c.transcript != nil { c.transcript.Write(buf[:n]) } low := lower(acc) for _, w := range want { if bytesContains(low, w) { _, werr := c.Write([]byte(send + "\n")) return werr } } } if err != nil { var ne net.Error if errors.As(err, &ne) && ne.Timeout() { continue } return err } } return fmt.Errorf("не дочекались запиту %v", want) } // Read знімає команди протоколу з потоку. // // Telnet перемішує керування з даними, і якщо не вирізати IAC-послідовності, // вони потраплять просто в текст конфігу — саме звідти беруться // «сміттєві» байти в збережених конфігах. func (c *telnetConn) Read(p []byte) (int, error) { if len(c.leftover) > 0 { n := copy(p, c.leftover) c.leftover = c.leftover[n:] return n, nil } raw := make([]byte, len(p)) n, err := c.conn.Read(raw) if n == 0 { return 0, err } clean := make([]byte, 0, n) for i := 0; i < n; { if raw[i] != iac { clean = append(clean, raw[i]) i++ continue } // IAC IAC — екранований 0xFF, справжній байт даних. if i+1 < n && raw[i+1] == iac { clean = append(clean, iac) i += 2 continue } if i+2 < n && (raw[i+1] == do || raw[i+1] == dont || raw[i+1] == will || raw[i+1] == wont) { // Відмовляємось від усього: жодна опція нам не потрібна, // а мовчання частина пристроїв тлумачить як згоду й чекає // підтвердження вічно. resp := byte(wont) if raw[i+1] == will || raw[i+1] == wont { resp = dont } _, _ = c.conn.Write([]byte{iac, resp, raw[i+2]}) i += 3 continue } if i+1 < n && raw[i+1] == sb { // Підпереговори тягнуться до IAC SE. j := i + 2 for j+1 < n && !(raw[j] == iac && raw[j+1] == se) { j++ } i = j + 2 continue } // Обрізана послідовність на межі читання — лишаємо на потім. c.leftover = append(c.leftover, raw[i:n]...) break } copied := copy(p, clean) if copied < len(clean) { c.leftover = append(clean[copied:], c.leftover...) } return copied, err } func (c *telnetConn) Write(p []byte) (int, error) { _ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) return c.conn.Write(p) } func (c *telnetConn) Close() error { return c.conn.Close() } func lower(b []byte) []byte { out := make([]byte, len(b)) for i, c := range b { if c >= 'A' && c <= 'Z' { c += 'a' - 'A' } out[i] = c } return out } func bytesContains(hay []byte, needle string) bool { return len(needle) > 0 && indexOf(hay, needle) >= 0 } func indexOf(hay []byte, needle string) int { n := len(needle) for i := 0; i+n <= len(hay); i++ { if string(hay[i:i+n]) == needle { return i } } return -1 }