Спільний SNMP-транспорт винесено в snmpx: ним користуються два модулі. Звіти автовиявлення йдуть окремим RPC, не телеметричним стрімом — вони рідкі, великі й не прив'язані до моменту часу так, як метрики. Живий прогін проти справжнього snmpd+lldpd знайшов дві помилки: 1. Префікс типу чека не збігався з ключем модуля (topo.discover при плагіні topology). Агент маршрутизує задачі саме за префіксом, тож зонд відхиляв би їх. Інваріант закріплено обмеженням у БД. 2. Унікальний індекс topo.neighbors схлопував ARP-сусідів: ключ не включав MAC, а chassis_id/port_id в ARP немає взагалі. Перевірено на живих даних: інвентар із ifTable, 2 ARP-сусіди, зіставлення шлюзу за MAC (впевненість 90), зведений лінк із capacity 10 Гбіт/с. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
176 lines
5.8 KiB
Go
176 lines
5.8 KiB
Go
// Команда netpulse-secret — заведення зашифрованих секретів у core.secrets.
|
||
//
|
||
// Потрібна, бо секрети не можна вставити звичайним SQL: у БД лягає лише
|
||
// шифротекст, а ключ живе поза нею. Доки немає UI, це єдиний коректний
|
||
// спосіб завести пароль SSH чи SNMP-community — і він же лишається
|
||
// робочим інструментом для скриптів масового заведення.
|
||
//
|
||
// netpulse-secret -dsn ... -dek k1=<hex> -tenant <uuid> -kind snmp_v3 \
|
||
// -value public -credential snmp-ro -proto snmp_v2c \
|
||
// -attach-device <uuid>
|
||
//
|
||
// Значення читається з -value або зі stdin: у скриптах воно не має
|
||
// потрапляти в історію команд і в список процесів.
|
||
package main
|
||
|
||
import (
|
||
"bufio"
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"errors"
|
||
"flag"
|
||
"fmt"
|
||
"os"
|
||
"strings"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||
)
|
||
|
||
func main() {
|
||
if err := run(); err != nil {
|
||
fmt.Fprintln(os.Stderr, "netpulse-secret:", err)
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
|
||
func run() error {
|
||
var (
|
||
dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL")
|
||
dek = flag.String("dek", os.Getenv("NETPULSE_DEK"), "ключ: id=<hex|base64>")
|
||
tenant = flag.String("tenant", "", "uuid тенанта")
|
||
kind = flag.String("kind", "generic", "core.secret_kind: ssh_password|ssh_key|snmp_v3|api_token|...")
|
||
value = flag.String("value", "", "значення секрету; порожнє — читати зі stdin")
|
||
credName = flag.String("credential", "", "створити inv.credentials з цим іменем")
|
||
proto = flag.String("proto", "", "inv.credential_proto для -credential")
|
||
username = flag.String("username", "", "логін для -credential")
|
||
port = flag.Int("port", 0, "порт для -credential")
|
||
options = flag.String("options", "{}", "JSON-опції креденшела (SNMPv3 тощо)")
|
||
attach = flag.String("attach-device", "", "uuid пристрою, до якого прив'язати креденшел")
|
||
)
|
||
flag.Parse()
|
||
|
||
if *dsn == "" || *tenant == "" {
|
||
return errors.New("потрібні -dsn і -tenant")
|
||
}
|
||
|
||
ring, err := buildKeyring(*dek)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
plaintext := *value
|
||
if plaintext == "" {
|
||
data, err := readStdin()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
plaintext = data
|
||
}
|
||
if plaintext == "" {
|
||
return errors.New("порожнє значення секрету")
|
||
}
|
||
|
||
ctx := context.Background()
|
||
conn, err := pgx.Connect(ctx, *dsn)
|
||
if err != nil {
|
||
return fmt.Errorf("підключення: %w", err)
|
||
}
|
||
defer conn.Close(ctx)
|
||
|
||
// AAD прив'язує шифротекст до тенанта й типу: переставити рядок
|
||
// на іншого тенанта прямим UPDATE не вийде — розшифрування впаде.
|
||
aad := *tenant + "|" + *kind
|
||
sec, err := ring.Encrypt([]byte(plaintext), aad)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
tx, err := conn.Begin(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer func() { _ = tx.Rollback(ctx) }()
|
||
|
||
var secretID string
|
||
if err := tx.QueryRow(ctx, `
|
||
INSERT INTO core.secrets (tenant_id, kind, key_id, nonce, ciphertext, auth_tag, aad)
|
||
VALUES ($1, $2::core.secret_kind, $3, $4, $5, $6, $7)
|
||
RETURNING id::text
|
||
`, *tenant, *kind, sec.KeyID, sec.Nonce, sec.Ciphertext, sec.AuthTag, aad).Scan(&secretID); err != nil {
|
||
return fmt.Errorf("запис секрету: %w", err)
|
||
}
|
||
fmt.Println("secret_id:", secretID)
|
||
|
||
if *credName != "" {
|
||
if *proto == "" {
|
||
return errors.New("для -credential потрібен -proto")
|
||
}
|
||
var credID string
|
||
if err := tx.QueryRow(ctx, `
|
||
INSERT INTO inv.credentials (tenant_id, name, proto, username, port, secret_id, options)
|
||
VALUES ($1, $2, $3::inv.credential_proto, NULLIF($4,''), NULLIF($5,0)::int, $6, $7::jsonb)
|
||
ON CONFLICT (tenant_id, name) DO UPDATE
|
||
SET secret_id = EXCLUDED.secret_id,
|
||
username = EXCLUDED.username,
|
||
port = EXCLUDED.port,
|
||
options = EXCLUDED.options,
|
||
updated_at = now()
|
||
RETURNING id::text
|
||
`, *tenant, *credName, *proto, *username, *port, secretID, *options).Scan(&credID); err != nil {
|
||
return fmt.Errorf("запис креденшела: %w", err)
|
||
}
|
||
fmt.Println("credential_id:", credID)
|
||
|
||
if *attach != "" {
|
||
if _, err := tx.Exec(ctx, `
|
||
INSERT INTO inv.device_credentials (device_id, credential_id, priority)
|
||
VALUES ($1, $2, 10)
|
||
ON CONFLICT (device_id, credential_id) DO NOTHING
|
||
`, *attach, credID); err != nil {
|
||
return fmt.Errorf("прив'язка до пристрою: %w", err)
|
||
}
|
||
fmt.Println("прив'язано до пристрою:", *attach)
|
||
}
|
||
}
|
||
|
||
return tx.Commit(ctx)
|
||
}
|
||
|
||
func readStdin() (string, error) {
|
||
r := bufio.NewReader(os.Stdin)
|
||
line, err := r.ReadString('\n')
|
||
if err != nil && line == "" {
|
||
return "", err
|
||
}
|
||
return strings.TrimRight(line, "\r\n"), nil
|
||
}
|
||
|
||
func buildKeyring(spec string) (*crypto.Keyring, error) {
|
||
ring := crypto.NewKeyring()
|
||
if strings.TrimSpace(spec) == "" {
|
||
return nil, errors.New("не вказано -dek")
|
||
}
|
||
for _, part := range strings.Split(spec, ",") {
|
||
part = strings.TrimSpace(part)
|
||
if part == "" {
|
||
continue
|
||
}
|
||
id, raw, ok := strings.Cut(part, "=")
|
||
if !ok {
|
||
return nil, fmt.Errorf("некоректний запис ключа: %q", part)
|
||
}
|
||
key, err := hex.DecodeString(raw)
|
||
if err != nil {
|
||
key, err = base64.StdEncoding.DecodeString(raw)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("ключ %q: не hex і не base64", id)
|
||
}
|
||
}
|
||
if err := ring.Add(id, key); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
return ring, nil
|
||
}
|