Дедлайн сокета від входу через 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
|
||||
хост прибрано
|
||||
```
|
||||
|
||||
## Живе обладнання: 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()
|
||||
|
||||
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()),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
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 ? (
|
||||
<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' && (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue