Netpulse_SasS/agent/internal/ncmx/transport.go
byrsapty fe4ed4e7d9
Some checks failed
CI / web (push) Has been cancelled
CI / server (push) Has been cancelled
CI / agent (push) Has been cancelled
Дедлайн сокета від входу через telnet обривав збір конфігу
Знайдено на живому ZTE C320. Вхід чекає «Username:» з дедлайном читання
у дві секунди, а дедлайн сокета липкий: він лишався на всі наступні
читання.

Наслідок подвійний. Довгий конфіг обривався на середині помилкою
«read tcp: i/o timeout» — мережевою там, де мережа ні до чого. А коли
ще й запрошення не збігалось зі зразком, той самий дедлайн спрацьовував
раніше за власний таймер, і замість «не дочекались запрошення» людина
бачила ту саму мережеву помилку.

Щоб це побачити, довелося спершу полагодити діагностику: стенограма
починалась після входу, тобто після того місця, де все зупинялось.
Тепер розмова входу пишеться теж — без пароля, лише те, що надіслав
пристрій.

Плюс вбудований профіль zte-zxan: наявні профілі ZTE чекають «>» або
«]», а OLT показує «ZXAN#». Окремий профіль, бо в Comware трапляються
рядки з самої решітки, і зразок із «#» обірвав би їхній конфіг.

Результат на живому C320: 26 555 рядків.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 19:04:36 +03:00

402 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, timeout time.Duration, tr *bytes.Buffer) (Conn, error) {
switch transport {
case "ssh", "":
if port == 0 {
port = 22
}
return dialSSH(ctx, host, port, user, password, 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, timeout time.Duration) (Conn, error) {
cfg := &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{
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
}),
},
// Ключі мережевого обладнання не звіряються.
//
// Це свідоме рішення, а не недогляд. Зонд стоїть усередині
// мережі клієнта й ходить до заліза, яке перегенеровує ключ
// після кожної заміни прошивки чи скидання. Тримати 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
}