Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.
ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ
0058 подієві алерти: syslog, ncm, compliance спрацьовують у мить
події; правило з нереалізованим джерелом більше не зберігається
мовчки
0059 snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
описуються шаблоном, а не Go
0060 відкат конфігу: план як різниця, маскування паролів із підписом
плану, обов'язковий контрольний збір, verifying при обриві
0061 кнопки Telegram: довге опитування, авторизація не з callback_data
0062 аудит і архів хостів; тест на AST, що падає на ключі без назви
0063 RLS: три ролі, окремий пул для фонових тактів
0064 строки зберігання даних і сторінка сховища
0065 приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
переклад v1→v2 за RFC 3584 дає правильний OID
0066 ескалації сповіщень
0067 алерт про вичерпання диска
0068 поля заливки конфігу переїхали в каталог профілів
Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.
ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ
netpulse установник: одна команда замість 18 змінних і
593 рядків інструкції
RLS з першого запуску нова інсталяція під політиками одразу;
RLS-EXISTING-INSTALL.md лишається тільки для
старих інсталяцій
.forgejo + CI раннер не зареєстрований
Ці три перевірені компіляцією й міркуванням, але не виконанням.
ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ
Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.
Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.
Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
432 lines
15 KiB
Go
432 lines
15 KiB
Go
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
|
||
}
|