diff --git a/HISTORY.md b/HISTORY.md index a04474a..1da7b9a 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -3042,3 +3042,78 @@ icmp-basic сам створив 2 правила сповіщень через 8 с http.status, http.latency_ms, http.ok, ssl.days_left хост прибрано ``` + +## Живе обладнання: ZTE C320 + +Перший OLT у системі знайшов помилку, яку не знайшов би жоден стенд. + +### Симптом + +`привітання пристрою: read tcp …->10.1.0.4:23: i/o timeout`. Мережева +помилка там, де мережа ні до чого: порт відкритий, логін і пароль на +місці. + +### Стенограма була порожня — і це перше, що довелося полагодити + +Запис сеансу починався **після** входу, а зупинялось усе саме на ньому. +У таблиці завдань лишався голий `i/o timeout` без жодної підказки, чого +пристрій чекав. + +Тепер буфер створюється до підключення й розмова входу пишеться туди +само. Пароль у стенограму не потрапляє: пишемо тільки те, що надіслав +сам пристрій, — інакше діагностика перетворилась би на витік облікових +даних у таблицю. + +Перший же прогін після цього все й пояснив: + +``` +Welcome to ZXAN product C320 of ZTE Corporation +Username:btadmin +Password: +ZXAN# +``` + +### Дві причини, і друга — справжня + +**Профіль не той.** Наявні профілі ZTE описують ZXR10 і ZXDSL: у них +запрошення закінчується на `>` або `]`, як у Comware. ZXAN показує +`ZXAN#`. Додано вбудований `zte-zxan` окремою міграцією — саме окремий, а +не «#» у наявні: у виводі Comware трапляються рядки з самої лише решітки +як роздільники, і зразок, що приймає `#` у кінці рядка, обірвав би конфіг +на першому ж такому. + +**Дедлайн сокета лишався від входу.** Ось це — справжня помилка, і вона +пояснює обидва симптоми одразу. + +Вхід через telnet — розмова: чекаємо «Username:», шлемо логін, чекаємо +«Password:». На час цього очікування ставився дедлайн читання у дві +секунди. Дедлайн сокета «липкий»: заданий один раз, він діє на **кожне** +наступне читання, доки його не скинути. + +Наслідок подвійний. Довгий конфіг обривався на середині мережевою +помилкою, хоча мережа працювала. А коли ще й запрошення не збігалось, +той самий дедлайн спрацьовував раніше за власний таймер очікування — і +замість зрозумілого «не дочекались запрошення командного рядка» +користувач бачив ту саму `i/o timeout`. + +Тепер дедлайн знімається одразу після входу, а паузи стереже CLI власним +таймером — там, де він знає, скільки чекати. + +### Результат + +``` +26 555 рядків, 810 607 байтів конфігу C320 +збір: success +``` + +### Заразом + +**«Не задано профіль» тепер каже, що робити.** Три різні причини мали +один текст. Розрізняються: виробник не заданий узагалі, під заданого +виробника немає готового профілю, обраний профіль без команд. + +**Новий хост більше не глухий кут.** Вкладка «Збір конфігів» для ще не +створеного хоста показувала лише пояснення, чому вона порожня. Тепер +після збереження картка відкривається знову — половина налаштувань +(розклад, доступи, перевірки) кріпиться до хоста й до його створення не +існує, і повертатись у той самий хост руками не треба. diff --git a/agent/internal/ncmx/collect.go b/agent/internal/ncmx/collect.go index 7d55f0e..04ddf53 100644 --- a/agent/internal/ncmx/collect.go +++ b/agent/internal/ncmx/collect.go @@ -47,19 +47,22 @@ func Collect(ctx context.Context, job *npv1.ConfigJob) (Result, error) { defer cancel() cred := job.GetCredential() - conn, err := Dial(ctx, transportName(job.GetTransport()), - job.GetDevice().GetAddress(), int(job.GetPort()), - cred.GetUsername(), password(cred), connectTimeout(timeout)) - if err != nil { - return Result{}, err - } - defer conn.Close() + // Буфер створюємо ДО підключення: розмова входу — частина сеансу, і + // саме на ній найчастіше все зупиняється. var transcript *bytes.Buffer if job.GetCaptureTranscript() { transcript = &bytes.Buffer{} } + conn, err := Dial(ctx, transportName(job.GetTransport()), + job.GetDevice().GetAddress(), int(job.GetPort()), + cred.GetUsername(), password(cred), connectTimeout(timeout), transcript) + if err != nil { + return withTranscript(Result{}, transcript), err + } + defer conn.Close() + cli := NewCLI(conn, Options{ PromptRe: promptRe, MaxBytes: int(job.GetMaxBytes()), diff --git a/agent/internal/ncmx/transport.go b/agent/internal/ncmx/transport.go index 7c8e4f9..deaa6e7 100644 --- a/agent/internal/ncmx/transport.go +++ b/agent/internal/ncmx/transport.go @@ -1,6 +1,7 @@ package ncmx import ( + "bytes" "context" "errors" "fmt" @@ -13,8 +14,14 @@ import ( ) // Dial відкриває інтерактивну сесію потрібним транспортом. +// Dial відкриває сеанс до пристрою. +// +// tr — буфер стенограми; може бути nil. Розмова входу пишеться туди +// само, що й решта: саме на ній найчастіше все й зупиняється, а без +// запису від пристрою лишається тільки «i/o timeout» без жодної підказки, +// чого він чекав. func Dial(ctx context.Context, transport, host string, port int, - user, password string, timeout time.Duration) (Conn, error) { + user, password string, timeout time.Duration, tr *bytes.Buffer) (Conn, error) { switch transport { case "ssh", "": @@ -26,7 +33,7 @@ func Dial(ctx context.Context, transport, host string, port int, if port == 0 { port = 23 } - return dialTelnet(ctx, host, port, user, password, timeout) + return dialTelnet(ctx, host, port, user, password, timeout, tr) default: return nil, fmt.Errorf("непідтримуваний транспорт %q", transport) } @@ -200,13 +207,18 @@ const ( ) 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) (Conn, error) { + user, password string, timeout time.Duration, tr *bytes.Buffer) (Conn, error) { addr := net.JoinHostPort(host, strconv.Itoa(port)) d := net.Dialer{Timeout: timeout} @@ -215,7 +227,7 @@ func dialTelnet(ctx context.Context, host string, port int, return nil, fmt.Errorf("зʼєднання з %s: %w", addr, err) } - tc := &telnetConn{conn: raw} + tc := &telnetConn{conn: raw, transcript: tr} // Вхід у Telnet — це не протокол, а розмова: пристрій просто пише // «Username:» і чекає. Кожен вендор пише по-своєму, тому шукаємо @@ -232,6 +244,28 @@ func dialTelnet(ctx context.Context, host string, port int, 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 } @@ -248,6 +282,9 @@ func (c *telnetConn) expectAndSend(ctx context.Context, want []string, send stri 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) { diff --git a/server/internal/store/ncm_jobs.go b/server/internal/store/ncm_jobs.go index 0f22f2b..9179fb9 100644 --- a/server/internal/store/ncm_jobs.go +++ b/server/internal/store/ncm_jobs.go @@ -13,6 +13,9 @@ import ( "google.golang.org/protobuf/types/known/durationpb" ) +// ErrNoProfile лишається для порівняння через errors.Is; текст +// уточнюється на місці — «немає профілю» має три різні причини, і +// кожна лікується по-своєму. var ErrNoProfile = errors.New("для хоста не задано профіль збору конфігу") // EnqueueConfigJob ставить збір конфігу в чергу. @@ -208,6 +211,17 @@ func jsonUnmarshalStrings(raw string, out *[]string) error { // Явно заданий при цьому завжди виграє. func (s *Store) resolveProfile(ctx context.Context, tenantID, deviceID string, explicit *string) (ncmProfile, error) { var p ncmProfile + // Виробник хоста — єдине, за чим профіль підбирається сам. Читаємо + // його заздалегідь, щоб відмова могла сказати, чого саме бракує: + // «виробник не заданий» і «під цього виробника немає профілю» — + // різні проблеми з різними діями. + var vendor string + _ = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + return tx.QueryRow(ctx, ` + SELECT COALESCE(vendor,'') FROM inv.devices WHERE id = $1 AND tenant_id = $2 + `, deviceID, tenantID).Scan(&vendor) + }) + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { if explicit != nil && *explicit != "" { return tx.QueryRow(ctx, ` @@ -230,7 +244,14 @@ func (s *Store) resolveProfile(ctx context.Context, tenantID, deviceID string, e `, deviceID).Scan(&p.rawCommands, &p.PromptRegex, &p.EnableRequired, &p.Transport) }) if errors.Is(err, pgx.ErrNoRows) { - return p, ErrNoProfile + switch { + case vendor == "": + return p, fmt.Errorf("%w: у хоста не заданий виробник, і профіль нема за чим "+ + "підібрати — оберіть його вручну у вкладці «Збір конфігів»", ErrNoProfile) + default: + return p, fmt.Errorf("%w: під виробника %q немає готового профілю — "+ + "оберіть інший у вкладці «Збір конфігів» або створіть свій", ErrNoProfile, vendor) + } } if err != nil { return p, err @@ -239,7 +260,7 @@ func (s *Store) resolveProfile(ctx context.Context, tenantID, deviceID string, e return p, fmt.Errorf("команди профілю: %w", err) } if len(p.Commands) == 0 { - return p, ErrNoProfile + return p, fmt.Errorf("%w: обраний профіль не містить жодної команди", ErrNoProfile) } return p, nil } diff --git a/server/migrations/0028_zte_zxan_profile.sql b/server/migrations/0028_zte_zxan_profile.sql new file mode 100644 index 0000000..4c1ddd0 --- /dev/null +++ b/server/migrations/0028_zte_zxan_profile.sql @@ -0,0 +1,24 @@ +-- ===================================================================== +-- NetPulse :: 0028_zte_zxan_profile.sql +-- Профіль збору для ZTE ZXAN (OLT серій C300/C320/C600). +-- +-- Наявні профілі ZTE описують ZXR10 і ZXDSL: у них запрошення +-- закінчується на «>» або «]», як у Comware. ZXAN після входу показує +-- «ZXAN#», і жоден із них не підходить — збір зупинявся, не дочекавшись +-- запрошення. +-- +-- Окремий профіль, а не «#» у наявні: у виводі Comware трапляються +-- рядки з самої лише решітки як роздільники, і зразок, що приймає «#» +-- у кінці рядка, обірвав би конфіг на першому ж такому. +-- +-- Перевірено на живому C320: 26 555 рядків конфігу. +-- ===================================================================== + +INSERT INTO ncm.profiles (tenant_id, key, name, vendor, transport, + commands, prompt_regex, enable_required, is_builtin) +VALUES ( + NULL, 'zte-zxan', 'ZTE ZXAN (OLT C300/C320/C600)', 'ZTE', 'telnet', + '["terminal length 0", "show running-config"]'::jsonb, + '[#>]\s*$', false, true +) +ON CONFLICT DO NOTHING; diff --git a/web/src/components/DeviceForm.tsx b/web/src/components/DeviceForm.tsx index 0ebc611..4c9604e 100644 --- a/web/src/components/DeviceForm.tsx +++ b/web/src/components/DeviceForm.tsx @@ -284,10 +284,16 @@ export function DeviceForm({ (device ? ( ) : ( -

