diff --git a/HISTORY.md b/HISTORY.md
index bc7f754..78369a8 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -2832,3 +2832,46 @@ MikroTik RouterOS, Linux), кнопка «За зразок» на кожном
пошук за назвою/виробником/командою, «лише власні»,
«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
+без доступу → лишається підказка профілю
+```
diff --git a/server/internal/store/ncm_jobs.go b/server/internal/store/ncm_jobs.go
index 65acb4d..0f22f2b 100644
--- a/server/internal/store/ncm_jobs.go
+++ b/server/internal/store/ncm_jobs.go
@@ -160,15 +160,7 @@ func (s *Store) buildConfigJob(ctx context.Context, tenantID, deviceID, jobID st
return nil, err
}
- transport := npv1.Transport_TRANSPORT_SSH
- port := uint32(22)
- if prof.Transport == "telnet" {
- transport = npv1.Transport_TRANSPORT_TELNET
- port = 23
- }
- if cred != nil && cred.Port != 0 {
- port = cred.Port
- }
+ transport, port := jobTransport(prof.Transport, cred)
return &npv1.ConfigJob{
JobId: jobID,
@@ -394,3 +386,36 @@ func (s *Store) ListConfigJobs(ctx context.Context, tenantID, deviceID string, l
})
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
+}
diff --git a/server/internal/store/ncm_transport_test.go b/server/internal/store/ncm_transport_test.go
new file mode 100644
index 0000000..4b8d4b3
--- /dev/null
+++ b/server/internal/store/ncm_transport_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/web/src/pages/ProfilesPage.tsx b/web/src/pages/ProfilesPage.tsx
index 658150f..79b38ca 100644
--- a/web/src/pages/ProfilesPage.tsx
+++ b/web/src/pages/ProfilesPage.tsx
@@ -208,7 +208,7 @@ export function ProfilesPage() {
},
{
key: 'transport',
- header: 'Транспорт',
+ header: 'Типово',
cell: (p) => {p.transport},
},
{
@@ -413,7 +413,10 @@ function ProfileForm({
onChange={(e) => setVendor(e.target.value)}
/>
-
+