Дедлайн сокета від входу через telnet обривав збір конфігу
Знайдено на живому ZTE C320. Вхід чекає «Username:» з дедлайном читання у дві секунди, а дедлайн сокета липкий: він лишався на всі наступні читання. Наслідок подвійний. Довгий конфіг обривався на середині помилкою «read tcp: i/o timeout» — мережевою там, де мережа ні до чого. А коли ще й запрошення не збігалось зі зразком, той самий дедлайн спрацьовував раніше за власний таймер, і замість «не дочекались запрошення» людина бачила ту саму мережеву помилку. Щоб це побачити, довелося спершу полагодити діагностику: стенограма починалась після входу, тобто після того місця, де все зупинялось. Тепер розмова входу пишеться теж — без пароля, лише те, що надіслав пристрій. Плюс вбудований профіль zte-zxan: наявні профілі ZTE чекають «>» або «]», а OLT показує «ZXAN#». Окремий профіль, бо в Comware трапляються рядки з самої решітки, і зразок із «#» обірвав би їхній конфіг. Результат на живому C320: 26 555 рядків. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
e371327174
commit
fe4ed4e7d9
7 changed files with 196 additions and 19 deletions
75
HISTORY.md
75
HISTORY.md
|
|
@ -3042,3 +3042,78 @@ icmp-basic сам створив 2 правила сповіщень
|
||||||
через 8 с http.status, http.latency_ms, http.ok, ssl.days_left
|
через 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Заразом
|
||||||
|
|
||||||
|
**«Не задано профіль» тепер каже, що робити.** Три різні причини мали
|
||||||
|
один текст. Розрізняються: виробник не заданий узагалі, під заданого
|
||||||
|
виробника немає готового профілю, обраний профіль без команд.
|
||||||
|
|
||||||
|
**Новий хост більше не глухий кут.** Вкладка «Збір конфігів» для ще не
|
||||||
|
створеного хоста показувала лише пояснення, чому вона порожня. Тепер
|
||||||
|
після збереження картка відкривається знову — половина налаштувань
|
||||||
|
(розклад, доступи, перевірки) кріпиться до хоста й до його створення не
|
||||||
|
існує, і повертатись у той самий хост руками не треба.
|
||||||
|
|
|
||||||
|
|
@ -47,19 +47,22 @@ func Collect(ctx context.Context, job *npv1.ConfigJob) (Result, error) {
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cred := job.GetCredential()
|
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
|
var transcript *bytes.Buffer
|
||||||
if job.GetCaptureTranscript() {
|
if job.GetCaptureTranscript() {
|
||||||
transcript = &bytes.Buffer{}
|
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{
|
cli := NewCLI(conn, Options{
|
||||||
PromptRe: promptRe,
|
PromptRe: promptRe,
|
||||||
MaxBytes: int(job.GetMaxBytes()),
|
MaxBytes: int(job.GetMaxBytes()),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package ncmx
|
package ncmx
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -13,8 +14,14 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// Dial відкриває інтерактивну сесію потрібним транспортом.
|
// Dial відкриває інтерактивну сесію потрібним транспортом.
|
||||||
|
// Dial відкриває сеанс до пристрою.
|
||||||
|
//
|
||||||
|
// tr — буфер стенограми; може бути nil. Розмова входу пишеться туди
|
||||||
|
// само, що й решта: саме на ній найчастіше все й зупиняється, а без
|
||||||
|
// запису від пристрою лишається тільки «i/o timeout» без жодної підказки,
|
||||||
|
// чого він чекав.
|
||||||
func Dial(ctx context.Context, transport, host string, port int,
|
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 {
|
switch transport {
|
||||||
case "ssh", "":
|
case "ssh", "":
|
||||||
|
|
@ -26,7 +33,7 @@ func Dial(ctx context.Context, transport, host string, port int,
|
||||||
if port == 0 {
|
if port == 0 {
|
||||||
port = 23
|
port = 23
|
||||||
}
|
}
|
||||||
return dialTelnet(ctx, host, port, user, password, timeout)
|
return dialTelnet(ctx, host, port, user, password, timeout, tr)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("непідтримуваний транспорт %q", transport)
|
return nil, fmt.Errorf("непідтримуваний транспорт %q", transport)
|
||||||
}
|
}
|
||||||
|
|
@ -200,13 +207,18 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
type telnetConn struct {
|
type telnetConn struct {
|
||||||
|
// Стенограма розмови входу. Пароль сюди не потрапляє: пишемо тільки
|
||||||
|
// те, що САМ пристрій надіслав, — інакше діагностика перетворилась
|
||||||
|
// би на витік облікових даних у таблицю завдань.
|
||||||
|
transcript *bytes.Buffer
|
||||||
|
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
// leftover — байти, що лишились після зняття команд протоколу.
|
// leftover — байти, що лишились після зняття команд протоколу.
|
||||||
leftover []byte
|
leftover []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func dialTelnet(ctx context.Context, host string, port int,
|
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))
|
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||||
d := net.Dialer{Timeout: timeout}
|
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)
|
return nil, fmt.Errorf("зʼєднання з %s: %w", addr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tc := &telnetConn{conn: raw}
|
tc := &telnetConn{conn: raw, transcript: tr}
|
||||||
|
|
||||||
// Вхід у Telnet — це не протокол, а розмова: пристрій просто пише
|
// Вхід у Telnet — це не протокол, а розмова: пристрій просто пише
|
||||||
// «Username:» і чекає. Кожен вендор пише по-своєму, тому шукаємо
|
// «Username:» і чекає. Кожен вендор пише по-своєму, тому шукаємо
|
||||||
|
|
@ -232,6 +244,28 @@ func dialTelnet(ctx context.Context, host string, port int,
|
||||||
return nil, fmt.Errorf("пароль: %w", err)
|
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
|
return tc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -248,6 +282,9 @@ func (c *telnetConn) expectAndSend(ctx context.Context, want []string, send stri
|
||||||
n, err := c.Read(buf)
|
n, err := c.Read(buf)
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
acc = append(acc, buf[:n]...)
|
acc = append(acc, buf[:n]...)
|
||||||
|
if c.transcript != nil {
|
||||||
|
c.transcript.Write(buf[:n])
|
||||||
|
}
|
||||||
low := lower(acc)
|
low := lower(acc)
|
||||||
for _, w := range want {
|
for _, w := range want {
|
||||||
if bytesContains(low, w) {
|
if bytesContains(low, w) {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ import (
|
||||||
"google.golang.org/protobuf/types/known/durationpb"
|
"google.golang.org/protobuf/types/known/durationpb"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrNoProfile лишається для порівняння через errors.Is; текст
|
||||||
|
// уточнюється на місці — «немає профілю» має три різні причини, і
|
||||||
|
// кожна лікується по-своєму.
|
||||||
var ErrNoProfile = errors.New("для хоста не задано профіль збору конфігу")
|
var ErrNoProfile = errors.New("для хоста не задано профіль збору конфігу")
|
||||||
|
|
||||||
// EnqueueConfigJob ставить збір конфігу в чергу.
|
// 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) {
|
func (s *Store) resolveProfile(ctx context.Context, tenantID, deviceID string, explicit *string) (ncmProfile, error) {
|
||||||
var p ncmProfile
|
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 {
|
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||||
if explicit != nil && *explicit != "" {
|
if explicit != nil && *explicit != "" {
|
||||||
return tx.QueryRow(ctx, `
|
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)
|
`, deviceID).Scan(&p.rawCommands, &p.PromptRegex, &p.EnableRequired, &p.Transport)
|
||||||
})
|
})
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
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 {
|
if err != nil {
|
||||||
return p, err
|
return p, err
|
||||||
|
|
@ -239,7 +260,7 @@ func (s *Store) resolveProfile(ctx context.Context, tenantID, deviceID string, e
|
||||||
return p, fmt.Errorf("команди профілю: %w", err)
|
return p, fmt.Errorf("команди профілю: %w", err)
|
||||||
}
|
}
|
||||||
if len(p.Commands) == 0 {
|
if len(p.Commands) == 0 {
|
||||||
return p, ErrNoProfile
|
return p, fmt.Errorf("%w: обраний профіль не містить жодної команди", ErrNoProfile)
|
||||||
}
|
}
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
server/migrations/0028_zte_zxan_profile.sql
Normal file
24
server/migrations/0028_zte_zxan_profile.sql
Normal file
|
|
@ -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;
|
||||||
|
|
@ -284,10 +284,16 @@ export function DeviceForm({
|
||||||
(device ? (
|
(device ? (
|
||||||
<BackupPolicyForm device={device} credentials={credentials} inline />
|
<BackupPolicyForm device={device} credentials={credentials} inline />
|
||||||
) : (
|
) : (
|
||||||
<p className="py-6 text-center text-xs leading-relaxed text-slate-600">
|
<div className="space-y-2 py-6 text-center text-xs leading-relaxed text-slate-500">
|
||||||
Розклад збору конфігів налаштовується після створення хоста: політиці потрібен сам
|
<p>
|
||||||
хост, до якого вона кріпиться.
|
Розклад збору конфігів кріпиться до хоста, тож налаштовується після його
|
||||||
</p>
|
створення.
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-600">
|
||||||
|
Натисніть «Зберегти» — картка відкриється знову, і ця вкладка запрацює. Щоб профіль
|
||||||
|
підібрався сам, заповніть «Виробник» на вкладці «Хост».
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{tab === 'checks' && (
|
{tab === 'checks' && (
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,9 @@ export function DevicesPage() {
|
||||||
setCheckTypes(ct)
|
setCheckTypes(ct)
|
||||||
setCreds(cr)
|
setCreds(cr)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
// Повертаємо список: той, хто щойно створив хост, має його
|
||||||
|
// знайти, а стан у React оновиться лише наступним рендером.
|
||||||
|
return d
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -276,9 +279,17 @@ export function DevicesPage() {
|
||||||
credentials={creds}
|
credentials={creds}
|
||||||
onClose={() => setCreating(false)}
|
onClose={() => setCreating(false)}
|
||||||
onSave={async (in_) => {
|
onSave={async (in_) => {
|
||||||
await api.createDevice(in_)
|
const made = await api.createDevice(in_)
|
||||||
setCreating(false)
|
setCreating(false)
|
||||||
await reload()
|
const list = await reload()
|
||||||
|
// Одразу відкриваємо картку щойно створеного хоста.
|
||||||
|
//
|
||||||
|
// Половина налаштувань — розклад збору конфігів, доступи,
|
||||||
|
// перевірки — кріпиться до хоста й до його створення не
|
||||||
|
// існує. Без цього кроку людина зберігає форму й лишається
|
||||||
|
// на списку, маючи повернутись у той самий хост руками.
|
||||||
|
const fresh = (list ?? []).find((d) => d.id === made.id)
|
||||||
|
if (fresh) setDetail(fresh)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue