Дедлайн сокета від входу через telnet обривав збір конфігу
Some checks failed
CI / web (push) Has been cancelled
CI / server (push) Has been cancelled
CI / agent (push) Has been cancelled

Знайдено на живому 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:
byrsapty 2026-08-25 19:04:36 +03:00
parent e371327174
commit fe4ed4e7d9
7 changed files with 196 additions and 19 deletions

View file

@ -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
```
### Заразом
**«Не задано профіль» тепер каже, що робити.** Три різні причини мали
один текст. Розрізняються: виробник не заданий узагалі, під заданого
виробника немає готового профілю, обраний профіль без команд.
**Новий хост більше не глухий кут.** Вкладка «Збір конфігів» для ще не
створеного хоста показувала лише пояснення, чому вона порожня. Тепер
після збереження картка відкривається знову — половина налаштувань
(розклад, доступи, перевірки) кріпиться до хоста й до його створення не
існує, і повертатись у той самий хост руками не треба.

View file

@ -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()),

View file

@ -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) {

View file

@ -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
}

View 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;

View file

@ -284,10 +284,16 @@ export function DeviceForm({
(device ? (
<BackupPolicyForm device={device} credentials={credentials} inline />
) : (
<p className="py-6 text-center text-xs leading-relaxed text-slate-600">
Розклад збору конфігів налаштовується після створення хоста: політиці потрібен сам
хост, до якого вона кріпиться.
</p>
<div className="space-y-2 py-6 text-center text-xs leading-relaxed text-slate-500">
<p>
Розклад збору конфігів кріпиться до хоста, тож налаштовується після його
створення.
</p>
<p className="text-slate-600">
Натисніть «Зберегти» картка відкриється знову, і ця вкладка запрацює. Щоб профіль
підібрався сам, заповніть «Виробник» на вкладці «Хост».
</p>
</div>
))}
{tab === 'checks' && (

View file

@ -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)
}}
/>
)}