Netpulse_SasS/server/cmd/netpulse-secret/main.go
zotac ad7672c32f Етап 2: модуль topology — LLDP/CDP/ARP/FDB та інвентар портів
Спільний 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>
2026-08-14 14:42:01 +03:00

176 lines
5.8 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.

// Команда 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
}