Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.
ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ
0058 подієві алерти: syslog, ncm, compliance спрацьовують у мить
події; правило з нереалізованим джерелом більше не зберігається
мовчки
0059 snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
описуються шаблоном, а не Go
0060 відкат конфігу: план як різниця, маскування паролів із підписом
плану, обов'язковий контрольний збір, verifying при обриві
0061 кнопки Telegram: довге опитування, авторизація не з callback_data
0062 аудит і архів хостів; тест на AST, що падає на ключі без назви
0063 RLS: три ролі, окремий пул для фонових тактів
0064 строки зберігання даних і сторінка сховища
0065 приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
переклад v1→v2 за RFC 3584 дає правильний OID
0066 ескалації сповіщень
0067 алерт про вичерпання диска
0068 поля заливки конфігу переїхали в каталог профілів
Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.
ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ
netpulse установник: одна команда замість 18 змінних і
593 рядків інструкції
RLS з першого запуску нова інсталяція під політиками одразу;
RLS-EXISTING-INSTALL.md лишається тільки для
старих інсталяцій
.forgejo + CI раннер не зареєстрований
Ці три перевірені компіляцією й міркуванням, але не виконанням.
ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ
Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.
Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.
Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
500 lines
20 KiB
Go
500 lines
20 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
npv1 "github.com/netpulse/netpulse/gen/go/netpulse/v1"
|
||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||
"google.golang.org/protobuf/types/known/durationpb"
|
||
)
|
||
|
||
// ErrNoProfile лишається для порівняння через errors.Is; текст
|
||
// уточнюється на місці — «немає профілю» має три різні причини, і
|
||
// кожна лікується по-своєму.
|
||
var ErrNoProfile = errors.New("для хоста не задано профіль збору конфігу")
|
||
|
||
// EnqueueConfigJob ставить збір конфігу в чергу.
|
||
//
|
||
// Саме черга в БД, а не прямий виклик: HTTP-процес і AgentService —
|
||
// різні процеси, і живу сесію зонда тримає лише другий. Черга робить
|
||
// передачу між ними явною й переживає перезапуск обох.
|
||
func (s *Store) EnqueueConfigJob(ctx context.Context, tenantID, deviceID, trigger, userID string) (string, error) {
|
||
var id string
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
// Другий queued для того самого хоста нічого не додає: перший
|
||
// збере той самий конфіг. Тому натискання кнопки двічі не
|
||
// множить роботу.
|
||
err := tx.QueryRow(ctx, `
|
||
SELECT id::text FROM ncm.jobs
|
||
WHERE tenant_id = $1 AND device_id = $2 AND status IN ('queued','running')
|
||
LIMIT 1
|
||
`, tenantID, deviceID).Scan(&id)
|
||
if err == nil {
|
||
return nil
|
||
}
|
||
if !errors.Is(err, pgx.ErrNoRows) {
|
||
return err
|
||
}
|
||
|
||
return tx.QueryRow(ctx, `
|
||
INSERT INTO ncm.jobs (tenant_id, device_id, agent_id, trigger, requested_by)
|
||
SELECT $1, d.id, d.agent_id, $3::ncm.job_trigger, $4
|
||
FROM inv.devices d
|
||
WHERE d.id = $2 AND d.tenant_id = $1 AND d.deleted_at IS NULL
|
||
RETURNING id::text
|
||
`, tenantID, deviceID, trigger, nullUUID(userID)).Scan(&id)
|
||
})
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return "", ErrNotFound
|
||
}
|
||
return id, err
|
||
}
|
||
|
||
// PendingConfigJob — завдання, готове до відправки зонду.
|
||
type PendingConfigJob struct {
|
||
JobID string
|
||
TenantID string
|
||
AgentID string
|
||
Job *npv1.ConfigJob
|
||
}
|
||
|
||
// ClaimConfigJobs забирає завдання для зондів, які зараз на зв'язку.
|
||
//
|
||
// Забирає, а не читає: рядок одразу переходить у 'running'. Два
|
||
// екземпляри AgentService за балансувальником інакше надіслали б одне
|
||
// завдання двічі, і пристрій отримав би дві паралельні сесії.
|
||
func (s *Store) ClaimConfigJobs(ctx context.Context, onlineAgents []string, limit int, ring *crypto.Keyring) ([]PendingConfigJob, error) {
|
||
if len(onlineAgents) == 0 {
|
||
return nil, nil
|
||
}
|
||
if limit <= 0 {
|
||
limit = 16
|
||
}
|
||
|
||
rows, err := s.bg.Query(ctx, `
|
||
UPDATE ncm.jobs j
|
||
SET status = 'running', started_at = now()
|
||
WHERE j.id IN (
|
||
SELECT id FROM ncm.jobs
|
||
WHERE status = 'queued' AND agent_id = ANY($1::uuid[])
|
||
ORDER BY created_at
|
||
-- SKIP LOCKED: другий екземпляр не чекає на нас, а бере
|
||
-- наступні завдання. Без цього паралельні диспетчери
|
||
-- вишикувались би в чергу за одним рядком.
|
||
FOR UPDATE SKIP LOCKED
|
||
LIMIT $2
|
||
)
|
||
RETURNING j.id::text, j.tenant_id::text, j.agent_id::text, j.device_id::text
|
||
`, onlineAgents, limit)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
type claimed struct{ jobID, tenantID, agentID, deviceID string }
|
||
var list []claimed
|
||
for rows.Next() {
|
||
var c claimed
|
||
if err := rows.Scan(&c.jobID, &c.tenantID, &c.agentID, &c.deviceID); err != nil {
|
||
rows.Close()
|
||
return nil, err
|
||
}
|
||
list = append(list, c)
|
||
}
|
||
rows.Close()
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
out := make([]PendingConfigJob, 0, len(list))
|
||
for _, c := range list {
|
||
job, err := s.buildConfigJob(ctx, c.tenantID, c.deviceID, c.jobID, ring)
|
||
if err != nil {
|
||
// Завдання, яке неможливо зібрати, має впасти зараз і з
|
||
// поясненням, а не висіти в 'running' до перезапуску.
|
||
_ = s.FinishConfigJob(ctx, c.jobID, "failed", err.Error(), "")
|
||
continue
|
||
}
|
||
out = append(out, PendingConfigJob{
|
||
JobID: c.jobID, TenantID: c.tenantID, AgentID: c.agentID, Job: job,
|
||
})
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// buildConfigJob збирає завдання з профілю, хоста й доступу.
|
||
func (s *Store) buildConfigJob(ctx context.Context, tenantID, deviceID, jobID string, ring *crypto.Keyring) (*npv1.ConfigJob, error) {
|
||
// Хост, оголошений машиною власного зонда, іде окремою гілкою — і
|
||
// вона стоїть найпершою, до читання адреси. Ні адреси, ні доступу,
|
||
// ні профілю в такого хоста немає й бути не може: зонд читає власний
|
||
// диск, а не набирає когось по мережі. Перевірки нижче відмовили б
|
||
// йому саме за те, чого для нього не існує.
|
||
if agentID, self, err := s.SelfDeviceOf(ctx, tenantID, deviceID); err != nil {
|
||
return nil, err
|
||
} else if self {
|
||
return s.buildLocalFilesJob(ctx, tenantID, deviceID, jobID, agentID)
|
||
}
|
||
|
||
var (
|
||
devName, address string
|
||
profileID *string
|
||
credID *string
|
||
)
|
||
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
return tx.QueryRow(ctx, `
|
||
SELECT d.name, COALESCE(host(d.address),''),
|
||
p.profile_id::text, p.credential_id::text
|
||
FROM inv.devices d
|
||
LEFT JOIN ncm.device_policies p ON p.device_id = d.id
|
||
WHERE d.id = $1 AND d.tenant_id = $2 AND d.deleted_at IS NULL
|
||
`, deviceID, tenantID).Scan(&devName, &address, &profileID, &credID)
|
||
})
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if address == "" {
|
||
return nil, fmt.Errorf("у хоста %s немає адреси", devName)
|
||
}
|
||
|
||
prof, err := s.resolveProfile(ctx, tenantID, deviceID, profileID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
cred, err := s.resolveNcmCredential(ctx, tenantID, deviceID, credID, ring)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
transport, port := jobTransport(prof.Transport, cred)
|
||
|
||
return &npv1.ConfigJob{
|
||
JobId: jobID,
|
||
Device: &npv1.DeviceTarget{
|
||
DeviceId: deviceID,
|
||
Name: devName,
|
||
Address: address,
|
||
},
|
||
Credential: cred,
|
||
Transport: transport,
|
||
Port: port,
|
||
Commands: prof.Commands,
|
||
PromptRegex: prof.PromptRegex,
|
||
EnableRequired: prof.EnableRequired,
|
||
ConfigType: "running",
|
||
Timeout: durationpb.New(5 * time.Minute),
|
||
MaxBytes: 16 << 20,
|
||
// Транскрипт пишемо завжди: він потрібен рівно тоді, коли збір
|
||
// не вдався, а вдруге відтворити ту саму сесію не вийде.
|
||
CaptureTranscript: true,
|
||
}, nil
|
||
}
|
||
|
||
type ncmProfile struct {
|
||
// ID потрібен, щоб підібраний профіль можна було закріпити за
|
||
// хостом, а не переграваати підбір під час кожного завдання.
|
||
ID string
|
||
Commands []string
|
||
PromptRegex string
|
||
EnableRequired bool
|
||
Transport string
|
||
// rawCommands — jsonb із БД до розбору.
|
||
rawCommands string
|
||
}
|
||
|
||
// jsonUnmarshalStrings розбирає масив рядків із jsonb.
|
||
func jsonUnmarshalStrings(raw string, out *[]string) error {
|
||
if raw == "" {
|
||
return nil
|
||
}
|
||
return json.Unmarshal([]byte(raw), out)
|
||
}
|
||
|
||
// resolveProfile шукає профіль: спершу заданий явно, потім за вендором.
|
||
//
|
||
// Автопідбір за вендором — не здогад, а єдиний спосіб не змушувати
|
||
// адміністратора вручну призначати профіль кожному з тисячі хостів.
|
||
// Явно заданий при цьому завжди виграє.
|
||
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, `
|
||
SELECT id::text, commands::text, COALESCE(prompt_regex,''),
|
||
enable_required, transport::text
|
||
FROM ncm.profiles WHERE id = $1
|
||
`, *explicit).Scan(&p.ID, &p.rawCommands, &p.PromptRegex, &p.EnableRequired, &p.Transport)
|
||
}
|
||
|
||
// Спершу за sysObjectID: пристрій сам сказав, що він таке, і це
|
||
// точніше за поле «виробник», яке заповнює людина руками.
|
||
//
|
||
// Порядок переваги всередині: правило кабінету перед вбудованим,
|
||
// правило з уточненням за описом перед правилом лише за OID,
|
||
// довший префікс перед коротшим. Останнє важливе, бо виробник
|
||
// кодує в OID і серію: правило на конкретну серію має вигравати
|
||
// в загального правила виробника.
|
||
err := tx.QueryRow(ctx, `
|
||
SELECT pr.id::text, pr.commands::text, COALESCE(pr.prompt_regex,''),
|
||
pr.enable_required, pr.transport::text
|
||
FROM inv.devices d
|
||
JOIN inv.ident_rules aa
|
||
ON aa.enabled
|
||
AND (aa.tenant_id IS NULL OR aa.tenant_id = d.tenant_id)
|
||
AND (d.sys_object_id = aa.oid_prefix
|
||
OR d.sys_object_id LIKE aa.oid_prefix || '.%')
|
||
AND (aa.descr_regex IS NULL
|
||
OR COALESCE(d.sys_descr,'') ~ aa.descr_regex)
|
||
JOIN ncm.profiles pr ON pr.id = aa.profile_id
|
||
WHERE d.id = $1 AND d.sys_object_id IS NOT NULL
|
||
ORDER BY aa.tenant_id NULLS LAST,
|
||
(aa.descr_regex IS NULL),
|
||
length(aa.oid_prefix) DESC
|
||
LIMIT 1
|
||
`, deviceID).Scan(&p.ID, &p.rawCommands, &p.PromptRegex, &p.EnableRequired, &p.Transport)
|
||
if !errors.Is(err, pgx.ErrNoRows) {
|
||
return err
|
||
}
|
||
|
||
// Потім за виробником — як було. Ця гілка лишається для хостів
|
||
// без SNMP: у мережах повно заліза, яке віддає лише CLI.
|
||
return tx.QueryRow(ctx, `
|
||
SELECT pr.id::text, pr.commands::text, COALESCE(pr.prompt_regex,''),
|
||
pr.enable_required, pr.transport::text
|
||
FROM inv.devices d
|
||
JOIN ncm.profiles pr
|
||
ON lower(pr.vendor) = lower(d.vendor)
|
||
AND (pr.tenant_id IS NULL OR pr.tenant_id = d.tenant_id)
|
||
WHERE d.id = $1 AND d.vendor IS NOT NULL
|
||
-- Профіль тенанта важить більше за вбудований: клієнт міг
|
||
-- підправити команди під свою прошивку.
|
||
ORDER BY pr.tenant_id NULLS LAST, pr.key
|
||
LIMIT 1
|
||
`, deviceID).Scan(&p.ID, &p.rawCommands, &p.PromptRegex, &p.EnableRequired, &p.Transport)
|
||
})
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
switch {
|
||
case vendor == "":
|
||
return p, fmt.Errorf("%w: хост не впізнано за SNMP і виробник не заданий — "+
|
||
"або дайте хосту SNMP-доступ — він розпізнається сам, "+
|
||
"або оберіть профіль вручну у вкладці «Збір конфігів»", ErrNoProfile)
|
||
default:
|
||
return p, fmt.Errorf("%w: під виробника %q немає готового профілю — "+
|
||
"оберіть інший у вкладці «Збір конфігів» або створіть свій", ErrNoProfile, vendor)
|
||
}
|
||
}
|
||
if err != nil {
|
||
return p, err
|
||
}
|
||
if err := jsonUnmarshalStrings(p.rawCommands, &p.Commands); err != nil {
|
||
return p, fmt.Errorf("команди профілю: %w", err)
|
||
}
|
||
if len(p.Commands) == 0 {
|
||
return p, fmt.Errorf("%w: обраний профіль не містить жодної команди", ErrNoProfile)
|
||
}
|
||
return p, nil
|
||
}
|
||
|
||
// resolveNcmCredential бере доступ для CLI й розшифровує його.
|
||
func (s *Store) resolveNcmCredential(ctx context.Context, tenantID, deviceID string,
|
||
explicit *string, ring *crypto.Keyring) (*npv1.Credential, error) {
|
||
|
||
var (
|
||
id, proto, username string
|
||
port int
|
||
keyID, aad *string
|
||
nonce, ct, tag []byte
|
||
)
|
||
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
q := `
|
||
SELECT c.id::text, c.proto::text, COALESCE(c.username,''), COALESCE(c.port,0),
|
||
s.key_id, s.nonce, s.ciphertext, s.auth_tag, COALESCE(s.aad,'')
|
||
FROM inv.credentials c
|
||
LEFT JOIN core.secrets s ON s.id = c.secret_id
|
||
WHERE c.tenant_id = $1 AND c.id = $2
|
||
`
|
||
if explicit != nil && *explicit != "" {
|
||
return tx.QueryRow(ctx, q, tenantID, *explicit).
|
||
Scan(&id, &proto, &username, &port, &keyID, &nonce, &ct, &tag, &aad)
|
||
}
|
||
// Не задано явно — беремо прив'язаний до хоста доступ, придатний
|
||
// для CLI. SNMP-community сюди не годиться.
|
||
return tx.QueryRow(ctx, `
|
||
SELECT c.id::text, c.proto::text, COALESCE(c.username,''), COALESCE(c.port,0),
|
||
s.key_id, s.nonce, s.ciphertext, s.auth_tag, COALESCE(s.aad,'')
|
||
FROM inv.device_credentials dc
|
||
JOIN inv.credentials c ON c.id = dc.credential_id
|
||
LEFT JOIN core.secrets s ON s.id = c.secret_id
|
||
WHERE dc.device_id = $1 AND c.tenant_id = $2
|
||
AND c.proto IN ('ssh','telnet')
|
||
ORDER BY dc.priority
|
||
LIMIT 1
|
||
`, deviceID, tenantID).
|
||
Scan(&id, &proto, &username, &port, &keyID, &nonce, &ct, &tag, &aad)
|
||
})
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, fmt.Errorf("для хоста не прив'язано доступу ssh або telnet")
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
cred := &npv1.Credential{
|
||
CredentialId: id,
|
||
Username: username,
|
||
Port: uint32(port),
|
||
}
|
||
if proto == "telnet" {
|
||
cred.Transport = npv1.Transport_TRANSPORT_TELNET
|
||
} else {
|
||
cred.Transport = npv1.Transport_TRANSPORT_SSH
|
||
}
|
||
|
||
if keyID != nil && ring != nil {
|
||
plain, err := ring.Decrypt(&crypto.Secret{
|
||
KeyID: *keyID, Nonce: nonce, Ciphertext: ct, AuthTag: tag,
|
||
}, derefStr(aad))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("розшифровка доступу: %w", err)
|
||
}
|
||
cred.Secret = &npv1.Credential_Password{Password: string(plain)}
|
||
}
|
||
return cred, nil
|
||
}
|
||
|
||
// FinishConfigJob закриває завдання.
|
||
func (s *Store) FinishConfigJob(ctx context.Context, jobID, status, errMsg, transcript string) error {
|
||
_, err := s.bg.Exec(ctx, `
|
||
UPDATE ncm.jobs
|
||
SET status = $2::ncm.job_status,
|
||
finished_at = now(),
|
||
duration_ms = GREATEST(0, EXTRACT(EPOCH FROM (now() - COALESCE(started_at, now())))::int * 1000),
|
||
error = NULLIF($3,''),
|
||
log = NULLIF($4,'')
|
||
WHERE id = $1
|
||
`, jobID, status, errMsg, transcript)
|
||
return err
|
||
}
|
||
|
||
// ReapStuckJobs повертає в чергу завдання, що зависли в 'running'.
|
||
//
|
||
// Зонд міг зникнути разом із завданням: без цього хост залишився б без
|
||
// бекапів назавжди, а причина була б видна лише в таблиці.
|
||
func (s *Store) ReapStuckJobs(ctx context.Context, olderThan time.Duration) (int64, error) {
|
||
tag, err := s.bg.Exec(ctx, `
|
||
UPDATE ncm.jobs
|
||
SET status = 'failed', finished_at = now(),
|
||
error = COALESCE(error, 'зонд не відповів у відведений час')
|
||
WHERE status = 'running' AND started_at < now() - $1::interval
|
||
`, olderThan.String())
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return tag.RowsAffected(), nil
|
||
}
|
||
|
||
// ConfigJobRow — рядок історії збору для UI.
|
||
type ConfigJobRow struct {
|
||
ID string `json:"id"`
|
||
Status string `json:"status"`
|
||
Trigger string `json:"trigger"`
|
||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||
DurationMs int `json:"duration_ms"`
|
||
Error string `json:"error,omitempty"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
}
|
||
|
||
func (s *Store) ListConfigJobs(ctx context.Context, tenantID, deviceID string, limit int) ([]ConfigJobRow, error) {
|
||
if limit <= 0 || limit > 200 {
|
||
limit = 20
|
||
}
|
||
var out []ConfigJobRow
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT id::text, status::text, trigger::text,
|
||
started_at, finished_at, COALESCE(duration_ms,0),
|
||
COALESCE(error,''), created_at
|
||
FROM ncm.jobs
|
||
WHERE tenant_id = $1 AND device_id = $2
|
||
ORDER BY created_at DESC
|
||
LIMIT $3
|
||
`, tenantID, deviceID, limit)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var j ConfigJobRow
|
||
if err := rows.Scan(&j.ID, &j.Status, &j.Trigger, &j.StartedAt,
|
||
&j.FinishedAt, &j.DurationMs, &j.Error, &j.CreatedAt); err != nil {
|
||
return err
|
||
}
|
||
out = append(out, j)
|
||
}
|
||
return rows.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
|
||
}
|
||
|
||
// ResolveProfile — публічна обгортка підбору профілю.
|
||
//
|
||
// Потрібна розпізнаванню: воно закріплює підібраний профіль за хостом,
|
||
// і робити це другим, схожим, але окремим запитом означало б завести
|
||
// два підбори, які розійдуться при першій же правці одного з них.
|
||
func (s *Store) ResolveProfile(ctx context.Context, tenantID, deviceID string, explicit *string) (ncmProfile, error) {
|
||
return s.resolveProfile(ctx, tenantID, deviceID, explicit)
|
||
}
|