Транспорт збору конфігу бере доступ хоста, а не профіль моделі
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run

Питання «чому всі профілі ssh» виявило справжню помилку. Список
правильний: ssh — розумний типовий вибір. Але це поле ВИРІШУВАЛО
транспорт, а профіль описує модель, не конкретну коробку.

Хост зі старою прошивкою, де є лише telnet, і з чесно заведеним
telnet-доступом усе одно набирався по SSH — і не збирався ніколи.
inv.credentials.proto вже ніс потрібну відповідь і доїжджав до зонда
невикористаним.

Тепер транспорт бере доступ; профільне поле лишилось підказкою й діє,
поки доступу немає. Логіка винесена в jobTransport() — щоб її можна
було перевірити без бази й без пристрою.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-25 16:45:48 +03:00
parent 444447d86b
commit 452345da32
4 changed files with 146 additions and 11 deletions

View file

@ -2832,3 +2832,46 @@ MikroTik RouterOS, Linux), кнопка «За зразок» на кожном
пошук за назвою/виробником/командою, «лише власні», пошук за назвою/виробником/командою, «лише власні»,
«Cisco IOS» зі зразка заповнив усі шість полів «Cisco IOS» зі зразка заповнив усі шість полів
``` ```
## Транспорт вирішує доступ, а не профіль
### Питання, яке виявило помилку
«Чому всі готові профілі — ssh? Хіба не має бути telnet?»
Сам список правильний: `ssh` у всіх 147 вбудованих — це не недогляд, а
розумний типовий вибір. Помилка була в тому, **що саме це поле робило**.
`buildConfigJob` брав транспорт із профілю моделі. А оскільки профіль
описує модель, а не конкретну коробку, виходило так: хост зі старою
прошивкою, де є лише telnet, і з чесно заведеним telnet-доступом усе
одно набирався по SSH — бо профіль моделі каже «ssh». І не збирався
ніколи.
Доступ при цьому вже ніс потрібне поле: `inv.credentials.proto` — саме
воно й відповідає на питання «чим до цієї коробки достукатись». Воно
доїжджало до зонда в `Credential.Transport` і не використовувалось для
набору.
### Як тепер
Транспорт бере доступ. Профільне поле лишилось підказкою «чим це залізо
зазвичай беруть» і діє, тільки поки доступу немає. Порт іде за
транспортом (22/23), а порт із доступу перекриває типовий — залізо за
NAT цілком може слухати SSH на 2222.
Логіка винесена в `jobTransport()` окремою функцією саме щоб її можна
було перевірити без бази й без пристрою: чотири випадки, чотири рядки
таблиці.
У формі профілю поле перейменоване на «Типовий транспорт» із підказкою,
що реальний задає доступ хоста. Назва колонки — «Типово».
### Перевірено
```
доступ telnet + профіль ssh → telnet, порт 23
доступ ssh + профіль telnet → ssh, порт 22
порт 2222 у доступі → 2222
без доступу → лишається підказка профілю
```

View file

@ -160,15 +160,7 @@ func (s *Store) buildConfigJob(ctx context.Context, tenantID, deviceID, jobID st
return nil, err return nil, err
} }
transport := npv1.Transport_TRANSPORT_SSH transport, port := jobTransport(prof.Transport, cred)
port := uint32(22)
if prof.Transport == "telnet" {
transport = npv1.Transport_TRANSPORT_TELNET
port = 23
}
if cred != nil && cred.Port != 0 {
port = cred.Port
}
return &npv1.ConfigJob{ return &npv1.ConfigJob{
JobId: jobID, JobId: jobID,
@ -394,3 +386,36 @@ func (s *Store) ListConfigJobs(ctx context.Context, tenantID, deviceID string, l
}) })
return out, err return out, err
} }
// jobTransport каже, чим набирати цей хост і на який порт.
//
// Транспорт вирішує ДОСТУП, а не профіль. Профіль описує модель заліза:
// які команди віддати й як упізнати запрошення. А чим до конкретної
// коробки достукатись — властивість самої коробки: та сама модель у
// клієнта може стояти з увімкненим SSH, а на сусідньому вузлі — зі
// старою прошивкою, де є лише telnet.
//
// Раніше вирішував профіль, і оскільки всі 147 вбудованих кажуть «ssh»,
// хост із telnet-доступом набирався по SSH і не збирався ніколи.
// Профільне поле лишилось підказкою «чим це залізо зазвичай беруть» і
// діє, тільки поки доступу немає.
func jobTransport(profileTransport string, cred *npv1.Credential) (npv1.Transport, uint32) {
transport := npv1.Transport_TRANSPORT_SSH
switch {
case cred != nil:
transport = cred.Transport
case profileTransport == "telnet":
transport = npv1.Transport_TRANSPORT_TELNET
}
port := uint32(22)
if transport == npv1.Transport_TRANSPORT_TELNET {
port = 23
}
// Порт із доступу перекриває типовий: залізо за NAT цілком може
// слухати SSH на 2222.
if cred != nil && cred.Port != 0 {
port = cred.Port
}
return transport, port
}

View file

@ -0,0 +1,64 @@
package store
import (
"testing"
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
)
// Транспорт задає доступ хоста, а не профіль моделі.
//
// Усі 147 вбудованих профілів кажуть «ssh», бо так знімають з
// переважної більшості заліза. Але та сама модель у клієнта може стояти
// зі старою прошивкою, де є лише telnet, — і поки вирішував профіль,
// такий хост набирався по SSH і не збирався ніколи.
func TestTransportComesFromCredential(t *testing.T) {
cases := []struct {
name string
profile string
cred *npv1.Credential
want npv1.Transport
port uint32
}{
{
name: "доступ telnet перемагає профіль ssh",
profile: "ssh",
cred: &npv1.Credential{Transport: npv1.Transport_TRANSPORT_TELNET},
want: npv1.Transport_TRANSPORT_TELNET,
port: 23,
},
{
name: "доступ ssh перемагає профіль telnet",
profile: "telnet",
cred: &npv1.Credential{Transport: npv1.Transport_TRANSPORT_SSH},
want: npv1.Transport_TRANSPORT_SSH,
port: 22,
},
{
name: "порт з доступу перекриває типовий",
profile: "ssh",
cred: &npv1.Credential{Transport: npv1.Transport_TRANSPORT_SSH, Port: 2222},
want: npv1.Transport_TRANSPORT_SSH,
port: 2222,
},
{
name: "без доступу лишається підказка профілю",
profile: "telnet",
cred: nil,
want: npv1.Transport_TRANSPORT_TELNET,
port: 23,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, port := jobTransport(c.profile, c.cred)
if got != c.want {
t.Fatalf("транспорт: %v, очікував %v", got, c.want)
}
if port != c.port {
t.Fatalf("порт: %d, очікував %d", port, c.port)
}
})
}
}

View file

@ -208,7 +208,7 @@ export function ProfilesPage() {
}, },
{ {
key: 'transport', key: 'transport',
header: 'Транспорт', header: 'Типово',
cell: (p) => <span className="text-slate-400">{p.transport}</span>, cell: (p) => <span className="text-slate-400">{p.transport}</span>,
}, },
{ {
@ -413,7 +413,10 @@ function ProfileForm({
onChange={(e) => setVendor(e.target.value)} onChange={(e) => setVendor(e.target.value)}
/> />
</Field> </Field>
<Field label="Транспорт"> <Field
label="Типовий транспорт"
hint="Підказка, чим це залізо зазвичай беруть. Реальний транспорт задає доступ, прив'язаний до хоста"
>
<select <select
className={inputClass} className={inputClass}
value={transport} value={transport}