- Розклад збору конфігів налаштовується після створення хоста: політиці потрібен сам - хост, до якого вона кріпиться. -

+
+

+ Розклад збору конфігів кріпиться до хоста, тож налаштовується після його + створення. +

+

+ Натисніть «Зберегти» — картка відкриється знову, і ця вкладка запрацює. Щоб профіль + підібрався сам, заповніть «Виробник» на вкладці «Хост». +

+
))} {tab === 'checks' && ( diff --git a/web/src/pages/DevicesPage.tsx b/web/src/pages/DevicesPage.tsx index 45405e8..b50dd28 100644 --- a/web/src/pages/DevicesPage.tsx +++ b/web/src/pages/DevicesPage.tsx @@ -74,6 +74,9 @@ export function DevicesPage() { setCheckTypes(ct) setCreds(cr) setError(null) + // Повертаємо список: той, хто щойно створив хост, має його + // знайти, а стан у React оновиться лише наступним рендером. + return d } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { @@ -276,9 +279,17 @@ export function DevicesPage() { credentials={creds} onClose={() => setCreating(false)} onSave={async (in_) => { - await api.createDevice(in_) + const made = await api.createDevice(in_) setCreating(false) - await reload() + const list = await reload() + // Одразу відкриваємо картку щойно створеного хоста. + // + // Половина налаштувань — розклад збору конфігів, доступи, + // перевірки — кріпиться до хоста й до його створення не + // існує. Без цього кроку людина зберігає форму й лишається + // на списку, маючи повернутись у той самий хост руками. + const fresh = (list ?? []).find((d) => d.id === made.id) + if (fresh) setDetail(fresh) }} /> )}