147 вбудованих профілів досі жили лише в базі — виправити команди під свою прошивку було ніяк. Тепер є сторінка з пошуком і формою. Вбудований не правиться, а перекривається власною копією з тим самим ключем: вони спільні для всіх кабінетів, і правка під одну прошивку не має міняти їх усім. resolveProfile уже віддавав перевагу тенантському, тож нічого дописувати не довелося. Профіль — це три регулярні вирази й перелік команд, і порожня форма з такими полями не підказує нічого. Тому три робочі заготовки, кнопка «За зразок» на кожному вбудованому й приклад у кожному полі. Вирази компілюються при збереженні: інакше про друкарську помилку дізнаються з бекапу, який завис, чекаючи неіснуючого запрошення. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
222 lines
8.2 KiB
Go
222 lines
8.2 KiB
Go
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
|
||
}
|