Профілі були даними без виконавця. Тепер ланцюг замкнено: черга в БД → диспетчер у 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>
365 lines
11 KiB
Go
365 lines
11 KiB
Go
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
|
||
}
|