Netpulse_SasS/server/internal/store/credentials.go
byrsapty c28852df66
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run
Syslog: транспорт до сервера і тригер позачергового бекапу
Приймач на зонді лежав без діла — тепер під'єднаний. Окремий стрім
StreamLogs, а не контрольний канал: сплеск логів під час аварії не має
заважати heartbeat і командам.

Хост зіставляється за адресою джерела на зонді: у сервера немає
контексту мережі клієнта, а один приватний діапазон трапляється в
десятках кабінетів. Невідома адреса не привід викинути подію.

Подія, що збіглася зі зразком у ncm.device_policies.syslog_match,
ставить позачерговий збір конфігу. Типовий зразок покриває Cisco,
HP/Huawei, Juniper і MikroTik — навмисно широкий: зайвий бекап коштує
секунд, пропущений — цілої зміни.

Заразом увесь репозиторій прогнано через gofmt: CI, написаний два
кроки тому, перевіряє це і впав би на 29 файлах із порушеннями,
накопиченими за весь проєкт.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:20:32 +03:00

205 lines
6.3 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"
"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/timestamppb"
)
// CredentialTTL — скільки живе комплект на зонді.
//
// Короткий TTL — це не паранойя, а керованість: після відкликання
// доступу зонд перестане ним користуватись за годину без жодних
// додаткових дій, навіть якщо зв'язок із ним втрачено.
const CredentialTTL = time.Hour
// snmpV3Payload — домовленість про формат відкритого тексту секрету
// kind='snmp_v3': два паролі в одному секреті, бо вони завжди
// використовуються разом і ротуються разом.
type snmpV3Payload struct {
AuthPassword string `json:"auth_password"`
PrivPassword string `json:"priv_password"`
}
type credOptions struct {
SecLevel string `json:"sec_level"`
AuthProto string `json:"auth_proto"`
PrivProto string `json:"priv_proto"`
Context string `json:"context"`
SecurityName string `json:"security_name"`
}
// BuildCredentialBundle збирає розшифровані креденшели для всіх
// пристроїв цього зонда.
//
// Розшифровка відбувається саме тут, а не на агенті: DEK не покидає
// сервер. Далі вони їдуть виключно всередині mTLS-каналу й живуть у
// пам'яті агента до expires_at.
func (s *Store) BuildCredentialBundle(ctx context.Context, a *Agent, ring *crypto.Keyring) (*npv1.CredentialBundle, error) {
bundle := &npv1.CredentialBundle{
ByDevice: make(map[string]*npv1.CredentialList),
ExpiresAt: timestamppb.New(time.Now().Add(CredentialTTL)),
}
err := s.InTenantTx(ctx, a.TenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT dc.device_id::text,
c.id::text,
c.proto::text,
COALESCE(c.username, ''),
COALESCE(c.port, 0),
COALESCE(c.options::text, '{}'),
COALESCE(s.kind::text, ''),
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
JOIN inv.devices d ON d.id = dc.device_id
LEFT JOIN core.secrets s ON s.id = c.secret_id
WHERE c.tenant_id = $1
AND d.agent_id = $2
AND d.enabled
AND d.deleted_at IS NULL
ORDER BY dc.device_id, dc.priority
`, a.TenantID, a.ID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var (
deviceID, credID, proto, username, optionsJSON string
port int32
kind, keyID, aad string
nonce, ciphertext, authTag []byte
)
var (
keyIDPtr *string
aadPtr *string
)
if err := rows.Scan(&deviceID, &credID, &proto, &username, &port,
&optionsJSON, &kind, &keyIDPtr, &nonce, &ciphertext, &authTag, &aadPtr); err != nil {
return err
}
if keyIDPtr != nil {
keyID = *keyIDPtr
}
if aadPtr != nil {
aad = *aadPtr
}
cred := &npv1.Credential{
CredentialId: credID,
Transport: transportFromProto(proto),
Username: username,
Port: uint32(port),
ExpiresAt: bundle.ExpiresAt,
}
var plaintext []byte
if len(ciphertext) > 0 {
pt, decErr := ring.Decrypt(&crypto.Secret{
KeyID: keyID, Nonce: nonce, Ciphertext: ciphertext, AuthTag: authTag,
}, aad)
if decErr != nil {
// Один нечитабельний секрет не має рушити весь
// комплект: решта пристроїв мусить опитуватись.
continue
}
plaintext = pt
}
var opts credOptions
_ = json.Unmarshal([]byte(optionsJSON), &opts)
if err := fillSecret(cred, kind, proto, plaintext, opts); err != nil {
continue
}
list := bundle.ByDevice[deviceID]
if list == nil {
list = &npv1.CredentialList{}
bundle.ByDevice[deviceID] = list
}
list.Credentials = append(list.Credentials, cred)
}
return rows.Err()
})
return bundle, err
}
func fillSecret(cred *npv1.Credential, kind, proto string, plaintext []byte, opts credOptions) error {
switch {
case proto == "snmp_v2c":
cred.Secret = &npv1.Credential_Community{Community: string(plaintext)}
case proto == "snmp_v3":
var p snmpV3Payload
if len(plaintext) > 0 {
if err := json.Unmarshal(plaintext, &p); err != nil {
return err
}
}
cred.SnmpV3 = &npv1.SnmpV3Options{
Level: secLevel(opts.SecLevel),
AuthProtocol: opts.AuthProto,
AuthPassword: p.AuthPassword,
PrivProtocol: opts.PrivProto,
PrivPassword: p.PrivPassword,
ContextName: opts.Context,
SecurityName: opts.SecurityName,
}
case kind == "ssh_key":
cred.Secret = &npv1.Credential_PrivateKey{PrivateKey: plaintext}
case proto == "http" || proto == "https" || proto == "api":
cred.Secret = &npv1.Credential_Token{Token: string(plaintext)}
default:
cred.Secret = &npv1.Credential_Password{Password: string(plaintext)}
}
return nil
}
func transportFromProto(p string) npv1.Transport {
switch p {
case "ssh":
return npv1.Transport_TRANSPORT_SSH
case "telnet":
return npv1.Transport_TRANSPORT_TELNET
case "snmp_v2c":
return npv1.Transport_TRANSPORT_SNMP_V2C
case "snmp_v3":
return npv1.Transport_TRANSPORT_SNMP_V3
case "http":
return npv1.Transport_TRANSPORT_HTTP
case "https":
return npv1.Transport_TRANSPORT_HTTPS
case "api":
return npv1.Transport_TRANSPORT_API
case "modbus":
return npv1.Transport_TRANSPORT_MODBUS
default:
return npv1.Transport_TRANSPORT_UNSPECIFIED
}
}
func secLevel(s string) npv1.SnmpV3Options_SecurityLevel {
switch s {
case "authPriv":
return npv1.SnmpV3Options_SECURITY_LEVEL_AUTH_PRIV
case "authNoPriv":
return npv1.SnmpV3Options_SECURITY_LEVEL_AUTH_NO_PRIV
case "noAuthNoPriv":
return npv1.SnmpV3Options_SECURITY_LEVEL_NO_AUTH_NO_PRIV
default:
return npv1.SnmpV3Options_SECURITY_LEVEL_UNSPECIFIED
}
}