Приймач на зонді лежав без діла — тепер під'єднаний. Окремий стрім StreamLogs, а не контрольний канал: сплеск логів під час аварії не має заважати heartbeat і командам. Хост зіставляється за адресою джерела на зонді: у сервера немає контексту мережі клієнта, а один приватний діапазон трапляється в десятках кабінетів. Невідома адреса не привід викинути подію. Подія, що збіглася зі зразком у ncm.device_policies.syslog_match, ставить позачерговий збір конфігу. Типовий зразок покриває Cisco, HP/Huawei, Juniper і MikroTik — навмисно широкий: зайвий бекап коштує секунд, пропущений — цілої зміни. Заразом увесь репозиторій прогнано через gofmt: CI, написаний два кроки тому, перевіряє це і впав би на 29 файлах із порушеннями, накопиченими за весь проєкт. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
205 lines
6.3 KiB
Go
205 lines
6.3 KiB
Go
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
|
||
}
|
||
}
|