Netpulse_SasS/server/internal/store/ncm_profiles.go
byrsapty 444447d86b
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run
Профілі збору конфігів: перегляд, редагування, створення з прикладів
147 вбудованих профілів досі жили лише в базі — виправити команди під
свою прошивку було ніяк. Тепер є сторінка з пошуком і формою.

Вбудований не правиться, а перекривається власною копією з тим самим
ключем: вони спільні для всіх кабінетів, і правка під одну прошивку не
має міняти їх усім. resolveProfile уже віддавав перевагу тенантському,
тож нічого дописувати не довелося.

Профіль — це три регулярні вирази й перелік команд, і порожня форма з
такими полями не підказує нічого. Тому три робочі заготовки, кнопка
«За зразок» на кожному вбудованому й приклад у кожному полі.

Вирази компілюються при збереженні: інакше про друкарську помилку
дізнаються з бекапу, який завис, чекаючи неіснуючого запрошення.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:40:26 +03:00

222 lines
8.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package store
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
"github.com/jackc/pgx/v5"
)
// NcmProfile — як знімати конфіг з певної родини заліза.
//
// Профіль відповідає на чотири питання: чим під'єднатись, які команди
// віддати, як упізнати запрошення командного рядка й що з відповіді
// викинути перед порівнянням версій.
type NcmProfile struct {
ID string `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Vendor string `json:"vendor,omitempty"`
Transport string `json:"transport"`
Commands []string `json:"commands"`
PromptRegex string `json:"prompt_regex,omitempty"`
EnableRequired bool `json:"enable_required"`
// Рядки, що змінюються щоразу: uptime, час збору, лічильники. Без
// них кожен нічний бекап виглядав би зміною.
ScrubPatterns []string `json:"scrub_patterns"`
// Секрети, які не мають потрапити у сховище версій відкритим
// текстом: хеші паролів, community, ключі.
RedactPatterns []string `json:"redact_patterns"`
IsBuiltin bool `json:"is_builtin"`
// Скільки хостів користуються цим профілем — щоб було видно, що
// саме правиш, перш ніж правити.
DeviceCount int `json:"device_count"`
}
// ListNcmProfiles — усі доступні профілі з повним вмістом.
//
// Вбудовані й власні в одному списку: людина шукає «як знімати з
// MikroTik», а не «чий це профіль». Звідки він узявся, каже позначка.
func (s *Store) ListNcmProfiles(ctx context.Context, tenantID string) ([]NcmProfile, error) {
out := []NcmProfile{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT p.id::text, p.key, p.name, COALESCE(p.vendor,''), p.transport::text,
p.commands::text, COALESCE(p.prompt_regex,''), p.enable_required,
p.scrub_patterns::text, p.redact_patterns::text,
p.tenant_id IS NULL,
(SELECT count(*) FROM ncm.device_policies dp WHERE dp.profile_id = p.id)
FROM ncm.profiles p
WHERE p.tenant_id IS NULL OR p.tenant_id = $1
ORDER BY p.tenant_id NULLS LAST, p.name
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var (
p NcmProfile
commands, scrub, redact string
)
if err := rows.Scan(&p.ID, &p.Key, &p.Name, &p.Vendor, &p.Transport,
&commands, &p.PromptRegex, &p.EnableRequired,
&scrub, &redact, &p.IsBuiltin, &p.DeviceCount); err != nil {
return err
}
p.Commands = jsonStrings(commands)
p.ScrubPatterns = jsonStrings(scrub)
p.RedactPatterns = jsonStrings(redact)
out = append(out, p)
}
return rows.Err()
})
return out, err
}
// SaveNcmProfile створює або оновлює профіль.
//
// Спроба зберегти вбудований створює власну копію з тим самим ключем.
// Вбудовані спільні для всіх кабінетів, і правка «під свою прошивку» не
// має міняти їх усім; а копія з тим самим ключем перекриває оригінал
// у виборі профілю — саме цього й хоче той, хто правив.
func (s *Store) SaveNcmProfile(ctx context.Context, tenantID string, p NcmProfile) (string, error) {
if strings.TrimSpace(p.Name) == "" {
return "", fmt.Errorf("%w: профіль без назви", ErrInvalid)
}
if len(p.Commands) == 0 {
return "", fmt.Errorf("%w: профіль без команд нічого не збере", ErrInvalid)
}
// Регулярні вирази компілюємо тут: інакше про помилку людина
// дізнається з бекапу, який завис, чекаючи на запрошення, якого не
// існує.
if p.PromptRegex != "" {
if _, err := regexp.Compile(p.PromptRegex); err != nil {
return "", fmt.Errorf("%w: вираз запрошення не компілюється: %v", ErrInvalid, err)
}
}
for _, list := range [][]string{p.ScrubPatterns, p.RedactPatterns} {
for _, x := range list {
if _, err := regexp.Compile(x); err != nil {
return "", fmt.Errorf("%w: вираз %q не компілюється: %v", ErrInvalid, x, err)
}
}
}
commands, _ := json.Marshal(p.Commands)
scrub, _ := json.Marshal(orEmpty(p.ScrubPatterns))
redact, _ := json.Marshal(orEmpty(p.RedactPatterns))
var id string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
owned := p.ID != ""
if p.ID != "" {
var owner *string
if err := tx.QueryRow(ctx,
`SELECT tenant_id::text FROM ncm.profiles WHERE id = $1`, p.ID).Scan(&owner); err != nil {
if isNoRows(err) {
return ErrNotFound
}
return err
}
// Вбудований правимо не на місці, а копією.
owned = owner != nil
}
if !owned {
return tx.QueryRow(ctx, `
INSERT INTO ncm.profiles
(tenant_id, key, name, vendor, transport, commands,
prompt_regex, enable_required, scrub_patterns, redact_patterns,
is_builtin)
VALUES ($1,$2,$3,NULLIF($4,''),$5::inv.credential_proto,$6::jsonb,
NULLIF($7,''),$8,$9::jsonb,$10::jsonb,false)
ON CONFLICT (COALESCE(tenant_id,'00000000-0000-0000-0000-000000000000'::uuid), key)
DO UPDATE SET
name = EXCLUDED.name, vendor = EXCLUDED.vendor,
transport = EXCLUDED.transport, commands = EXCLUDED.commands,
prompt_regex = EXCLUDED.prompt_regex,
enable_required = EXCLUDED.enable_required,
scrub_patterns = EXCLUDED.scrub_patterns,
redact_patterns = EXCLUDED.redact_patterns
RETURNING id::text
`, tenantID, p.Key, p.Name, p.Vendor, p.Transport, string(commands),
p.PromptRegex, p.EnableRequired, string(scrub), string(redact)).Scan(&id)
}
id = p.ID
tag, err := tx.Exec(ctx, `
UPDATE ncm.profiles SET
key = $3, name = $4, vendor = NULLIF($5,''),
transport = $6::inv.credential_proto, commands = $7::jsonb,
prompt_regex = NULLIF($8,''), enable_required = $9,
scrub_patterns = $10::jsonb, redact_patterns = $11::jsonb
WHERE id = $1 AND tenant_id = $2
`, p.ID, tenantID, p.Key, p.Name, p.Vendor, p.Transport, string(commands),
p.PromptRegex, p.EnableRequired, string(scrub), string(redact))
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
return id, err
}
// DeleteNcmProfile прибирає власний профіль.
//
// Вбудований не видаляється: він спільний, і кабінет, який ним не
// користується, не має права забрати його в інших.
func (s *Store) DeleteNcmProfile(ctx context.Context, tenantID, id string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var owner *string
if err := tx.QueryRow(ctx,
`SELECT tenant_id::text FROM ncm.profiles WHERE id = $1`, id).Scan(&owner); err != nil {
if isNoRows(err) {
return ErrNotFound
}
return err
}
if owner == nil {
return ErrBuiltin
}
// Політики, які на нього посилались, лишаються без профілю:
// схема робить це сама (ON DELETE SET NULL), і це чесніше за
// мовчазну підміну на інший профіль.
tag, err := tx.Exec(ctx,
`DELETE FROM ncm.profiles WHERE id = $1 AND tenant_id = $2`, id, tenantID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
}
func jsonStrings(raw string) []string {
out := []string{}
if raw == "" || raw == "null" {
return out
}
_ = json.Unmarshal([]byte(raw), &out)
return out
}
func orEmpty(v []string) []string {
if v == nil {
return []string{}
}
return v
}