Netpulse_SasS/agent/internal/ncmx/transport.go
byrsapty 3816e2cfcf Етап 7: збір конфігів запрацював наскрізно
Профілі були даними без виконавця. Тепер ланцюг замкнено: черга в БД →
диспетчер у AgentService → зонд по SSH/Telnet → вивантаження стрімом →
звірка sha256 і дедуплікація.

Агент (internal/ncmx):
- усе побудовано навколо пошуку промпту: у консолі немає ані коду
  завершення, ані довжини відповіді
- промпт шукається лише в хвості 512 байтів, інакше "banner motd #"
  обривав би збір на середині конфігу
- дедлайн на паузу між байтами, а не на всю операцію: збір із шасі
  триває хвилини, а тиша означає завислий пристрій або хибний regex
- ключі SSH обладнання не звіряються свідомо: залізо міняє їх з кожною
  прошивкою, і known_hosts на сотні пристроїв означав би не збирати
  конфіги зовсім

Сервер: черга ncm.jobs із FOR UPDATE SKIP LOCKED (два екземпляри не
надішлють одне завдання двічі), диспетчер, прибирання завислих.

Знайдено живим прогоном:
- сервер ігнорував поле encoding: агент стискав gzip і рахував sha256
  від оригіналу, сервер рахував від стиснених байтів і відхиляв кожен
  бекап. Поле в контракті з Етапу 2, реалізації не було — інтеграційний
  тест користувався "none" і повз дірку проходив
- промпт обрізався не по рядку: шаблон [>#]\s*$ ловить лише символ, і
  в конфізі лишалось ім'я пристрою окремим рядком
- у конфіг потрапляли escape-послідовності bash і подвоєні \r\r\n від
  PTY: 295 байтів / 12 рядків замість 286 / 10. Після виправлення —
  285 / 10, різниця лише у фінальному переводі рядка

Живий прогін проти стенду: success, конфіг 285 байтів, повторний збір —
unchanged без другої версії.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 12:02:50 +03:00

365 lines
11 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 (
"context"
"errors"
"fmt"
"io"
"net"
"strconv"
"time"
"golang.org/x/crypto/ssh"
)
// Dial відкриває інтерактивну сесію потрібним транспортом.
func Dial(ctx context.Context, transport, host string, port int,
user, password string, timeout time.Duration) (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)
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 {
conn net.Conn
// leftover — байти, що лишились після зняття команд протоколу.
leftover []byte
}
func dialTelnet(ctx context.Context, host string, port int,
user, password string, timeout time.Duration) (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}
// Вхід у 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)
}
}
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]...)
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
}