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 ? (
- Розклад збору конфігів налаштовується після створення хоста: політиці потрібен сам - хост, до якого вона кріпиться. -
++ Розклад збору конфігів кріпиться до хоста, тож налаштовується після його + створення. +
++ Натисніть «Зберегти» — картка відкриється знову, і ця вкладка запрацює. Щоб профіль + підібрався сам, заповніть «Виробник» на вкладці «Хост». +
+