mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-01 13:21:29 +02:00
Compare commits
22 Commits
feat-post_
...
revert/com
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d6117d2c0 | ||
|
|
c2f8360b00 | ||
|
|
ea1b4d56e8 | ||
|
|
acbe22b831 | ||
|
|
3b5c8e2298 | ||
|
|
2baeb4bc0d | ||
|
|
4bf75fdc97 | ||
|
|
799f3a3c62 | ||
|
|
129736ad61 | ||
|
|
f7be9c4347 | ||
|
|
e05cb5264d | ||
|
|
5a10561ca1 | ||
|
|
42ce83a8f3 | ||
|
|
e620c86cd4 | ||
|
|
9dee2d60b9 | ||
|
|
4525014632 | ||
|
|
23a5c0de4b | ||
|
|
25e882004f | ||
|
|
15003258d2 | ||
|
|
2af3a5fba5 | ||
|
|
a6603a2e0a | ||
|
|
7ed3737cda |
9
.github/workflows/agent-network-e2e.yml
vendored
9
.github/workflows/agent-network-e2e.yml
vendored
@@ -5,13 +5,6 @@ on:
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bedrock_model:
|
||||
description: >-
|
||||
Bedrock inference-profile id to drive the matrix with, exactly as
|
||||
AWS issues it. Leave empty for the Sonnet 4.6 default.
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -69,8 +62,6 @@ jobs:
|
||||
CLOUDFLARE_TOKEN: ${{ secrets.E2E_CLOUDFLARE_TOKEN }}
|
||||
AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.E2E_AWS_BEARER_TOKEN_BEDROCK }}
|
||||
AWS_REGION: ${{ secrets.E2E_AWS_REGION }}
|
||||
# Bedrock model override: dispatch input wins, then the repo variable, else the test default.
|
||||
AWS_BEDROCK_MODEL: ${{ inputs.bedrock_model || vars.E2E_AWS_BEDROCK_MODEL }}
|
||||
# Vertex (Anthropic-on-Vertex): SA + project required; region defaults
|
||||
# to "global", model to a pinned claude snapshot.
|
||||
GOOGLE_VERTEX_SA_BASE64: ${{ secrets.E2E_GOOGLE_VERTEX_SA_BASE64 }}
|
||||
|
||||
@@ -273,8 +273,8 @@ dockers_v2:
|
||||
- netbirdio/netbird
|
||||
- ghcr.io/netbirdio/netbird
|
||||
tags:
|
||||
- "{{ .Version }}-rootless"
|
||||
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}rootless-latest{{ end }}"
|
||||
- "v{{ .Version }}-rootless"
|
||||
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
|
||||
dockerfile: client/Dockerfile-rootless
|
||||
extra_files:
|
||||
- client/netbird-entrypoint.sh
|
||||
|
||||
@@ -464,8 +464,6 @@ func Test_RemovePeer(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_ConnectPeers(t *testing.T) {
|
||||
t.Setenv("NB_DISABLE_EBPF_WG_PROXY", "true")
|
||||
|
||||
peer1ifaceName := fmt.Sprintf("utun%d", WgIntNumber+400)
|
||||
peer1wgIP := netip.MustParsePrefix("10.99.99.17/30")
|
||||
peer1Key, _ := wgtypes.GeneratePrivateKey()
|
||||
@@ -507,8 +505,12 @@ func Test_ConnectPeers(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
localIP1 := "127.0.0.1"
|
||||
peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP1, peer1wgPort))
|
||||
localIP, err := getLocalIP()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer1wgPort))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -544,8 +546,7 @@ func Test_ConnectPeers(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
localIP2 := "127.0.0.1"
|
||||
peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP2, peer2wgPort))
|
||||
peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer2wgPort))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -620,3 +621,28 @@ func getPeer(ifaceName, peerPubKey string) (wgtypes.Peer, error) {
|
||||
}
|
||||
return wgtypes.Peer{}, fmt.Errorf("peer not found")
|
||||
}
|
||||
|
||||
func getLocalIP() (string, error) {
|
||||
// Get all interfaces
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
ipNet, ok := addr.(*net.IPNet)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if ipNet.IP.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
|
||||
if ipNet.IP.To4() == nil {
|
||||
continue
|
||||
}
|
||||
return ipNet.IP.String(), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no local IP found")
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ import (
|
||||
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
|
||||
"github.com/netbirdio/netbird/client/internal/peerstore"
|
||||
"github.com/netbirdio/netbird/client/internal/portforward"
|
||||
"github.com/netbirdio/netbird/client/internal/pqkem"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/relay"
|
||||
"github.com/netbirdio/netbird/client/internal/rosenpass"
|
||||
@@ -198,10 +197,6 @@ type Engine struct {
|
||||
// rpManager is a Rosenpass manager
|
||||
rpManager *rosenpass.Manager
|
||||
|
||||
// pqkemManager runs the ML-KEM post-quantum PSK exchange (gated by NB_ENABLE_PQ_MLKEM).
|
||||
// It owns the data-path transport and peer endpoint routing.
|
||||
pqkemManager *pqkem.Manager
|
||||
|
||||
// syncMsgMux is used to guarantee sequential Management Service message processing
|
||||
syncMsgMux *sync.Mutex
|
||||
|
||||
@@ -656,30 +651,6 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
||||
e.rpManager.SetInterface(e.wgInterface)
|
||||
}
|
||||
|
||||
// Start the ML-KEM PQ manager after the interface is up so its dedicated UDP
|
||||
// transport can bind on the WG overlay IP.
|
||||
if pqkem.Enabled() {
|
||||
tr, pqErr := newPQTransport(e.config.WgAddr.IP)
|
||||
if pqErr != nil {
|
||||
log.Errorf("pqkem: transport bind failed, exchange disabled: %v", pqErr)
|
||||
} else {
|
||||
cbHandler := pqCallbackHandler{
|
||||
wg: e.wgInterface,
|
||||
// On a persistent rekey failure, re-bootstrap the KEM over Signal: a
|
||||
// fresh signalling offer starts a new exchange that overwrites the
|
||||
// stalled PSK on both sides, recovering from a data-path desync.
|
||||
reoffer: func(remoteKey string) {
|
||||
if conn, ok := e.peerStore.PeerConn(remoteKey); ok {
|
||||
conn.RequestReoffer()
|
||||
}
|
||||
},
|
||||
}
|
||||
e.pqkemManager = pqkem.NewManager(pqkem.LocalID(publicKey.String()), cbHandler, pqkem.NewLogger())
|
||||
e.pqkemManager.Start(tr)
|
||||
log.Infof("pqkem: enabled (udp port %d on overlay %s)", e.pqkemManager.LocalPort(), e.config.WgAddr.IP)
|
||||
}
|
||||
}
|
||||
|
||||
// if inbound conns are blocked there is no need to create the ACL manager
|
||||
if e.firewall != nil && !e.config.BlockInbound {
|
||||
e.acl = acl.NewDefaultManager(e.firewall)
|
||||
@@ -943,10 +914,6 @@ func (e *Engine) removePeer(peerKey string) error {
|
||||
|
||||
e.connMgr.RemovePeerConn(peerKey)
|
||||
|
||||
if e.pqkemManager != nil {
|
||||
e.pqkemManager.RemovePeer(pqkem.RemoteID(peerKey))
|
||||
}
|
||||
|
||||
err := e.statusRecorder.RemovePeer(peerKey)
|
||||
if err != nil {
|
||||
log.Warnf("received error when removing peer %s from status recorder: %v", peerKey, err)
|
||||
@@ -1933,10 +1900,6 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
|
||||
},
|
||||
ICEConfig: e.createICEConfig(),
|
||||
}
|
||||
if e.pqkemManager != nil {
|
||||
config.PQ = pqHandshaker{mgr: e.pqkemManager}
|
||||
config.PQStrict = pqkem.Strict()
|
||||
}
|
||||
|
||||
serviceDependencies := peer.ServiceDependencies{
|
||||
StatusRecorder: e.statusRecorder,
|
||||
@@ -2120,10 +2083,6 @@ func (e *Engine) close() {
|
||||
_ = e.rpManager.Close()
|
||||
}
|
||||
|
||||
if e.pqkemManager != nil {
|
||||
e.pqkemManager.Stop()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := e.portForwardManager.GracefullyStop(ctx); err != nil {
|
||||
@@ -2936,8 +2895,6 @@ func convertToOfferAnswer(msg *sProto.Message) (*peer.OfferAnswer, error) {
|
||||
Version: msg.GetBody().GetNetBirdVersion(),
|
||||
RosenpassPubKey: rosenpassPubKey,
|
||||
RosenpassAddr: rosenpassAddr,
|
||||
MlkemPayload: msg.GetBody().GetMlkemPayload(),
|
||||
MlkemPort: int(msg.GetBody().GetMlkemPort()),
|
||||
RelaySrvAddress: msg.GetBody().GetRelayServerAddress(),
|
||||
RelaySrvIP: relayIP,
|
||||
SessionID: sessionID,
|
||||
|
||||
@@ -3,7 +3,6 @@ package peer
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"net/netip"
|
||||
"runtime"
|
||||
@@ -27,7 +26,6 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/portforward"
|
||||
"github.com/netbirdio/netbird/client/internal/rosenpass"
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
"github.com/netbirdio/netbird/monotime"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
||||
)
|
||||
@@ -76,34 +74,6 @@ type RosenpassConfig struct {
|
||||
PermissiveMode bool
|
||||
}
|
||||
|
||||
// PQHandshaker attaches post-quantum ML-KEM material to signalling offers/answers and
|
||||
// feeds received material back. It is implemented by the engine over the pqkem
|
||||
// manager and is nil when the PQ exchange is disabled. remoteKey is the peer's
|
||||
// WireGuard public key.
|
||||
type PQHandshaker interface {
|
||||
// OfferPayload returns the KEM offer to embed in an outgoing offer (nil if this
|
||||
// peer is not the KEM initiator) and the local PQ data-path port to announce.
|
||||
OfferPayload(remoteKey string) (payload []byte, port int)
|
||||
// AnswerPayload processes a received KEM offer (nil if absent) and returns the KEM
|
||||
// answer to embed in the outgoing answer (nil if none) and the local PQ port.
|
||||
AnswerPayload(remoteKey string, recvOffer []byte) (payload []byte, port int)
|
||||
// OnAnswer feeds a received KEM answer (nil if absent).
|
||||
OnAnswer(remoteKey string, recvAnswer []byte)
|
||||
// PSK returns the peer's latest derived post-quantum PSK to program at WG
|
||||
// peer-config time (the pull path). ok is false until one has been derived.
|
||||
PSK(remoteKey string) (wgtypes.Key, bool)
|
||||
// SetRemoteAddr registers the peer's data-path endpoint learned from signalling:
|
||||
// its WG overlay IP with the advertised pq UDP port.
|
||||
SetRemoteAddr(remoteKey string, addr netip.AddrPort)
|
||||
// OnDataPathRekeyed signals a fresh WireGuard handshake for the peer; it clocks the
|
||||
// next chained PSK rotation pushed over the data path. sinceActivity is how long
|
||||
// ago the peer last exchanged real user data, so the rotation can be skipped for
|
||||
// idle tunnels.
|
||||
OnDataPathRekeyed(remoteKey string, sinceActivity time.Duration)
|
||||
// OnDataPathDown signals the peer's tunnel went down.
|
||||
OnDataPathDown(remoteKey string)
|
||||
}
|
||||
|
||||
// ConnConfig is a peer Connection configuration
|
||||
type ConnConfig struct {
|
||||
// Key is a public key of a remote peer
|
||||
@@ -121,12 +91,6 @@ type ConnConfig struct {
|
||||
|
||||
RosenpassConfig RosenpassConfig
|
||||
|
||||
// PQ carries post-quantum ML-KEM material on offers/answers; nil when disabled.
|
||||
PQ PQHandshaker
|
||||
// PQStrict fails closed: block peer traffic until the ML-KEM PSK is established,
|
||||
// instead of letting the tunnel come up classically and upgrading to PQ later.
|
||||
PQStrict bool
|
||||
|
||||
// ICEConfig ICE protocol configuration
|
||||
ICEConfig icemaker.Config
|
||||
}
|
||||
@@ -185,11 +149,6 @@ type Conn struct {
|
||||
// pendingFirstPacket is the lazyconn-captured handshake init, replayed once the real
|
||||
// transport is up.
|
||||
pendingFirstPacket []byte
|
||||
|
||||
// pqBlockingKey is a per-conn random sentinel PSK used in PQ strict mode to fail
|
||||
// closed: it is programmed until the real ML-KEM PSK is derived, so no session can
|
||||
// form on a non-PQ key. Per-conn random so two strict peers never match by chance.
|
||||
pqBlockingKey *wgtypes.Key
|
||||
}
|
||||
|
||||
// injectPendingFirstPacket replays the captured handshake through the proxy if present, else
|
||||
@@ -247,14 +206,6 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) {
|
||||
metricsRecorder: services.MetricsRecorder,
|
||||
}
|
||||
|
||||
if config.PQ != nil && config.PQStrict {
|
||||
if k, err := wgtypes.GenerateKey(); err != nil {
|
||||
connLog.Errorf("pqkem: failed to generate strict-mode sentinel key, strict fail-closed disabled for this peer: %v", err)
|
||||
} else {
|
||||
conn.pqBlockingKey = &k
|
||||
}
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
@@ -719,22 +670,6 @@ func (conn *Conn) onGuardEvent() {
|
||||
}
|
||||
}
|
||||
|
||||
// RequestReoffer sends a fresh signalling offer for the peer, re-running the
|
||||
// post-quantum bootstrap over Signal. Used to recover from a persistent data-path
|
||||
// rekey failure: a new exchange overwrites the stalled PSK on both sides. No-op if the
|
||||
// connection is not open yet.
|
||||
func (conn *Conn) RequestReoffer() {
|
||||
conn.mu.Lock()
|
||||
h := conn.handshaker
|
||||
conn.mu.Unlock()
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
if err := h.SendOffer(); err != nil {
|
||||
conn.Log.Debugf("pqkem: recovery re-offer failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (conn *Conn) onWGDisconnected(watcherCtx context.Context) {
|
||||
conn.mu.Lock()
|
||||
defer conn.mu.Unlock()
|
||||
@@ -746,10 +681,6 @@ func (conn *Conn) onWGDisconnected(watcherCtx context.Context) {
|
||||
|
||||
conn.Log.Warnf("WireGuard handshake timeout detected, closing current connection")
|
||||
|
||||
if conn.config.PQ != nil {
|
||||
conn.config.PQ.OnDataPathDown(conn.config.Key)
|
||||
}
|
||||
|
||||
// Close the active connection based on current priority
|
||||
switch conn.currentConnPriority {
|
||||
case conntype.Relay:
|
||||
@@ -792,7 +723,7 @@ func (conn *Conn) updateRelayStatus(relayServerAddr string, rosenpassPubKey []by
|
||||
ConnStatus: conn.evalStatus(),
|
||||
Relayed: conn.isRelayed(),
|
||||
RelayServerAddress: relayServerAddr,
|
||||
RosenpassEnabled: conn.quantumResistant(rosenpassPubKey),
|
||||
RosenpassEnabled: isRosenpassEnabled(rosenpassPubKey),
|
||||
}
|
||||
|
||||
err := conn.statusRecorder.UpdatePeerRelayedState(peerState)
|
||||
@@ -811,7 +742,7 @@ func (conn *Conn) updateIceState(iceConnInfo ICEConnInfo, updateTime time.Time)
|
||||
RemoteIceCandidateType: iceConnInfo.RemoteIceCandidateType,
|
||||
LocalIceCandidateEndpoint: iceConnInfo.LocalIceCandidateEndpoint,
|
||||
RemoteIceCandidateEndpoint: iceConnInfo.RemoteIceCandidateEndpoint,
|
||||
RosenpassEnabled: conn.quantumResistant(iceConnInfo.RosenpassPubKey),
|
||||
RosenpassEnabled: isRosenpassEnabled(iceConnInfo.RosenpassPubKey),
|
||||
}
|
||||
|
||||
err := conn.statusRecorder.UpdatePeerICEState(peerState)
|
||||
@@ -1015,35 +946,6 @@ func (conn *Conn) onWGCheckSuccess() {
|
||||
conn.mu.Lock()
|
||||
conn.wgTimeouts = 0
|
||||
conn.mu.Unlock()
|
||||
|
||||
// A fresh WireGuard handshake clocks the post-quantum PSK rotation. Pass how long
|
||||
// ago the peer last exchanged real user data (keepalives excluded) so the pqkem
|
||||
// manager can skip rotation on idle tunnels — rotating then would push data-path
|
||||
// traffic that keeps the lazy connection artificially active.
|
||||
if conn.config.PQ != nil {
|
||||
conn.config.PQ.OnDataPathRekeyed(conn.config.Key, conn.dataActivityAge())
|
||||
}
|
||||
}
|
||||
|
||||
// dataActivityAge returns how long ago the peer last exchanged real user data
|
||||
// (WireGuard keepalives excluded), per the same LastActivities signal the
|
||||
// lazy-connection inactivity monitor uses. It reports a very large duration when no
|
||||
// activity has ever been recorded, so the peer is treated as idle.
|
||||
//
|
||||
// In kernel mode there is no per-peer data-activity signal (LastActivities is
|
||||
// userspace-only), so we cannot tell active from idle. We report zero — always
|
||||
// "active" — so PSK rotation is not disabled in kernel mode. Lazy back-to-idle is
|
||||
// already limited there; the eBPF WG-activity detection (future) will supply a real
|
||||
// signal that excludes handshake/pqkem traffic.
|
||||
func (conn *Conn) dataActivityAge() time.Duration {
|
||||
if !conn.config.WgConfig.WgInterface.IsUserspaceBind() {
|
||||
return 0
|
||||
}
|
||||
last, ok := conn.config.WgConfig.WgInterface.LastActivities()[conn.config.WgConfig.RemoteKey]
|
||||
if !ok {
|
||||
return time.Duration(math.MaxInt64)
|
||||
}
|
||||
return monotime.Since(last)
|
||||
}
|
||||
|
||||
// recordConnectionMetrics records connection stage timestamps as metrics
|
||||
@@ -1085,23 +987,6 @@ func (conn *Conn) AgentVersionString() string {
|
||||
}
|
||||
|
||||
func (conn *Conn) presharedKey(remoteRosenpassKey []byte) *wgtypes.Key {
|
||||
// Post-quantum: once the ML-KEM exchange has derived a PSK for this peer, program
|
||||
// it here so the peer's next WireGuard handshake adopts it. Applied at peer-config
|
||||
// time (bootstrap / reconnect); steady-state rotation is pushed separately.
|
||||
if conn.config.PQ != nil {
|
||||
if psk, ok := conn.config.PQ.PSK(conn.config.Key); ok {
|
||||
return &psk
|
||||
}
|
||||
if conn.config.PQStrict && conn.pqBlockingKey != nil {
|
||||
// Fail closed: program a non-matching sentinel so no session forms on a
|
||||
// non-PQ key until the ML-KEM exchange derives the real PSK (pushed via
|
||||
// SetPresharedKey once it converges). "pending" — turns into a "stuck"
|
||||
// warning from the manager if the exchange keeps failing (see raiseFailure).
|
||||
conn.Log.Debugf("pqkem: strict mode — no PQ PSK yet, blocking peer traffic until the ML-KEM exchange converges")
|
||||
return conn.pqBlockingKey
|
||||
}
|
||||
}
|
||||
|
||||
if conn.config.RosenpassConfig.PubKey == nil {
|
||||
return conn.config.WgConfig.PreSharedKey
|
||||
}
|
||||
@@ -1141,21 +1026,6 @@ func isRosenpassEnabled(remoteRosenpassPubKey []byte) bool {
|
||||
return remoteRosenpassPubKey != nil
|
||||
}
|
||||
|
||||
// quantumResistant reports whether the peer's tunnel is post-quantum protected, for
|
||||
// the status "Quantum resistance" field: either Rosenpass (the remote advertised a
|
||||
// Rosenpass key) or the ML-KEM exchange (a PQ PSK has been derived for this peer).
|
||||
func (conn *Conn) quantumResistant(remoteRosenpassPubKey []byte) bool {
|
||||
if isRosenpassEnabled(remoteRosenpassPubKey) {
|
||||
return true
|
||||
}
|
||||
if conn.config.PQ != nil {
|
||||
if _, ok := conn.config.PQ.PSK(conn.config.Key); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func evalConnStatus(in connStatusInputs) guard.ConnStatus {
|
||||
// "Relay up and needed" — the peer uses relay and the transport is connected.
|
||||
relayUsedAndUp := in.peerUsesRelay && in.relayConnected
|
||||
|
||||
@@ -39,16 +39,6 @@ type OfferAnswer struct {
|
||||
// This value is the local Rosenpass server address when sending the message
|
||||
RosenpassAddr string
|
||||
|
||||
// MlkemPayload carries the post-quantum X25519MLKEM768 handshake message
|
||||
// (pqkem-framed offer on an OFFER, answer on an ANSWER) that seeds the
|
||||
// WireGuard PSK. Opaque here — the pqkem library frames and parses it. Nil
|
||||
// when the peer does not run the ML-KEM PQ exchange.
|
||||
MlkemPayload []byte
|
||||
|
||||
// MlkemPort is the peer's ML-KEM PQ service UDP port (bound on its WG overlay
|
||||
// IP) where data-path rekey messages are sent. Zero when not running the exchange.
|
||||
MlkemPort int
|
||||
|
||||
// relay server address
|
||||
RelaySrvAddress string
|
||||
// RelaySrvIP is the IP the remote peer is connected to on its
|
||||
@@ -91,20 +81,14 @@ type Handshaker struct {
|
||||
|
||||
func NewHandshaker(log *log.Entry, config ConnConfig, signaler *Signaler, ice *WorkerICE, relay *WorkerRelay, metricsStages *MetricsStages) *Handshaker {
|
||||
h := &Handshaker{
|
||||
log: log,
|
||||
config: config,
|
||||
signaler: signaler,
|
||||
ice: ice,
|
||||
relay: relay,
|
||||
metricsStages: metricsStages,
|
||||
// Buffered by 1: the single Listen goroutine can be busy handling an offer
|
||||
// (sendAnswer does a blocking signal send) exactly when the matching answer
|
||||
// arrives on the other channel. Unbuffered, that answer would hit the
|
||||
// non-blocking send's default and be dropped — fatal for the post-quantum
|
||||
// exchange, which needs the answer to converge. A 1-slot cushion lets it wait
|
||||
// until Listen loops back, without ever blocking the signal receiver.
|
||||
remoteOffersCh: make(chan OfferAnswer, 1),
|
||||
remoteAnswerCh: make(chan OfferAnswer, 1),
|
||||
log: log,
|
||||
config: config,
|
||||
signaler: signaler,
|
||||
ice: ice,
|
||||
relay: relay,
|
||||
metricsStages: metricsStages,
|
||||
remoteOffersCh: make(chan OfferAnswer),
|
||||
remoteAnswerCh: make(chan OfferAnswer),
|
||||
}
|
||||
// assume remote supports ICE until we learn otherwise from received offers
|
||||
h.remoteICESupported.Store(ice != nil)
|
||||
@@ -136,8 +120,6 @@ func (h *Handshaker) Listen(ctx context.Context) {
|
||||
|
||||
h.updateRemoteICEState(&remoteOfferAnswer)
|
||||
|
||||
h.pqRegisterEndpoint(remoteOfferAnswer.MlkemPort)
|
||||
|
||||
if h.relayListener != nil {
|
||||
h.relayListener.Notify(&remoteOfferAnswer)
|
||||
}
|
||||
@@ -146,7 +128,7 @@ func (h *Handshaker) Listen(ctx context.Context) {
|
||||
h.iceListener(&remoteOfferAnswer)
|
||||
}
|
||||
|
||||
if err := h.sendAnswer(&remoteOfferAnswer); err != nil {
|
||||
if err := h.sendAnswer(); err != nil {
|
||||
h.log.Errorf("failed to send remote offer confirmation: %s", err)
|
||||
continue
|
||||
}
|
||||
@@ -160,8 +142,6 @@ func (h *Handshaker) Listen(ctx context.Context) {
|
||||
|
||||
h.updateRemoteICEState(&remoteOfferAnswer)
|
||||
|
||||
h.pqRegisterEndpoint(remoteOfferAnswer.MlkemPort)
|
||||
|
||||
if h.relayListener != nil {
|
||||
h.relayListener.Notify(&remoteOfferAnswer)
|
||||
}
|
||||
@@ -169,10 +149,6 @@ func (h *Handshaker) Listen(ctx context.Context) {
|
||||
if h.iceListener != nil && h.RemoteICESupported() {
|
||||
h.iceListener(&remoteOfferAnswer)
|
||||
}
|
||||
|
||||
if h.config.PQ != nil {
|
||||
h.config.PQ.OnAnswer(h.config.Key, remoteOfferAnswer.MlkemPayload)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
h.log.Infof("stop listening for remote offers and answers")
|
||||
return
|
||||
@@ -180,16 +156,6 @@ func (h *Handshaker) Listen(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// pqRegisterEndpoint feeds the post-quantum handshaker the peer's data-path endpoint
|
||||
// (its WG overlay IP plus the advertised pq UDP port) learned from a remote offer/answer.
|
||||
func (h *Handshaker) pqRegisterEndpoint(remotePort int) {
|
||||
if h.config.PQ == nil || remotePort <= 0 || remotePort > 65535 || len(h.config.WgConfig.AllowedIps) == 0 {
|
||||
return
|
||||
}
|
||||
addr := netip.AddrPortFrom(h.config.WgConfig.AllowedIps[0].Addr(), uint16(remotePort))
|
||||
h.config.PQ.SetRemoteAddr(h.config.Key, addr)
|
||||
}
|
||||
|
||||
func (h *Handshaker) SendOffer() error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
@@ -229,23 +195,13 @@ func (h *Handshaker) sendOffer() error {
|
||||
}
|
||||
|
||||
offer := h.buildOfferAnswer()
|
||||
if h.config.PQ != nil {
|
||||
offer.MlkemPayload, offer.MlkemPort = h.config.PQ.OfferPayload(h.config.Key)
|
||||
}
|
||||
h.log.Debugf("sending offer with serial: %s", offer.SessionIDString())
|
||||
|
||||
return h.signaler.SignalOffer(offer, h.config.Key)
|
||||
}
|
||||
|
||||
func (h *Handshaker) sendAnswer(remoteOffer *OfferAnswer) error {
|
||||
func (h *Handshaker) sendAnswer() error {
|
||||
answer := h.buildOfferAnswer()
|
||||
if h.config.PQ != nil {
|
||||
var recvOffer []byte
|
||||
if remoteOffer != nil {
|
||||
recvOffer = remoteOffer.MlkemPayload
|
||||
}
|
||||
answer.MlkemPayload, answer.MlkemPort = h.config.PQ.AnswerPayload(h.config.Key, recvOffer)
|
||||
}
|
||||
h.log.Debugf("sending answer with serial: %s", answer.SessionIDString())
|
||||
|
||||
return h.signaler.SignalAnswer(answer, h.config.Key)
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/netbirdio/netbird/client/iface/configurer"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
"github.com/netbirdio/netbird/client/iface/wgproxy"
|
||||
"github.com/netbirdio/netbird/monotime"
|
||||
)
|
||||
|
||||
type WGIface interface {
|
||||
@@ -20,11 +19,4 @@ type WGIface interface {
|
||||
GetProxy() wgproxy.Proxy
|
||||
Address() wgaddr.Address
|
||||
RemoveEndpointAddress(key string) error
|
||||
// LastActivities returns the last real-data activity time per peer (WireGuard
|
||||
// keepalives excluded), used to gate post-quantum PSK rotation on active tunnels.
|
||||
LastActivities() map[string]monotime.Time
|
||||
// IsUserspaceBind reports whether WireGuard runs in userspace. Only there does
|
||||
// LastActivities track per-peer data activity; in kernel mode it is unavailable,
|
||||
// so PSK rotation cannot be gated on activity.
|
||||
IsUserspaceBind() bool
|
||||
}
|
||||
|
||||
@@ -63,8 +63,6 @@ func (s *Signaler) signalOfferAnswer(offerAnswer OfferAnswer, remoteKey string,
|
||||
},
|
||||
RosenpassPubKey: offerAnswer.RosenpassPubKey,
|
||||
RosenpassAddr: offerAnswer.RosenpassAddr,
|
||||
MlkemPayload: offerAnswer.MlkemPayload,
|
||||
MlkemPort: offerAnswer.MlkemPort,
|
||||
RelaySrvAddress: offerAnswer.RelaySrvAddress,
|
||||
RelaySrvIP: offerAnswer.RelaySrvIP,
|
||||
SessionID: sessionIDBytes,
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package pqkem
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/mlkem"
|
||||
"crypto/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func BenchmarkX25519Keygen(b *testing.B) {
|
||||
c := ecdh.X25519()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := c.GenerateKey(rand.Reader); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkX25519ECDH(b *testing.B) {
|
||||
c := ecdh.X25519()
|
||||
a, _ := c.GenerateKey(rand.Reader)
|
||||
p, _ := c.GenerateKey(rand.Reader)
|
||||
pub := p.PublicKey()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := a.ECDH(pub); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMLKEMKeygen(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := mlkem.GenerateKey768(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMLKEMEncaps(b *testing.B) {
|
||||
dk, _ := mlkem.GenerateKey768()
|
||||
ek := dk.EncapsulationKey()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = ek.Encapsulate()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMLKEMDecaps(b *testing.B) {
|
||||
dk, _ := mlkem.GenerateKey768()
|
||||
_, ct := dk.EncapsulationKey().Encapsulate()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := dk.Decapsulate(ct); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package pqkem
|
||||
|
||||
// CallbackHandler is implemented by the host and invoked by the library. The
|
||||
// library only reports events; the host owns the reaction. Keeping this an
|
||||
// interface — rather than touching the transport or keying directly — is what lets
|
||||
// the KEM code be extracted as a standalone library.
|
||||
type CallbackHandler interface {
|
||||
// OnNewPSKReady fires when a fresh post-quantum PSK has been derived for a peer
|
||||
// and must be programmed into the consumer's secure channel. It is invoked at
|
||||
// the commit point of each side: the initiator on receiving the answer, the
|
||||
// responder on receiving the confirm.
|
||||
OnNewPSKReady(remoteID RemoteID, psk PSK) error
|
||||
|
||||
// OnRekeyFailed fires when an exchange fails to converge within the allotted
|
||||
// time. The host should tear the peer connection down so it re-establishes, and
|
||||
// log a WARN. The library reports the event; it does not dictate the reaction.
|
||||
OnRekeyFailed(remoteID RemoteID) error
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
package pqkem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
)
|
||||
|
||||
// idHex renders an exchange ID for logs.
|
||||
func idHex(id ExchangeID) string { return hex.EncodeToString(id[:]) }
|
||||
|
||||
// pskFingerprint is a short, non-secret digest of a derived PSK: identical on both
|
||||
// peers iff they derived the same key. Logged instead of the raw PSK so debug logs
|
||||
// never carry the actual WireGuard preshared key.
|
||||
func pskFingerprint(psk PSK) string {
|
||||
sum := sha256.Sum256(psk[:])
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
// startExchange creates a fresh initiator exchange (acknowledging ackID, zero for a
|
||||
// bootstrap) and returns the framed offer for the caller to send — pushed over the
|
||||
// data path for a chained rekey, or handed to the host for signalling when viaSignal
|
||||
// is set. Any previous in-flight exchange for the peer is cancelled.
|
||||
func (m *Manager) startExchange(remoteID RemoteID, viaSignal bool, ackID ExchangeID) ([]byte, error) {
|
||||
init, err := NewInitiator()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := newExchangeID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := (&OfferMsg{ExchangeID: id, AckID: ackID, KEMOffer: init.Offer()}).Encode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(m.rootCtx)
|
||||
m.mu.Lock()
|
||||
if old := m.exchanges[remoteID]; old != nil && old.cancel != nil {
|
||||
old.cancel()
|
||||
}
|
||||
m.exchanges[remoteID] = &exchangeCtl{
|
||||
id: id,
|
||||
state: stateAwaitingAnswer,
|
||||
startedAt: time.Now(),
|
||||
cancel: cancel,
|
||||
lastSent: raw,
|
||||
initiator: init,
|
||||
viaSignal: viaSignal,
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
m.wait.Add(1)
|
||||
go m.initiatorLoop(ctx, remoteID, id)
|
||||
|
||||
via := "data-path"
|
||||
if viaSignal {
|
||||
via = "signal"
|
||||
}
|
||||
m.trace("pqkem: offer sent", "peer", remoteID, "exchange", idHex(id), "acks", idHex(ackID), "via", via)
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// processOffer (responder) first acknowledges the previous exchange the offer names
|
||||
// (that offer riding the data path under the freshly adopted key proves it worked),
|
||||
// then derives the PSK for the new offer, commits it optimistically, and returns the
|
||||
// framed answer. A duplicate offer returns the cached answer without re-deriving.
|
||||
func (m *Manager) processOffer(remoteID RemoteID, o *OfferMsg) ([]byte, error) {
|
||||
m.trace("pqkem: offer received", "peer", remoteID, "exchange", idHex(o.ExchangeID), "acks", idHex(o.AckID))
|
||||
|
||||
if o.AckID != (ExchangeID{}) {
|
||||
m.ackConverged(remoteID, o.AckID)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
if ex := m.exchanges[remoteID]; ex != nil && ex.id == o.ExchangeID {
|
||||
state, last := ex.state, ex.lastSent
|
||||
m.mu.Unlock()
|
||||
if state == stateReserved {
|
||||
return nil, nil
|
||||
}
|
||||
m.trace("pqkem: duplicate offer, resending cached answer", "peer", remoteID, "exchange", idHex(o.ExchangeID))
|
||||
return last, nil
|
||||
}
|
||||
// Reserve the slot so a concurrent duplicate offer bails.
|
||||
m.exchanges[remoteID] = &exchangeCtl{id: o.ExchangeID, state: stateReserved, startedAt: time.Now()}
|
||||
m.mu.Unlock()
|
||||
|
||||
answerBytes, psk, err := Respond(o.KEMOffer, m.binding(remoteID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := (&AnswerMsg{ExchangeID: o.ExchangeID, KEMAnswer: answerBytes}).Encode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
ex := m.exchanges[remoteID]
|
||||
if ex == nil || ex.id != o.ExchangeID {
|
||||
m.mu.Unlock()
|
||||
m.trace("pqkem: exchange superseded during respond, dropping answer", "peer", remoteID, "exchange", idHex(o.ExchangeID))
|
||||
return nil, nil
|
||||
}
|
||||
ex.state = stateAwaitingAck
|
||||
ex.lastSent = raw
|
||||
ex.pendingPSK = psk
|
||||
m.psks[remoteID] = psk
|
||||
m.mu.Unlock()
|
||||
|
||||
m.trace("pqkem: new PSK derived", "peer", remoteID, "exchange", idHex(o.ExchangeID), "role", "responder", "psk_fp", pskFingerprint(psk))
|
||||
|
||||
// Commit optimistically so our data path can rekey to the new PSK.
|
||||
if err := m.cbHandler.OnNewPSKReady(remoteID, psk); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.trace("pqkem: answer sent", "peer", remoteID, "exchange", idHex(o.ExchangeID))
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// processAnswer (initiator) derives and commits the PSK and parks in
|
||||
// stateAwaitingRekey; the next offer (chained from OnDataPathRekeyed) will acknowledge
|
||||
// this exchange. Only valid in stateAwaitingAnswer; advancing the state under the
|
||||
// lock makes a concurrent/duplicate answer bail.
|
||||
func (m *Manager) processAnswer(remoteID RemoteID, a *AnswerMsg) error {
|
||||
m.mu.Lock()
|
||||
ex := m.exchanges[remoteID]
|
||||
if ex == nil || ex.id != a.ExchangeID || ex.state != stateAwaitingAnswer {
|
||||
haveID := "none"
|
||||
if ex != nil {
|
||||
haveID = idHex(ex.id)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
m.trace("pqkem: unexpected answer dropped (inconsistency)", "peer", remoteID, "answer_for", idHex(a.ExchangeID), "have_exchange", haveID)
|
||||
return nil
|
||||
}
|
||||
ex.state = stateAwaitingRekey
|
||||
init := ex.initiator
|
||||
ex.initiator = nil
|
||||
m.mu.Unlock()
|
||||
|
||||
m.trace("pqkem: answer received", "peer", remoteID, "exchange", idHex(a.ExchangeID))
|
||||
|
||||
psk, err := init.Finish(a.KEMAnswer, m.binding(remoteID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The initiator has converged: the responder must have derived the key to answer.
|
||||
m.mu.Lock()
|
||||
m.established[remoteID] = true
|
||||
m.failures[remoteID] = 0
|
||||
m.psks[remoteID] = psk
|
||||
m.mu.Unlock()
|
||||
|
||||
m.trace("pqkem: new PSK derived", "peer", remoteID, "exchange", idHex(a.ExchangeID), "role", "initiator", "psk_fp", pskFingerprint(psk))
|
||||
|
||||
return m.cbHandler.OnNewPSKReady(remoteID, psk)
|
||||
}
|
||||
|
||||
// ackConverged (responder) records convergence of the exchange named by ackID: a
|
||||
// later offer acknowledging it proves both sides operate on that exchange's key. Only
|
||||
// acts on a matching stateAwaitingAck exchange; anything else is ignored.
|
||||
func (m *Manager) ackConverged(remoteID RemoteID, ackID ExchangeID) {
|
||||
m.mu.Lock()
|
||||
ex := m.exchanges[remoteID]
|
||||
if ex == nil || ex.id != ackID || ex.state != stateAwaitingAck {
|
||||
m.mu.Unlock()
|
||||
m.trace("pqkem: ack for unknown/mismatched exchange, ignored (inconsistency)", "peer", remoteID, "acks", idHex(ackID))
|
||||
return
|
||||
}
|
||||
delete(m.exchanges, remoteID)
|
||||
m.established[remoteID] = true
|
||||
m.failures[remoteID] = 0
|
||||
_ = time.Since(ex.startedAt) // convergence latency (metrics hook, later step)
|
||||
m.mu.Unlock()
|
||||
|
||||
m.trace("pqkem: previous exchange confirmed by ack", "peer", remoteID, "exchange", idHex(ackID))
|
||||
}
|
||||
|
||||
// initiatorLoop enforces the offer->answer convergence deadline and retransmits the
|
||||
// initiator's outstanding data-path offer while awaiting the answer (a
|
||||
// signalling-bootstrapped offer is retransmitted by the host, so it is not resent
|
||||
// here). Exhausting the deadline before the answer arrives is a failure. Once the
|
||||
// answer is in (state past awaitingAnswer) the loop exits: the next rotation is driven
|
||||
// by OnDataPathRekeyed, and the idle wait for it has no deadline.
|
||||
func (m *Manager) initiatorLoop(ctx context.Context, remoteID RemoteID, id ExchangeID) {
|
||||
defer m.wait.Done()
|
||||
t := time.NewTicker(m.retryInterval)
|
||||
defer t.Stop()
|
||||
|
||||
attempts := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
m.mu.Lock()
|
||||
ex := m.exchanges[remoteID]
|
||||
if ex == nil || ex.id != id {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
switch ex.state {
|
||||
case stateAwaitingAnswer:
|
||||
if attempts >= m.maxRetries {
|
||||
delete(m.exchanges, remoteID)
|
||||
initial := !m.established[remoteID]
|
||||
fail := m.registerFailureLocked(remoteID)
|
||||
m.mu.Unlock()
|
||||
m.raiseFailure(remoteID, fail, initial)
|
||||
return
|
||||
}
|
||||
viaSignal := ex.viaSignal
|
||||
msg := ex.lastSent
|
||||
attempts++
|
||||
m.mu.Unlock()
|
||||
if !viaSignal {
|
||||
if err := m.pushDataPath(remoteID, msg); err != nil {
|
||||
m.logger.Warn("pqkem: offer retransmit failed", "peer", remoteID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
// Past awaiting the answer (converged) or superseded: the loop's job
|
||||
// is done. The next rotation is driven externally by OnDataPathRekeyed,
|
||||
// so there is no deadline while idle-waiting for it (that wait can be
|
||||
// as long as the transport's natural rekey interval).
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// registerFailureLocked applies policy B and reports whether OnRekeyFailed is due:
|
||||
// an initial exchange (peer never established) fails immediately; a rekey tolerates
|
||||
// up to maxRekeyFailures consecutive misses (we stay on the still-valid previous
|
||||
// PSK) before failing. Assumes m.mu is held.
|
||||
func (m *Manager) registerFailureLocked(remoteID RemoteID) bool {
|
||||
if !m.established[remoteID] {
|
||||
return true
|
||||
}
|
||||
m.failures[remoteID]++
|
||||
if m.failures[remoteID] >= m.maxRekeyFailures {
|
||||
m.failures[remoteID] = 0
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// raiseFailure reports a convergence failure. initial distinguishes a never-established
|
||||
// peer (bootstrap failed → no PQ PSK at all; in strict mode the peer stays blocked =
|
||||
// "stuck") from a rekey failure (a previous PSK is still in force and traffic continues).
|
||||
func (m *Manager) raiseFailure(remoteID RemoteID, fail, initial bool) {
|
||||
if !fail {
|
||||
m.logger.Warn("pqkem: rekey attempt timed out, will retry next cycle", "peer", remoteID)
|
||||
return
|
||||
}
|
||||
if initial {
|
||||
m.logger.Warn("pqkem: initial exchange failed — no PQ PSK established for peer (strict mode keeps the peer blocked until it converges)", "peer", remoteID)
|
||||
} else {
|
||||
m.logger.Warn("pqkem: rekey failed after retries — staying on the previous PSK", "peer", remoteID)
|
||||
}
|
||||
if err := m.cbHandler.OnRekeyFailed(remoteID); err != nil {
|
||||
m.logger.Error("pqkem: OnRekeyFailed handler error", "peer", remoteID, "err", err)
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package pqkem
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// dropTransport is a pqkem.Transport that silently discards everything.
|
||||
type dropTransport struct{}
|
||||
|
||||
func (dropTransport) Send(netip.AddrPort, []byte) error { return nil }
|
||||
func (dropTransport) LocalPort() int { return 0 }
|
||||
func (dropTransport) Run(func(netip.AddrPort, []byte)) {}
|
||||
func (dropTransport) Close() error { return nil }
|
||||
|
||||
func failedCount(f *fakeWG) int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.failed)
|
||||
}
|
||||
|
||||
func TestManager_InitialTimeoutFailsImmediately(t *testing.T) {
|
||||
wg := newFakeWG()
|
||||
d := NewManager("bbbb", wg, nil) // bbbb > aaaa -> initiator
|
||||
d.Start(dropTransport{})
|
||||
d.retryInterval = 5 * time.Millisecond
|
||||
d.maxRetries = 3
|
||||
defer d.Stop()
|
||||
|
||||
// Bootstrap offer is produced for signalling; no answer ever comes back -> the
|
||||
// initial exchange fails fast.
|
||||
offer, err := d.SignalOffer("aaaa")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, offer)
|
||||
|
||||
require.Eventually(t, func() bool { return failedCount(wg) == 1 }, time.Second, 5*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestManager_RekeyToleratesKFailures(t *testing.T) {
|
||||
dA, dB, _, wgB, lbB := pair(t)
|
||||
defer dA.Stop()
|
||||
defer dB.Stop()
|
||||
|
||||
// Tighten B's timings before any exchange loop spawns (the loop reads these
|
||||
// fields, so writing them after a loop is running would race).
|
||||
dB.retryInterval = 5 * time.Millisecond
|
||||
dB.maxRetries = 2
|
||||
|
||||
// Establish: bootstrap + data-path-rekeyed so B becomes established and its data
|
||||
// path is usable.
|
||||
bootstrap(t, dA, dB)
|
||||
dA.OnDataPathRekeyed("bbbb", 0)
|
||||
dB.OnDataPathRekeyed("aaaa", 0)
|
||||
require.NotEqual(t, PSK{}, wgB.psk("aaaa"))
|
||||
|
||||
// Drop B's outbound so rekeys can no longer converge.
|
||||
lbB.drop.Store(true)
|
||||
|
||||
// K-1 data-path rekeys must NOT raise OnRekeyFailed.
|
||||
for i := 0; i < DefaultMaxRekeyFailures-1; i++ {
|
||||
_, err := dB.startExchange("aaaa", false, ExchangeID{})
|
||||
require.NoError(t, err)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
require.Equal(t, 0, failedCount(wgB), "no failure before K attempts")
|
||||
|
||||
// The K-th failure raises it once.
|
||||
_, err := dB.startExchange("aaaa", false, ExchangeID{})
|
||||
require.NoError(t, err)
|
||||
require.Eventually(t, func() bool { return failedCount(wgB) == 1 }, time.Second, 5*time.Millisecond)
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
package pqkem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// EnvEnabled is the environment variable that turns the ML-KEM post-quantum
|
||||
// exchange on for this client. Accepts on/off aliases plus anything
|
||||
// strconv.ParseBool understands (true/false/1/0).
|
||||
const EnvEnabled = "NB_ENABLE_PQ_MLKEM"
|
||||
|
||||
// Enabled reports whether the ML-KEM PQ exchange is enabled via the environment.
|
||||
// An empty or unrecognized value is treated as disabled.
|
||||
func Enabled() bool {
|
||||
raw := strings.ToLower(strings.TrimSpace(os.Getenv(EnvEnabled)))
|
||||
switch raw {
|
||||
case "":
|
||||
return false
|
||||
case "on":
|
||||
return true
|
||||
case "off":
|
||||
return false
|
||||
}
|
||||
enabled, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
log.Warnf("failed to parse %s value %q: %v", EnvEnabled, raw, err)
|
||||
return false
|
||||
}
|
||||
return enabled
|
||||
}
|
||||
|
||||
// EnvStrict enables strict (fail-closed) mode: block peer traffic until the ML-KEM
|
||||
// PSK has been established, instead of the default opportunistic behaviour that lets
|
||||
// the tunnel come up classically and upgrades to PQ once the exchange converges.
|
||||
const EnvStrict = "NB_PQ_MLKEM_STRICT"
|
||||
|
||||
// Strict reports whether strict (fail-closed) mode is enabled via the environment.
|
||||
// An empty or unrecognized value is treated as disabled (opportunistic).
|
||||
func Strict() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(EnvStrict))) {
|
||||
case "on":
|
||||
return true
|
||||
case "", "off":
|
||||
return false
|
||||
}
|
||||
enabled, err := strconv.ParseBool(strings.TrimSpace(os.Getenv(EnvStrict)))
|
||||
if err != nil {
|
||||
log.Warnf("failed to parse %s value %q: %v", EnvStrict, os.Getenv(EnvStrict), err)
|
||||
return false
|
||||
}
|
||||
return enabled
|
||||
}
|
||||
|
||||
// EnvLogLevel overrides the ML-KEM manager's slog level (trace/debug/info/warn/error).
|
||||
// Defaults to info. The verbose per-exchange lifecycle logs are emitted at trace.
|
||||
const EnvLogLevel = "NB_PQ_MLKEM_LOG_LEVEL"
|
||||
|
||||
// LevelTrace is a custom slog level below Debug for the verbose per-exchange lifecycle
|
||||
// logs, so they stay off unless NB_PQ_MLKEM_LOG_LEVEL=trace (and the daemon log level
|
||||
// is trace, since the records are forwarded to logrus).
|
||||
const LevelTrace = slog.LevelDebug - 4
|
||||
|
||||
// NewLogger builds the slog logger for the ML-KEM manager. It forwards records to
|
||||
// logrus so PQ logs land in the same sink as the rest of the daemon (console +
|
||||
// client.log) rather than stdout. Verbosity is gated by EnvLogLevel.
|
||||
func NewLogger() *slog.Logger {
|
||||
return slog.New(slogToLogrus{})
|
||||
}
|
||||
|
||||
func logLevel() slog.Level {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(EnvLogLevel))) {
|
||||
case "trace":
|
||||
return LevelTrace
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
// slogToLogrus is a slog.Handler that forwards records to logrus, so the ML-KEM
|
||||
// manager's logs go wherever the daemon's logrus is configured (console + client.log)
|
||||
// instead of stdout. Verbosity is gated by EnvLogLevel via logLevel().
|
||||
type slogToLogrus struct {
|
||||
fields log.Fields
|
||||
}
|
||||
|
||||
func (h slogToLogrus) Enabled(_ context.Context, level slog.Level) bool {
|
||||
return level >= logLevel()
|
||||
}
|
||||
|
||||
func (h slogToLogrus) Handle(_ context.Context, r slog.Record) error {
|
||||
fields := make(log.Fields, len(h.fields)+r.NumAttrs())
|
||||
for k, v := range h.fields {
|
||||
fields[k] = v
|
||||
}
|
||||
r.Attrs(func(a slog.Attr) bool {
|
||||
fields[a.Key] = a.Value.Any()
|
||||
return true
|
||||
})
|
||||
entry := log.WithFields(fields)
|
||||
switch {
|
||||
case r.Level >= slog.LevelError:
|
||||
entry.Error(r.Message)
|
||||
case r.Level >= slog.LevelWarn:
|
||||
entry.Warn(r.Message)
|
||||
case r.Level >= slog.LevelInfo:
|
||||
entry.Info(r.Message)
|
||||
case r.Level >= slog.LevelDebug:
|
||||
entry.Debug(r.Message)
|
||||
default:
|
||||
entry.Trace(r.Message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h slogToLogrus) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
fields := make(log.Fields, len(h.fields)+len(attrs))
|
||||
for k, v := range h.fields {
|
||||
fields[k] = v
|
||||
}
|
||||
for _, a := range attrs {
|
||||
fields[a.Key] = a.Value.Any()
|
||||
}
|
||||
return slogToLogrus{fields: fields}
|
||||
}
|
||||
|
||||
func (h slogToLogrus) WithGroup(_ string) slog.Handler { return h }
|
||||
@@ -1,178 +0,0 @@
|
||||
// Package pqkem is a spike (NET-1406) for a post-quantum pre-shared-key exchange
|
||||
// that could replace Rosenpass. It performs an X25519MLKEM768 hybrid key
|
||||
// encapsulation and derives a 32-byte pre-shared key (PSK).
|
||||
//
|
||||
// The exchange is a single round trip designed to ride the (already
|
||||
// authenticated) Signal offer/answer channel:
|
||||
//
|
||||
// initiator --Offer(1216B)--> responder
|
||||
// initiator <--Answer(1120B)-- responder
|
||||
//
|
||||
// Both sides then hold the same PSK, which is bound to the two peers' identities
|
||||
// (their peer identity keys) so the derived key cannot be transplanted
|
||||
// to a different peer pair even if the transport authentication were bypassed.
|
||||
//
|
||||
// Combiner note: this follows draft-ietf-tls-ecdhe-mlkem for X25519MLKEM768 — on
|
||||
// the wire ML-KEM ‖ X25519 (the draft deliberately reversed the share order for
|
||||
// this group), and ML-KEM_ss ‖ X25519_ss as the KDF input. The PSK is derived with
|
||||
// HKDF-SHA256 over that hybrid secret, salted with a domain-separation label and
|
||||
// bound (via the HKDF info) to the full transcript and the canonicalised peer
|
||||
// identities.
|
||||
package pqkem
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/hkdf"
|
||||
"crypto/mlkem"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
// OfferSize is the initiator message: ML-KEM-768 encapsulation key ‖ X25519 public key
|
||||
// (share order per draft-ietf-tls-ecdhe-mlkem for X25519MLKEM768).
|
||||
OfferSize = mlkem.EncapsulationKeySize768 + 32 // 1216
|
||||
// AnswerSize is the responder message: ML-KEM-768 ciphertext ‖ X25519 public key.
|
||||
AnswerSize = mlkem.CiphertextSize768 + 32 // 1120
|
||||
|
||||
pskLabel = "netbird-pq-psk-v1"
|
||||
)
|
||||
|
||||
// PSK is the 32-byte derived pre-shared key handed to the consumer to key its channel.
|
||||
type PSK [32]byte
|
||||
|
||||
// Binding identifies the peer pair the PSK is derived for. Callers set both
|
||||
// peer identity keys; the order does not matter (it is canonicalised).
|
||||
type Binding struct {
|
||||
LocalID []byte
|
||||
RemoteID []byte
|
||||
}
|
||||
|
||||
// Initiator holds the ephemeral secrets between Offer and Finish.
|
||||
type Initiator struct {
|
||||
x25519 *ecdh.PrivateKey
|
||||
mlkemDK *mlkem.DecapsulationKey768
|
||||
offer []byte
|
||||
}
|
||||
|
||||
// NewInitiator generates the ephemeral X25519 + ML-KEM-768 keypairs.
|
||||
func NewInitiator() (*Initiator, error) {
|
||||
x, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("x25519 keygen: %w", err)
|
||||
}
|
||||
dk, err := mlkem.GenerateKey768()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ml-kem keygen: %w", err)
|
||||
}
|
||||
|
||||
offer := make([]byte, 0, OfferSize)
|
||||
offer = append(offer, dk.EncapsulationKey().Bytes()...)
|
||||
offer = append(offer, x.PublicKey().Bytes()...)
|
||||
|
||||
return &Initiator{x25519: x, mlkemDK: dk, offer: offer}, nil
|
||||
}
|
||||
|
||||
// Offer returns the initiator message to send over Signal.
|
||||
func (i *Initiator) Offer() []byte {
|
||||
return i.offer
|
||||
}
|
||||
|
||||
// Finish consumes the responder's answer and derives the PSK.
|
||||
func (i *Initiator) Finish(answer []byte, b Binding) (PSK, error) {
|
||||
if len(answer) != AnswerSize {
|
||||
return PSK{}, fmt.Errorf("answer: got %d bytes, want %d", len(answer), AnswerSize)
|
||||
}
|
||||
ct := answer[:mlkem.CiphertextSize768]
|
||||
peerX := answer[mlkem.CiphertextSize768:]
|
||||
|
||||
ssMLKEM, err := i.mlkemDK.Decapsulate(ct)
|
||||
if err != nil {
|
||||
return PSK{}, fmt.Errorf("ml-kem decapsulate: %w", err)
|
||||
}
|
||||
pub, err := ecdh.X25519().NewPublicKey(peerX)
|
||||
if err != nil {
|
||||
return PSK{}, fmt.Errorf("parse peer x25519: %w", err)
|
||||
}
|
||||
ssX, err := i.x25519.ECDH(pub)
|
||||
if err != nil {
|
||||
return PSK{}, fmt.Errorf("x25519 ecdh: %w", err)
|
||||
}
|
||||
|
||||
return derivePSK(ssMLKEM, ssX, i.offer, answer, b)
|
||||
}
|
||||
|
||||
// Respond consumes an initiator offer, produces the answer, and derives the PSK.
|
||||
func Respond(offer []byte, b Binding) (answer []byte, psk PSK, err error) {
|
||||
if len(offer) != OfferSize {
|
||||
return nil, PSK{}, fmt.Errorf("offer: got %d bytes, want %d", len(offer), OfferSize)
|
||||
}
|
||||
peerEK := offer[:mlkem.EncapsulationKeySize768]
|
||||
peerX := offer[mlkem.EncapsulationKeySize768:]
|
||||
|
||||
ek, err := mlkem.NewEncapsulationKey768(peerEK)
|
||||
if err != nil {
|
||||
return nil, PSK{}, fmt.Errorf("parse peer ml-kem key: %w", err)
|
||||
}
|
||||
ssMLKEM, ct := ek.Encapsulate()
|
||||
|
||||
x, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, PSK{}, fmt.Errorf("x25519 keygen: %w", err)
|
||||
}
|
||||
pub, err := ecdh.X25519().NewPublicKey(peerX)
|
||||
if err != nil {
|
||||
return nil, PSK{}, fmt.Errorf("parse peer x25519: %w", err)
|
||||
}
|
||||
ssX, err := x.ECDH(pub)
|
||||
if err != nil {
|
||||
return nil, PSK{}, fmt.Errorf("x25519 ecdh: %w", err)
|
||||
}
|
||||
|
||||
answer = make([]byte, 0, AnswerSize)
|
||||
answer = append(answer, ct...)
|
||||
answer = append(answer, x.PublicKey().Bytes()...)
|
||||
|
||||
// derivePSK uses the same argument order on both sides; the responder's local
|
||||
// binding is the mirror of the initiator's, canonicalised inside derivePSK.
|
||||
psk, err = derivePSK(ssMLKEM, ssX, offer, answer, b)
|
||||
if err != nil {
|
||||
return nil, PSK{}, err
|
||||
}
|
||||
return answer, psk, nil
|
||||
}
|
||||
|
||||
// derivePSK runs HKDF-SHA256 over the hybrid shared secret (ML-KEM_ss ‖ X25519_ss,
|
||||
// per draft-ietf-tls-ecdhe-mlkem), salted with the domain-separation label, and binds
|
||||
// the result — via the HKDF info — to the full transcript (offer ‖ answer) and the
|
||||
// canonicalised peer identities, so the PSK cannot be transplanted to another peer
|
||||
// pair or a different exchange.
|
||||
func derivePSK(ssMLKEM, ssX, offer, answer []byte, b Binding) (PSK, error) {
|
||||
lo, hi := canonicalPair(b.LocalID, b.RemoteID)
|
||||
|
||||
ikm := make([]byte, 0, len(ssMLKEM)+len(ssX))
|
||||
ikm = append(ikm, ssMLKEM...)
|
||||
ikm = append(ikm, ssX...)
|
||||
|
||||
info := make([]byte, 0, len(offer)+len(answer)+len(lo)+len(hi))
|
||||
info = append(info, offer...)
|
||||
info = append(info, answer...)
|
||||
info = append(info, lo...)
|
||||
info = append(info, hi...)
|
||||
|
||||
var psk PSK
|
||||
key, err := hkdf.Key(sha256.New, ikm, []byte(pskLabel), string(info), len(psk))
|
||||
if err != nil {
|
||||
return PSK{}, fmt.Errorf("hkdf derive psk: %w", err)
|
||||
}
|
||||
copy(psk[:], key)
|
||||
return psk, nil
|
||||
}
|
||||
|
||||
func canonicalPair(a, b []byte) (lo, hi []byte) {
|
||||
if string(a) <= string(b) {
|
||||
return a, b
|
||||
}
|
||||
return b, a
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package pqkem
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var (
|
||||
wgA = []byte("peer-A-wireguard-pubkey-32bytes!")
|
||||
wgB = []byte("peer-B-wireguard-pubkey-32bytes!")
|
||||
)
|
||||
|
||||
func TestExchange_DerivesMatchingPSK(t *testing.T) {
|
||||
init, err := NewInitiator()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, init.Offer(), OfferSize)
|
||||
|
||||
answer, pskB, err := Respond(init.Offer(), Binding{LocalID: wgB, RemoteID: wgA})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, answer, AnswerSize)
|
||||
|
||||
pskA, err := init.Finish(answer, Binding{LocalID: wgA, RemoteID: wgB})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, pskB, pskA, "both sides must derive the same PSK")
|
||||
require.NotEqual(t, PSK{}, pskA, "PSK must not be zero")
|
||||
}
|
||||
|
||||
func TestExchange_PSKBoundToPeerIdentities(t *testing.T) {
|
||||
init, err := NewInitiator()
|
||||
require.NoError(t, err)
|
||||
|
||||
// responder computes with the honest pair...
|
||||
_, pskHonest, err := Respond(init.Offer(), Binding{LocalID: wgB, RemoteID: wgA})
|
||||
require.NoError(t, err)
|
||||
|
||||
// ...a second responder run with a different peer identity yields a different PSK,
|
||||
// even though the KEM material would otherwise combine identically.
|
||||
wgC := []byte("peer-C-wireguard-pubkey-32bytes!")
|
||||
_, pskWrong, err := Respond(init.Offer(), Binding{LocalID: wgC, RemoteID: wgA})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotEqual(t, pskHonest, pskWrong, "PSK must be bound to the peer pair")
|
||||
}
|
||||
|
||||
func TestExchange_RejectsMalformedMessages(t *testing.T) {
|
||||
init, err := NewInitiator()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = Respond(init.Offer()[:10], Binding{})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = init.Finish([]byte("too short"), Binding{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestExchange_ReportSizesAndTiming is a spike measurement, not a pass/fail gate.
|
||||
// Run with: go test -run TestExchange_ReportSizesAndTiming -v ./client/internal/pqkem/
|
||||
func TestExchange_ReportSizesAndTiming(t *testing.T) {
|
||||
const iters = 200
|
||||
|
||||
var tInit, tResp, tFinish time.Duration
|
||||
for i := 0; i < iters; i++ {
|
||||
s0 := time.Now()
|
||||
init, err := NewInitiator()
|
||||
require.NoError(t, err)
|
||||
tInit += time.Since(s0)
|
||||
|
||||
s1 := time.Now()
|
||||
answer, _, err := Respond(init.Offer(), Binding{LocalID: wgB, RemoteID: wgA})
|
||||
require.NoError(t, err)
|
||||
tResp += time.Since(s1)
|
||||
|
||||
s2 := time.Now()
|
||||
_, err = init.Finish(answer, Binding{LocalID: wgA, RemoteID: wgB})
|
||||
require.NoError(t, err)
|
||||
tFinish += time.Since(s2)
|
||||
}
|
||||
|
||||
t.Logf("wire sizes: offer=%d B answer=%d B (Rosenpass static pubkey ~524160 B)", OfferSize, AnswerSize)
|
||||
t.Logf("total on-wire per handshake: %d B (~%.0fx smaller than RP static key)", OfferSize+AnswerSize, 524160.0/float64(OfferSize+AnswerSize))
|
||||
t.Logf("avg NewInitiator (keygen): %s", tInit/iters)
|
||||
t.Logf("avg Respond (encaps+dh): %s", tResp/iters)
|
||||
t.Logf("avg Finish (decaps+dh): %s", tFinish/iters)
|
||||
t.Logf("avg full handshake CPU: %s", (tInit+tResp+tFinish)/iters)
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
package pqkem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultRetryInterval is how often the initiator retransmits its outstanding
|
||||
// data-path offer while awaiting the answer.
|
||||
DefaultRetryInterval = 2 * time.Second
|
||||
// DefaultMaxRetries bounds how many ticks an exchange may run before it is
|
||||
// declared failed. The convergence deadline is thus MaxRetries * RetryInterval.
|
||||
DefaultMaxRetries = 10
|
||||
// DefaultMaxRekeyFailures is how many consecutive rekey (non-initial) failures
|
||||
// are tolerated before OnRekeyFailed. The initial exchange fails immediately.
|
||||
DefaultMaxRekeyFailures = 3
|
||||
|
||||
// rotationActivityWindow gates rotation on recent real-data activity: a rekey
|
||||
// clocks a rotation only if the peer exchanged user data within this window. It
|
||||
// must stay shorter than the data path's rekey interval (WireGuard
|
||||
// REKEY_AFTER_TIME ~120s) so the rotation's own traffic — which itself renews the
|
||||
// activity signal — ages out before the next rekey, letting an idle tunnel stop
|
||||
// rotating instead of self-sustaining.
|
||||
rotationActivityWindow = 90 * time.Second
|
||||
)
|
||||
|
||||
// LocalID and RemoteID are peer identity keys (e.g. WireGuard public keys). They are
|
||||
// distinct types so the local and a remote identity cannot be mixed up.
|
||||
type (
|
||||
LocalID string
|
||||
RemoteID string
|
||||
)
|
||||
|
||||
// Transport is the data-path socket the Manager drives (the analogue of
|
||||
// go-rosenpass's Conn). It is a dumb mover of bytes to/from endpoints: the Manager
|
||||
// owns the remoteID<->endpoint routing and hands the transport a resolved endpoint
|
||||
// to Send, and reverse-resolves the source of each inbound datagram. Its lifecycle
|
||||
// belongs to the Manager (Run at Start, Close at Stop).
|
||||
type Transport interface {
|
||||
// Send delivers msg to the given data-path endpoint.
|
||||
Send(endpoint netip.AddrPort, msg []byte) error
|
||||
// LocalPort is the bound local UDP port, announced to peers so they know where
|
||||
// to send data-path messages.
|
||||
LocalPort() int
|
||||
// Run starts delivering inbound datagrams as (source endpoint, msg) to onInbound
|
||||
// and returns immediately; it runs until Close.
|
||||
Run(onInbound func(src netip.AddrPort, msg []byte))
|
||||
// Close stops delivery and releases the socket.
|
||||
Close() error
|
||||
}
|
||||
|
||||
// exchangeState is the single source of truth for an exchange's role and phase.
|
||||
type exchangeState uint8
|
||||
|
||||
const (
|
||||
stateReserved exchangeState = iota // responder: deriving the answer
|
||||
stateAwaitingAnswer // initiator: offer sent, awaiting the answer
|
||||
stateAwaitingRekey // initiator: PSK derived+set, awaiting OnDataPathRekeyed to chain the next offer
|
||||
stateAwaitingAck // responder: answer sent, awaiting the next offer that acks this exchange
|
||||
)
|
||||
|
||||
// exchangeCtl holds all state for one in-flight exchange with a peer, under the
|
||||
// Manager's single lock. state drives every decision. lastSent is the current
|
||||
// data-path retransmit payload (the offer, for the initiator). initiator is the
|
||||
// ephemeral handle used at Finish; pendingPSK is the responder's derived key.
|
||||
// viaSignal records that the offer went to the host for the signalling channel, so
|
||||
// the loop does not retransmit it on the data path. Only the initiator runs a
|
||||
// retransmit loop, so only it sets cancel.
|
||||
type exchangeCtl struct {
|
||||
id ExchangeID
|
||||
state exchangeState
|
||||
startedAt time.Time
|
||||
cancel context.CancelFunc
|
||||
lastSent []byte
|
||||
initiator *Initiator
|
||||
pendingPSK PSK
|
||||
viaSignal bool
|
||||
}
|
||||
|
||||
// Manager is the stateful orchestrator — the analogue of go-rosenpass's Server. It
|
||||
// drives the X25519MLKEM768 exchange, owns the peer endpoint routing and the data-path
|
||||
// transport, and surfaces the derived PSK and convergence to the host via
|
||||
// CallbackHandler. It is event-driven: the bootstrap is triggered by the host
|
||||
// (SignalOffer) and each rotation is clocked by OnDataPathRekeyed. The cryptography is
|
||||
// the pure kem.go primitives; all state lives here under one lock.
|
||||
type Manager struct {
|
||||
localID LocalID
|
||||
cbHandler CallbackHandler
|
||||
logger *slog.Logger
|
||||
|
||||
retryInterval time.Duration
|
||||
maxRetries int
|
||||
maxRekeyFailures int
|
||||
|
||||
rootCtx context.Context
|
||||
rootCancel context.CancelFunc
|
||||
|
||||
mu sync.Mutex
|
||||
transport Transport
|
||||
exchanges map[RemoteID]*exchangeCtl // in-flight exchange per peer
|
||||
established map[RemoteID]bool // peer has completed at least one exchange
|
||||
failures map[RemoteID]int // consecutive rekey failures per peer
|
||||
psks map[RemoteID]PSK // latest derived PSK per peer (pulled at WG peer-config time)
|
||||
peerAddrs map[RemoteID]netip.AddrPort // remoteID -> data-path endpoint (send routing)
|
||||
peersByAddr map[netip.AddrPort]RemoteID // reverse: source endpoint -> remoteID (inbound)
|
||||
wait sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewManager builds a manager for the local peer identified by its peer identity key
|
||||
// (used for the deterministic initiator role and the identity binding). A nil logger
|
||||
// falls back to slog.Default(). Install the data-path transport with Start.
|
||||
func NewManager(localID LocalID, h CallbackHandler, logger *slog.Logger) *Manager {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Manager{
|
||||
localID: localID,
|
||||
cbHandler: h,
|
||||
logger: logger,
|
||||
retryInterval: DefaultRetryInterval,
|
||||
maxRetries: DefaultMaxRetries,
|
||||
maxRekeyFailures: DefaultMaxRekeyFailures,
|
||||
rootCtx: ctx,
|
||||
rootCancel: cancel,
|
||||
exchanges: make(map[RemoteID]*exchangeCtl),
|
||||
established: make(map[RemoteID]bool),
|
||||
failures: make(map[RemoteID]int),
|
||||
psks: make(map[RemoteID]PSK),
|
||||
peerAddrs: make(map[RemoteID]netip.AddrPort),
|
||||
peersByAddr: make(map[netip.AddrPort]RemoteID),
|
||||
}
|
||||
}
|
||||
|
||||
// Start installs the data-path transport and begins its inbound delivery. The Manager
|
||||
// owns it from here; Stop closes it. Start/Stop are the transport lifecycle pair.
|
||||
func (m *Manager) Start(t Transport) {
|
||||
m.mu.Lock()
|
||||
m.transport = t
|
||||
m.mu.Unlock()
|
||||
if t != nil {
|
||||
t.Run(m.onDataPathInbound)
|
||||
}
|
||||
}
|
||||
|
||||
// LocalPort is the data-path transport's bound UDP port (0 if no transport), to be
|
||||
// announced to peers.
|
||||
func (m *Manager) LocalPort() int {
|
||||
m.mu.Lock()
|
||||
t := m.transport
|
||||
m.mu.Unlock()
|
||||
if t == nil {
|
||||
return 0
|
||||
}
|
||||
return t.LocalPort()
|
||||
}
|
||||
|
||||
// IsInitiator reports whether the local peer drives the exchange for this remote
|
||||
// peer. Roles are deterministic (lexicographic identity-key compare) so exactly one
|
||||
// side initiates, mirroring how Rosenpass picks its handshake initiator.
|
||||
func (m *Manager) IsInitiator(remoteID RemoteID) bool {
|
||||
return string(m.localID) > string(remoteID)
|
||||
}
|
||||
|
||||
// PSK returns the latest PSK derived for the peer, for the host to program at WG
|
||||
// peer-config time (the pull path). ok is false until an exchange has derived one.
|
||||
func (m *Manager) PSK(remoteID RemoteID) (PSK, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
psk, ok := m.psks[remoteID]
|
||||
return psk, ok
|
||||
}
|
||||
|
||||
// trace logs at LevelTrace, the verbose per-exchange lifecycle level gated by
|
||||
// NB_PQ_MLKEM_LOG_LEVEL=trace.
|
||||
func (m *Manager) trace(msg string, args ...any) {
|
||||
m.logger.Log(context.Background(), LevelTrace, msg, args...)
|
||||
}
|
||||
|
||||
// AddPeer registers where a peer's data-path messages are sent and received: its
|
||||
// overlay endpoint (IP:port). Re-adding updates the endpoint.
|
||||
func (m *Manager) AddPeer(remoteID RemoteID, endpoint netip.AddrPort) {
|
||||
if !endpoint.IsValid() {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
if old, ok := m.peerAddrs[remoteID]; ok {
|
||||
delete(m.peersByAddr, old)
|
||||
}
|
||||
m.peerAddrs[remoteID] = endpoint
|
||||
m.peersByAddr[endpoint] = remoteID
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// RemovePeer stops any in-flight exchange for a peer and drops its state and routing.
|
||||
func (m *Manager) RemovePeer(remoteID RemoteID) {
|
||||
m.mu.Lock()
|
||||
if ex, ok := m.exchanges[remoteID]; ok {
|
||||
if ex.cancel != nil {
|
||||
ex.cancel()
|
||||
}
|
||||
delete(m.exchanges, remoteID)
|
||||
}
|
||||
delete(m.established, remoteID)
|
||||
delete(m.failures, remoteID)
|
||||
delete(m.psks, remoteID)
|
||||
if ep, ok := m.peerAddrs[remoteID]; ok {
|
||||
delete(m.peersByAddr, ep)
|
||||
delete(m.peerAddrs, remoteID)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// Stop cancels all in-flight exchanges, closes the transport, and waits for the
|
||||
// exchange goroutines to exit.
|
||||
func (m *Manager) Stop() {
|
||||
m.rootCancel()
|
||||
m.wait.Wait()
|
||||
m.mu.Lock()
|
||||
t := m.transport
|
||||
m.transport = nil
|
||||
m.exchanges = make(map[RemoteID]*exchangeCtl)
|
||||
m.psks = make(map[RemoteID]PSK)
|
||||
m.mu.Unlock()
|
||||
if t != nil {
|
||||
if err := t.Close(); err != nil {
|
||||
m.logger.Warn("pqkem: closing data-path transport", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Signalling channel (host-driven; rides the host's negotiation) ----
|
||||
|
||||
// SignalOffer returns the KEM offer for the host to embed in its outgoing offer to
|
||||
// remoteID (bootstrap). It returns (nil, nil) when the local peer is not the
|
||||
// initiator. It is idempotent for an in-flight bootstrap: a repeat call returns the
|
||||
// same offer rather than starting a new exchange.
|
||||
func (m *Manager) SignalOffer(remoteID RemoteID) ([]byte, error) {
|
||||
if !m.IsInitiator(remoteID) {
|
||||
return nil, nil
|
||||
}
|
||||
m.mu.Lock()
|
||||
if ex := m.exchanges[remoteID]; ex != nil && ex.viaSignal && ex.state == stateAwaitingAnswer {
|
||||
last := ex.lastSent
|
||||
m.mu.Unlock()
|
||||
return last, nil
|
||||
}
|
||||
m.mu.Unlock()
|
||||
// bootstrap offer acknowledges nothing (zero AckID).
|
||||
return m.startExchange(remoteID, true, ExchangeID{})
|
||||
}
|
||||
|
||||
// SignalOnOffer processes a KEM offer the host extracted from an incoming offer and
|
||||
// returns the KEM answer for the host to embed in its outgoing answer.
|
||||
func (m *Manager) SignalOnOffer(remoteID RemoteID, offer []byte) ([]byte, error) {
|
||||
typ, msg, err := Decode(offer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode signal offer from %s: %w", remoteID, err)
|
||||
}
|
||||
if typ != MsgOffer {
|
||||
return nil, fmt.Errorf("expected offer from %s, got type %d", remoteID, typ)
|
||||
}
|
||||
return m.processOffer(remoteID, msg.(*OfferMsg))
|
||||
}
|
||||
|
||||
// SignalOnAnswer processes a KEM answer the host extracted from an incoming answer.
|
||||
// There is no reply: the next offer (over the data path) acknowledges this exchange.
|
||||
func (m *Manager) SignalOnAnswer(remoteID RemoteID, answer []byte) error {
|
||||
typ, msg, err := Decode(answer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode signal answer from %s: %w", remoteID, err)
|
||||
}
|
||||
if typ != MsgAnswer {
|
||||
return fmt.Errorf("expected answer from %s, got type %d", remoteID, typ)
|
||||
}
|
||||
return m.processAnswer(remoteID, msg.(*AnswerMsg))
|
||||
}
|
||||
|
||||
// ---- Data path ----
|
||||
|
||||
// onDataPathInbound is the transport's inbound handler: it reverse-resolves the
|
||||
// source endpoint to a peer and dispatches. Unknown sources are dropped.
|
||||
func (m *Manager) onDataPathInbound(src netip.AddrPort, msg []byte) {
|
||||
m.mu.Lock()
|
||||
remoteID, ok := m.peersByAddr[src]
|
||||
m.mu.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := m.OnDataPathMessage(remoteID, msg); err != nil {
|
||||
m.trace("pqkem: inbound", "peer", remoteID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// OnDataPathMessage handles a KEM message received over the data path from remoteID
|
||||
// and pushes any reply back over the data path.
|
||||
func (m *Manager) OnDataPathMessage(remoteID RemoteID, raw []byte) error {
|
||||
typ, msg, err := Decode(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode data-path msg from %s: %w", remoteID, err)
|
||||
}
|
||||
switch typ {
|
||||
case MsgOffer:
|
||||
answer, err := m.processOffer(remoteID, msg.(*OfferMsg))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if answer == nil {
|
||||
return nil
|
||||
}
|
||||
return m.pushDataPath(remoteID, answer)
|
||||
case MsgAnswer:
|
||||
return m.processAnswer(remoteID, msg.(*AnswerMsg))
|
||||
default:
|
||||
return fmt.Errorf("unhandled data-path message type %d from %s", typ, remoteID)
|
||||
}
|
||||
}
|
||||
|
||||
// OnDataPathRekeyed notifies that the peer's data path is up and freshly keyed with
|
||||
// the latest PSK (fired on first establishment AND every rekey). If we are the
|
||||
// initiator that just derived a PSK, it chains the next exchange: a fresh offer over
|
||||
// the data path that acknowledges the just-completed one (its arrival under the new
|
||||
// key proves to the responder that the key works).
|
||||
// OnDataPathRekeyed clocks the next chained PSK rotation on a fresh data-path rekey.
|
||||
// sinceActivity is how long ago the peer last exchanged real user data; when it
|
||||
// exceeds rotationActivityWindow the tunnel is treated as idle and rotation is
|
||||
// skipped — an idle tunnel has nothing to protect, and rotating would emit data-path
|
||||
// traffic that keeps the peer artificially active (see conn.onWGCheckSuccess).
|
||||
func (m *Manager) OnDataPathRekeyed(remoteID RemoteID, sinceActivity time.Duration) {
|
||||
if sinceActivity >= rotationActivityWindow {
|
||||
m.trace("pqkem: peer idle, skipping data-path rotation", "peer", remoteID, "since_activity", sinceActivity)
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
ex := m.exchanges[remoteID]
|
||||
chain := ex != nil && ex.state == stateAwaitingRekey
|
||||
var ackID ExchangeID
|
||||
if chain {
|
||||
ackID = ex.id
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
m.trace("pqkem: data-path rekey signal", "peer", remoteID, "chaining", chain)
|
||||
if !chain {
|
||||
return
|
||||
}
|
||||
offer, err := m.startExchange(remoteID, false, ackID)
|
||||
if err != nil {
|
||||
m.logger.Error("pqkem: chain offer failed to start", "peer", remoteID, "err", err)
|
||||
return
|
||||
}
|
||||
if err := m.pushDataPath(remoteID, offer); err != nil {
|
||||
m.logger.Warn("pqkem: send chain offer failed", "peer", remoteID, "err", err)
|
||||
return
|
||||
}
|
||||
m.trace("pqkem: chain offer sent over data path", "peer", remoteID)
|
||||
}
|
||||
|
||||
// OnDataPathDown notifies that the peer's data path went down. Rotations resume once
|
||||
// the host re-bootstraps over signalling on reconnect; in-flight data-path sends will
|
||||
// simply fail until then. Reserved as an explicit hook.
|
||||
func (m *Manager) OnDataPathDown(remoteID RemoteID) {}
|
||||
|
||||
// ---- internals ----
|
||||
|
||||
// pushDataPath resolves the peer's endpoint and sends over the data-path transport,
|
||||
// erroring if the peer is unknown or no transport is set.
|
||||
func (m *Manager) pushDataPath(remoteID RemoteID, msg []byte) error {
|
||||
m.mu.Lock()
|
||||
ep, ok := m.peerAddrs[remoteID]
|
||||
t := m.transport
|
||||
m.mu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("no data-path endpoint for peer %s", remoteID)
|
||||
}
|
||||
if t == nil {
|
||||
return fmt.Errorf("no data-path transport")
|
||||
}
|
||||
return t.Send(ep, msg)
|
||||
}
|
||||
|
||||
func (m *Manager) binding(remoteID RemoteID) Binding {
|
||||
return Binding{LocalID: []byte(m.localID), RemoteID: []byte(remoteID)}
|
||||
}
|
||||
|
||||
func newExchangeID() (ExchangeID, error) {
|
||||
var id ExchangeID
|
||||
if _, err := rand.Read(id[:]); err != nil {
|
||||
return ExchangeID{}, fmt.Errorf("generate exchange id: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
package pqkem
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// netSwitch is an in-memory UDP fabric: transports register their endpoint and get
|
||||
// datagrams delivered to their inbound handler.
|
||||
type netSwitch struct {
|
||||
mu sync.Mutex
|
||||
h map[netip.AddrPort]func(netip.AddrPort, []byte)
|
||||
}
|
||||
|
||||
func newSwitch() *netSwitch {
|
||||
return &netSwitch{h: map[netip.AddrPort]func(netip.AddrPort, []byte){}}
|
||||
}
|
||||
|
||||
func (s *netSwitch) register(ep netip.AddrPort, fn func(netip.AddrPort, []byte)) {
|
||||
s.mu.Lock()
|
||||
s.h[ep] = fn
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *netSwitch) deliver(dst, src netip.AddrPort, msg []byte) error {
|
||||
s.mu.Lock()
|
||||
fn := s.h[dst]
|
||||
s.mu.Unlock()
|
||||
if fn == nil {
|
||||
return fmt.Errorf("no route to %s", dst)
|
||||
}
|
||||
fn(src, msg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// loopback is an endpoint-based pqkem.Transport over a netSwitch, with a switchable
|
||||
// drop flag.
|
||||
type loopback struct {
|
||||
ep netip.AddrPort
|
||||
sw *netSwitch
|
||||
drop atomic.Bool
|
||||
}
|
||||
|
||||
func (l *loopback) Send(dst netip.AddrPort, msg []byte) error {
|
||||
if l.drop.Load() {
|
||||
return nil
|
||||
}
|
||||
return l.sw.deliver(dst, l.ep, append([]byte(nil), msg...))
|
||||
}
|
||||
|
||||
func (l *loopback) LocalPort() int { return int(l.ep.Port()) }
|
||||
func (l *loopback) Run(onInbound func(netip.AddrPort, []byte)) { l.sw.register(l.ep, onInbound) }
|
||||
func (l *loopback) Close() error { return nil }
|
||||
|
||||
type fakeWG struct {
|
||||
mu sync.Mutex
|
||||
psks map[RemoteID]PSK
|
||||
failed []RemoteID
|
||||
}
|
||||
|
||||
func newFakeWG() *fakeWG { return &fakeWG{psks: map[RemoteID]PSK{}} }
|
||||
|
||||
func (f *fakeWG) OnNewPSKReady(remoteID RemoteID, psk PSK) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.psks[remoteID] = psk
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWG) OnRekeyFailed(remoteID RemoteID) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.failed = append(f.failed, remoteID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWG) psk(peer RemoteID) PSK {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.psks[peer]
|
||||
}
|
||||
|
||||
var (
|
||||
epA = netip.MustParseAddrPort("100.64.0.1:51833")
|
||||
epB = netip.MustParseAddrPort("100.64.0.2:51833")
|
||||
)
|
||||
|
||||
// pair builds two wired managers (B is the initiator, "bbbb" > "aaaa") sharing a
|
||||
// netSwitch, with each peer's data-path endpoint registered. lbB is B's loopback
|
||||
// (for toggling drop).
|
||||
func pair(t *testing.T) (dA, dB *Manager, wgA, wgB *fakeWG, lbB *loopback) {
|
||||
t.Helper()
|
||||
sw := newSwitch()
|
||||
wgA = newFakeWG()
|
||||
wgB = newFakeWG()
|
||||
dA = NewManager("aaaa", wgA, nil)
|
||||
dB = NewManager("bbbb", wgB, nil)
|
||||
dA.Start(&loopback{ep: epA, sw: sw})
|
||||
lbB = &loopback{ep: epB, sw: sw}
|
||||
dB.Start(lbB)
|
||||
dA.AddPeer("bbbb", epB)
|
||||
dB.AddPeer("aaaa", epA)
|
||||
return dA, dB, wgA, wgB, lbB
|
||||
}
|
||||
|
||||
// bootstrap runs the signalling offer/answer (the test plays the host carrying bytes).
|
||||
func bootstrap(t *testing.T, dA, dB *Manager) {
|
||||
t.Helper()
|
||||
offer, err := dB.SignalOffer("aaaa")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, offer)
|
||||
answer, err := dA.SignalOnOffer("bbbb", offer)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, answer)
|
||||
require.NoError(t, dB.SignalOnAnswer("aaaa", answer))
|
||||
}
|
||||
|
||||
func TestManager_BootstrapDerivesSamePSK(t *testing.T) {
|
||||
dA, dB, wgA, wgB, _ := pair(t)
|
||||
defer dA.Stop()
|
||||
defer dB.Stop()
|
||||
|
||||
bootstrap(t, dA, dB)
|
||||
|
||||
pskA := wgA.psk("bbbb")
|
||||
pskB := wgB.psk("aaaa")
|
||||
require.NotEqual(t, PSK{}, pskA)
|
||||
require.Equal(t, pskB, pskA, "both sides derive the same PSK from the bootstrap exchange")
|
||||
}
|
||||
|
||||
func TestManager_ChainRotatesAndAcks(t *testing.T) {
|
||||
dA, dB, wgA, wgB, _ := pair(t)
|
||||
defer dA.Stop()
|
||||
defer dB.Stop()
|
||||
|
||||
bootstrap(t, dA, dB)
|
||||
psk1 := wgB.psk("aaaa")
|
||||
|
||||
// Data path up: B (initiator) chains the next offer over the data path, which
|
||||
// rotates both to a fresh PSK and acknowledges A.
|
||||
dA.OnDataPathRekeyed("bbbb", 0)
|
||||
dB.OnDataPathRekeyed("aaaa", 0)
|
||||
|
||||
psk2A := wgA.psk("bbbb")
|
||||
psk2B := wgB.psk("aaaa")
|
||||
require.Equal(t, psk2B, psk2A, "both sides converge on the rotated PSK")
|
||||
require.NotEqual(t, psk1, psk2B, "the chain rotated to a new PSK")
|
||||
}
|
||||
|
||||
func TestManager_RotationSkippedWhenIdle(t *testing.T) {
|
||||
dA, dB, wgA, wgB, _ := pair(t)
|
||||
defer dA.Stop()
|
||||
defer dB.Stop()
|
||||
|
||||
bootstrap(t, dA, dB)
|
||||
psk1 := wgB.psk("aaaa")
|
||||
require.NotEqual(t, PSK{}, psk1)
|
||||
|
||||
// Idle: the peer's last real-data activity is older than the window, so a rekey
|
||||
// must NOT clock a rotation.
|
||||
dA.OnDataPathRekeyed("bbbb", rotationActivityWindow)
|
||||
dB.OnDataPathRekeyed("aaaa", rotationActivityWindow)
|
||||
require.Equal(t, psk1, wgB.psk("aaaa"), "idle peer must not rotate the PSK")
|
||||
require.Equal(t, psk1, wgA.psk("bbbb"), "idle peer must not rotate the PSK")
|
||||
|
||||
// Active: activity within the window clocks the rotation as usual.
|
||||
dA.OnDataPathRekeyed("bbbb", rotationActivityWindow-1)
|
||||
dB.OnDataPathRekeyed("aaaa", rotationActivityWindow-1)
|
||||
psk2 := wgB.psk("aaaa")
|
||||
require.NotEqual(t, psk1, psk2, "recent activity must clock a rotation")
|
||||
require.Equal(t, psk2, wgA.psk("bbbb"), "both sides converge on the rotated PSK")
|
||||
}
|
||||
|
||||
func TestManager_NonInitiatorReturnsNoOffer(t *testing.T) {
|
||||
dA := NewManager("aaaa", newFakeWG(), nil)
|
||||
defer dA.Stop()
|
||||
|
||||
offer, err := dA.SignalOffer("bbbb") // not the initiator vs "bbbb"
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, offer)
|
||||
}
|
||||
|
||||
func TestManager_StopIsIdempotent(t *testing.T) {
|
||||
dA := NewManager("aaaa", newFakeWG(), nil)
|
||||
dA.Start(&loopback{ep: epA, sw: newSwitch()})
|
||||
dA.Stop()
|
||||
dA.Stop() // must not panic or hang
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package pqkem
|
||||
|
||||
import (
|
||||
"crypto/mlkem"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Wire framing for the PQ-KEM exchange. Messages are self-contained, versioned,
|
||||
// transport-agnostic byte blobs: the same bytes ride the signalling channel
|
||||
// (initial bootstrap) or a data-tunnel packet (rekey). The library only ever sees
|
||||
// opaque []byte at the transport seam.
|
||||
//
|
||||
// Layout (all messages): [type:1][version:1][exchangeID:16][payload...]
|
||||
//
|
||||
// There is no confirm message: an exchange is acknowledged by the NEXT offer, which
|
||||
// carries the acked exchange's id (see OfferMsg.AckID) and — riding the data path
|
||||
// under the freshly adopted key — proves that key works.
|
||||
|
||||
const (
|
||||
// ProtocolVersion is bumped on any wire-incompatible change; a peer rejects
|
||||
// messages it does not understand rather than misparsing them.
|
||||
ProtocolVersion uint8 = 1
|
||||
|
||||
// ExchangeIDSize identifies one exchange so answers/acks correlate and stale
|
||||
// messages are dropped.
|
||||
ExchangeIDSize = 16
|
||||
|
||||
headerSize = 1 + 1 + ExchangeIDSize
|
||||
)
|
||||
|
||||
// MsgType tags the two message kinds of the exchange.
|
||||
type MsgType uint8
|
||||
|
||||
const (
|
||||
MsgOffer MsgType = iota + 1
|
||||
MsgAnswer
|
||||
)
|
||||
|
||||
// ExchangeID is the per-exchange correlator. The zero value means "none" (an offer
|
||||
// that acknowledges nothing, i.e. the first exchange of a connection).
|
||||
type ExchangeID [ExchangeIDSize]byte
|
||||
|
||||
// OfferMsg carries the initiator's public material (X25519 pub ‖ ML-KEM encap key)
|
||||
// and AckID, the id of the previous exchange this offer acknowledges (zero if none).
|
||||
type OfferMsg struct {
|
||||
ExchangeID ExchangeID
|
||||
AckID ExchangeID
|
||||
// KEMOffer is the raw Initiator.Offer() blob (OfferSize bytes).
|
||||
KEMOffer []byte
|
||||
}
|
||||
|
||||
// AnswerMsg carries the responder's reply (ML-KEM ciphertext ‖ X25519 pub) for the
|
||||
// round identified by ExchangeID.
|
||||
type AnswerMsg struct {
|
||||
ExchangeID ExchangeID
|
||||
// KEMAnswer is the raw Respond() answer blob (AnswerSize bytes).
|
||||
KEMAnswer []byte
|
||||
}
|
||||
|
||||
// Encode serialises the offer with its framed header (payload = AckID ‖ KEMOffer).
|
||||
func (m *OfferMsg) Encode() ([]byte, error) {
|
||||
if len(m.KEMOffer) != OfferSize {
|
||||
return nil, fmt.Errorf("offer payload: got %d, want %d", len(m.KEMOffer), OfferSize)
|
||||
}
|
||||
payload := make([]byte, 0, ExchangeIDSize+OfferSize)
|
||||
payload = append(payload, m.AckID[:]...)
|
||||
payload = append(payload, m.KEMOffer...)
|
||||
return frame(MsgOffer, m.ExchangeID, payload), nil
|
||||
}
|
||||
|
||||
// Encode serialises the answer with its framed header.
|
||||
func (m *AnswerMsg) Encode() ([]byte, error) {
|
||||
if len(m.KEMAnswer) != AnswerSize {
|
||||
return nil, fmt.Errorf("answer payload: got %d, want %d", len(m.KEMAnswer), AnswerSize)
|
||||
}
|
||||
return frame(MsgAnswer, m.ExchangeID, m.KEMAnswer), nil
|
||||
}
|
||||
|
||||
// Decode parses a framed message into one of *OfferMsg / *AnswerMsg.
|
||||
func Decode(buf []byte) (MsgType, any, error) {
|
||||
if len(buf) < headerSize {
|
||||
return 0, nil, fmt.Errorf("message too short: %d bytes", len(buf))
|
||||
}
|
||||
typ := MsgType(buf[0])
|
||||
if ver := buf[1]; ver != ProtocolVersion {
|
||||
return typ, nil, fmt.Errorf("unsupported protocol version %d (want %d)", ver, ProtocolVersion)
|
||||
}
|
||||
|
||||
var id ExchangeID
|
||||
copy(id[:], buf[2:headerSize])
|
||||
payload := buf[headerSize:]
|
||||
|
||||
switch typ {
|
||||
case MsgOffer:
|
||||
if len(payload) != ExchangeIDSize+OfferSize {
|
||||
return typ, nil, fmt.Errorf("offer payload: got %d, want %d", len(payload), ExchangeIDSize+OfferSize)
|
||||
}
|
||||
var ack ExchangeID
|
||||
copy(ack[:], payload[:ExchangeIDSize])
|
||||
return typ, &OfferMsg{ExchangeID: id, AckID: ack, KEMOffer: payload[ExchangeIDSize:]}, nil
|
||||
case MsgAnswer:
|
||||
if len(payload) != AnswerSize {
|
||||
return typ, nil, fmt.Errorf("answer payload: got %d, want %d", len(payload), AnswerSize)
|
||||
}
|
||||
return typ, &AnswerMsg{ExchangeID: id, KEMAnswer: payload}, nil
|
||||
default:
|
||||
return typ, nil, fmt.Errorf("unknown message type %d", typ)
|
||||
}
|
||||
}
|
||||
|
||||
func frame(typ MsgType, id ExchangeID, payload []byte) []byte {
|
||||
buf := make([]byte, headerSize+len(payload))
|
||||
buf[0] = byte(typ)
|
||||
buf[1] = ProtocolVersion
|
||||
copy(buf[2:], id[:])
|
||||
copy(buf[headerSize:], payload)
|
||||
return buf
|
||||
}
|
||||
|
||||
// compile-time assurance the KEM blob sizes referenced here stay in sync with kem.go.
|
||||
var _ = [1]struct{}{}[OfferSize-(32+mlkem.EncapsulationKeySize768)]
|
||||
@@ -1,57 +0,0 @@
|
||||
package pqkem
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMessageRoundTrip(t *testing.T) {
|
||||
init, err := NewInitiator()
|
||||
require.NoError(t, err)
|
||||
answer, _, err := Respond(init.Offer(), Binding{LocalID: wgB, RemoteID: wgA})
|
||||
require.NoError(t, err)
|
||||
|
||||
id := ExchangeID{1, 2, 3, 4}
|
||||
ack := ExchangeID{9, 9, 9}
|
||||
|
||||
offBytes, err := (&OfferMsg{ExchangeID: id, AckID: ack, KEMOffer: init.Offer()}).Encode()
|
||||
require.NoError(t, err)
|
||||
typ, decoded, err := Decode(offBytes)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, MsgOffer, typ)
|
||||
require.Equal(t, id, decoded.(*OfferMsg).ExchangeID)
|
||||
require.Equal(t, ack, decoded.(*OfferMsg).AckID)
|
||||
require.Equal(t, init.Offer(), decoded.(*OfferMsg).KEMOffer)
|
||||
|
||||
ansBytes, err := (&AnswerMsg{ExchangeID: id, KEMAnswer: answer}).Encode()
|
||||
require.NoError(t, err)
|
||||
typ, decoded, err = Decode(ansBytes)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, MsgAnswer, typ)
|
||||
require.Equal(t, answer, decoded.(*AnswerMsg).KEMAnswer)
|
||||
}
|
||||
|
||||
func TestDecodeRejects(t *testing.T) {
|
||||
// too short
|
||||
_, _, err := Decode([]byte{1, 1})
|
||||
require.Error(t, err)
|
||||
|
||||
// wrong version
|
||||
bad := make([]byte, headerSize+ExchangeIDSize+OfferSize)
|
||||
bad[0] = byte(MsgOffer)
|
||||
bad[1] = ProtocolVersion + 1
|
||||
_, _, err = Decode(bad)
|
||||
require.Error(t, err)
|
||||
|
||||
// unknown type
|
||||
bad2 := make([]byte, headerSize)
|
||||
bad2[0] = 99
|
||||
bad2[1] = ProtocolVersion
|
||||
_, _, err = Decode(bad2)
|
||||
require.Error(t, err)
|
||||
|
||||
// offer with wrong payload size
|
||||
_, err = (&OfferMsg{KEMOffer: []byte{1, 2, 3}}).Encode()
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/pqkem"
|
||||
)
|
||||
|
||||
// pqPresharedKeySetter is the subset of the WireGuard interface the ML-KEM callback
|
||||
// needs: programming a peer's preshared key. *iface.WGIface satisfies it.
|
||||
type pqPresharedKeySetter interface {
|
||||
SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error
|
||||
}
|
||||
|
||||
// pqCallbackHandler programs the derived PQ PSK onto the WireGuard peer. It is the
|
||||
// engine-side implementation of pqkem.CallbackHandler.
|
||||
type pqCallbackHandler struct {
|
||||
wg pqPresharedKeySetter
|
||||
// reoffer re-bootstraps the KEM over Signal for a peer (a fresh signalling offer)
|
||||
// to recover from a persistent data-path rekey failure. Nil disables recovery.
|
||||
reoffer func(remoteKey string)
|
||||
}
|
||||
|
||||
// OnNewPSKReady programs the freshly derived PSK for the peer (updateOnly: a no-op
|
||||
// if the peer is not present, mirroring Rosenpass). remoteID is the peer's WG pubkey.
|
||||
func (h pqCallbackHandler) OnNewPSKReady(remoteID pqkem.RemoteID, psk pqkem.PSK) error {
|
||||
// updateOnly: applies to an already-configured peer (rotation). At bootstrap the
|
||||
// peer is not configured yet, so this is a no-op there and the PSK is instead
|
||||
// pulled at peer-config time (pqHandshaker.PSK / conn.presharedKey).
|
||||
log.Tracef("pqkem: programming PSK for peer %s", remoteID)
|
||||
return h.wg.SetPresharedKey(string(remoteID), wgtypes.Key(psk), true)
|
||||
}
|
||||
|
||||
// OnRekeyFailed reports a failed PQ (re)key convergence and re-bootstraps the KEM over
|
||||
// Signal to recover: a fresh signalling offer starts a new exchange that overwrites the
|
||||
// stalled PSK on both sides, resyncing after a persistent data-path desync. The tunnel
|
||||
// stays up on the previous PSK meanwhile (the Signal channel is independent of the
|
||||
// broken data path).
|
||||
func (h pqCallbackHandler) OnRekeyFailed(remoteID pqkem.RemoteID) error {
|
||||
log.Warnf("pqkem: post-quantum rekey failed for peer %s, re-bootstrapping over signal", remoteID)
|
||||
if h.reoffer != nil {
|
||||
h.reoffer(string(remoteID))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pqHandshaker adapts the pqkem manager to peer.PQHandshaker (string peer keys),
|
||||
// wiring the host's signalling offers/answers to the KEM exchange.
|
||||
type pqHandshaker struct {
|
||||
mgr *pqkem.Manager
|
||||
}
|
||||
|
||||
func (p pqHandshaker) OfferPayload(remoteKey string) ([]byte, int) {
|
||||
payload, err := p.mgr.SignalOffer(pqkem.RemoteID(remoteKey))
|
||||
if err != nil {
|
||||
log.Warnf("pqkem: build offer for %s: %v", remoteKey, err)
|
||||
}
|
||||
return payload, p.mgr.LocalPort()
|
||||
}
|
||||
|
||||
func (p pqHandshaker) AnswerPayload(remoteKey string, recvOffer []byte) ([]byte, int) {
|
||||
if len(recvOffer) == 0 {
|
||||
return nil, p.mgr.LocalPort()
|
||||
}
|
||||
payload, err := p.mgr.SignalOnOffer(pqkem.RemoteID(remoteKey), recvOffer)
|
||||
if err != nil {
|
||||
log.Warnf("pqkem: build answer for %s: %v", remoteKey, err)
|
||||
}
|
||||
return payload, p.mgr.LocalPort()
|
||||
}
|
||||
|
||||
func (p pqHandshaker) OnAnswer(remoteKey string, recvAnswer []byte) {
|
||||
if len(recvAnswer) == 0 {
|
||||
return
|
||||
}
|
||||
if err := p.mgr.SignalOnAnswer(pqkem.RemoteID(remoteKey), recvAnswer); err != nil {
|
||||
log.Warnf("pqkem: process answer from %s: %v", remoteKey, err)
|
||||
}
|
||||
}
|
||||
|
||||
// PSK exposes the peer's derived PSK for the conn to program at WG peer-config time.
|
||||
func (p pqHandshaker) PSK(remoteKey string) (wgtypes.Key, bool) {
|
||||
psk, ok := p.mgr.PSK(pqkem.RemoteID(remoteKey))
|
||||
if !ok {
|
||||
return wgtypes.Key{}, false
|
||||
}
|
||||
return wgtypes.Key(psk), true
|
||||
}
|
||||
|
||||
// SetRemoteAddr registers the peer's data-path endpoint (overlay IP + pq UDP port)
|
||||
// learned from signalling. Sends only ever fire once the tunnel is up (clocked by
|
||||
// OnDataPathRekeyed), so registering here is safe even before connection-up.
|
||||
func (p pqHandshaker) SetRemoteAddr(remoteKey string, addr netip.AddrPort) {
|
||||
if !addr.IsValid() || addr.Port() == 0 {
|
||||
return
|
||||
}
|
||||
p.mgr.AddPeer(pqkem.RemoteID(remoteKey), addr)
|
||||
}
|
||||
|
||||
// OnDataPathRekeyed clocks the next chained PSK rotation on a fresh WG handshake.
|
||||
// sinceActivity is how long ago the peer last exchanged real user data; the manager
|
||||
// skips rotation for idle tunnels.
|
||||
func (p pqHandshaker) OnDataPathRekeyed(remoteKey string, sinceActivity time.Duration) {
|
||||
p.mgr.OnDataPathRekeyed(pqkem.RemoteID(remoteKey), sinceActivity)
|
||||
}
|
||||
|
||||
// OnDataPathDown signals the peer's tunnel went down.
|
||||
func (p pqHandshaker) OnDataPathDown(remoteKey string) {
|
||||
p.mgr.OnDataPathDown(pqkem.RemoteID(remoteKey))
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// DefaultPort is the preferred UDP port for the ML-KEM data-path service, bound on
|
||||
// the WG overlay IP. Since each client owns a distinct overlay IP, this port is
|
||||
// almost always free, so it need not be announced (peers assume it). A peer only
|
||||
// announces Body.mlkemPort when a collision forced it onto a different port.
|
||||
const DefaultPort = 51833
|
||||
|
||||
// pqTransport is the ML-KEM data-path transport: a dumb UDP socket bound on the WG
|
||||
// overlay IP. It implements pqkem.Transport — the manager owns the remoteID<->endpoint
|
||||
// routing and drives this socket's lifecycle (Run / Close).
|
||||
type pqTransport struct {
|
||||
conn *net.UDPConn
|
||||
port int
|
||||
}
|
||||
|
||||
// newPQTransport binds a UDP socket on the WG overlay IP, preferring DefaultPort and
|
||||
// falling back to an OS-assigned ephemeral port if it is in use. Call it after the WG
|
||||
// interface is up so the overlay IP is assigned; when the bound port is not
|
||||
// DefaultPort it must be announced to peers via Body.mlkemPort.
|
||||
func newPQTransport(overlayIP netip.Addr) (*pqTransport, error) {
|
||||
if !overlayIP.IsValid() {
|
||||
return nil, fmt.Errorf("invalid overlay IP for pqkem transport")
|
||||
}
|
||||
ip := net.IP(overlayIP.AsSlice())
|
||||
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: ip, Port: DefaultPort})
|
||||
if err != nil {
|
||||
log.Debugf("pqkem: default port %d unavailable on %s (%v), using an ephemeral port", DefaultPort, overlayIP, err)
|
||||
conn, err = net.ListenUDP("udp4", &net.UDPAddr{IP: ip, Port: 0})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bind pqkem udp on overlay %s: %w", overlayIP, err)
|
||||
}
|
||||
}
|
||||
return &pqTransport{conn: conn, port: conn.LocalAddr().(*net.UDPAddr).Port}, nil
|
||||
}
|
||||
|
||||
// Send implements pqkem.Transport.
|
||||
func (t *pqTransport) Send(endpoint netip.AddrPort, msg []byte) error {
|
||||
_, err := t.conn.WriteToUDPAddrPort(msg, endpoint)
|
||||
return err
|
||||
}
|
||||
|
||||
// LocalPort implements pqkem.Transport.
|
||||
func (t *pqTransport) LocalPort() int { return t.port }
|
||||
|
||||
// Run implements pqkem.Transport: the receive loop, delivering each datagram as
|
||||
// (source endpoint, msg). Exits when the socket is closed.
|
||||
func (t *pqTransport) Run(onInbound func(src netip.AddrPort, msg []byte)) {
|
||||
go func() {
|
||||
buf := make([]byte, 2048)
|
||||
for {
|
||||
n, src, err := t.conn.ReadFromUDPAddrPort(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
msg := make([]byte, n)
|
||||
copy(msg, buf[:n])
|
||||
onInbound(src, msg)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Close implements pqkem.Transport.
|
||||
func (t *pqTransport) Close() error { return t.conn.Close() }
|
||||
@@ -1,12 +0,0 @@
|
||||
//go:build ios
|
||||
|
||||
package NetBirdSDK
|
||||
|
||||
import "github.com/netbirdio/netbird/version"
|
||||
|
||||
// GoClientVersion returns the NetBird Go client version that was baked into
|
||||
// the framework at compile time via
|
||||
// -ldflags "-X github.com/netbirdio/netbird/version.version=<version>".
|
||||
func GoClientVersion() string {
|
||||
return version.NetbirdVersion()
|
||||
}
|
||||
@@ -9,220 +9,12 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/netbirdio/netbird/e2e/harness"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
// per1k is a model's published USD rates per 1k tokens. read is the prompt-cache read rate
|
||||
// (OpenAI: the cached-input discount rate); write is the cache-creation rate where one exists.
|
||||
type per1k struct{ in, out, read, write float64 }
|
||||
|
||||
// publishedPer1k hardcodes the vendors' PUBLISHED rates for the models the live matrix can drive,
|
||||
// keyed by the normalized model id the proxy stamps. Deliberately independent of the proxy's
|
||||
// pricing table so a wrong embedded rate or a broken normalization fails the run.
|
||||
var publishedPer1k = map[string]per1k{
|
||||
"gpt-4o-mini": {0.00015, 0.0006, 0.000075, 0},
|
||||
"gpt-4o": {0.0025, 0.01, 0.00125, 0},
|
||||
"claude-haiku-4-5": {0.001, 0.005, 0.0001, 0.00125},
|
||||
"claude-sonnet-4-5": {0.003, 0.015, 0.0003, 0.00375},
|
||||
"claude-sonnet-4-6": {0.003, 0.015, 0.0003, 0.00375},
|
||||
"kimi-k3": {0.003, 0.015, 0.0003, 0.003}, // no published write rate: bills at the input rate
|
||||
"anthropic.claude-haiku-4-5": {0.001, 0.005, 0.0001, 0.00125},
|
||||
"anthropic.claude-sonnet-4-5": {0.003, 0.015, 0.0003, 0.00375},
|
||||
"anthropic.claude-sonnet-4-6": {0.003, 0.015, 0.0003, 0.00375},
|
||||
}
|
||||
|
||||
// rawCostVerificationSQL is the operator-facing double-check, run straight against the management
|
||||
// sqlite store: recompute each usage row's expected total and cache cost from its own persisted
|
||||
// token buckets and hardcoded published rates. OpenAI counts cached tokens as a subset of input;
|
||||
// Anthropic-shape providers count cache buckets additively.
|
||||
const rawCostVerificationSQL = `
|
||||
WITH rates(model, in_rate, out_rate, read_rate, write_rate) AS (
|
||||
VALUES
|
||||
('gpt-4o-mini', 0.00015, 0.0006, 0.000075, 0.0),
|
||||
('gpt-4o', 0.0025, 0.01, 0.00125, 0.0),
|
||||
('claude-haiku-4-5', 0.001, 0.005, 0.0001, 0.00125),
|
||||
('claude-sonnet-4-5', 0.003, 0.015, 0.0003, 0.00375),
|
||||
('claude-sonnet-4-6', 0.003, 0.015, 0.0003, 0.00375),
|
||||
('kimi-k3', 0.003, 0.015, 0.0003, 0.003),
|
||||
('anthropic.claude-haiku-4-5', 0.001, 0.005, 0.0001, 0.00125),
|
||||
('anthropic.claude-sonnet-4-5', 0.003, 0.015, 0.0003, 0.00375),
|
||||
('anthropic.claude-sonnet-4-6', 0.003, 0.015, 0.0003, 0.00375)
|
||||
)
|
||||
SELECT
|
||||
u.provider,
|
||||
u.model,
|
||||
u.input_tokens,
|
||||
u.output_tokens,
|
||||
u.cached_input_tokens,
|
||||
u.cache_creation_tokens,
|
||||
u.input_cost_usd,
|
||||
u.cached_input_cost_usd,
|
||||
u.cache_creation_cost_usd,
|
||||
u.output_cost_usd,
|
||||
-- No cost_usd / cache_cost_usd columns are stored: both are derived from the
|
||||
-- four per-bucket columns above, exactly as the API renders them.
|
||||
(u.input_cost_usd + u.cached_input_cost_usd + u.cache_creation_cost_usd + u.output_cost_usd) AS cost_usd,
|
||||
(u.cached_input_cost_usd + u.cache_creation_cost_usd) AS cache_cost_usd,
|
||||
CASE WHEN u.provider = 'openai' THEN
|
||||
(u.input_tokens - MIN(u.cached_input_tokens, u.input_tokens))*r.in_rate/1000.0
|
||||
ELSE
|
||||
u.input_tokens*r.in_rate/1000.0
|
||||
END AS expected_input,
|
||||
CASE WHEN u.provider = 'openai' THEN
|
||||
MIN(u.cached_input_tokens, u.input_tokens)*r.read_rate/1000.0
|
||||
ELSE
|
||||
u.cached_input_tokens*r.read_rate/1000.0
|
||||
END AS expected_cached_input,
|
||||
CASE WHEN u.provider = 'openai' THEN
|
||||
0.0
|
||||
ELSE
|
||||
u.cache_creation_tokens*r.write_rate/1000.0
|
||||
END AS expected_cache_creation,
|
||||
u.output_tokens*r.out_rate/1000.0 AS expected_output,
|
||||
CASE WHEN u.provider = 'openai' THEN
|
||||
(u.input_tokens - MIN(u.cached_input_tokens, u.input_tokens))*r.in_rate/1000.0
|
||||
+ MIN(u.cached_input_tokens, u.input_tokens)*r.read_rate/1000.0
|
||||
+ u.output_tokens*r.out_rate/1000.0
|
||||
ELSE
|
||||
u.input_tokens*r.in_rate/1000.0 + u.cached_input_tokens*r.read_rate/1000.0
|
||||
+ u.cache_creation_tokens*r.write_rate/1000.0 + u.output_tokens*r.out_rate/1000.0
|
||||
END AS expected_total,
|
||||
CASE WHEN u.provider = 'openai' THEN
|
||||
MIN(u.cached_input_tokens, u.input_tokens)*r.read_rate/1000.0
|
||||
ELSE
|
||||
u.cached_input_tokens*r.read_rate/1000.0 + u.cache_creation_tokens*r.write_rate/1000.0
|
||||
END AS expected_cache
|
||||
FROM agent_network_request_usage u
|
||||
JOIN rates r ON r.model = u.model
|
||||
ORDER BY u.timestamp`
|
||||
|
||||
// verifyUsageRowsSQL re-checks every persisted usage row directly in the management sqlite store,
|
||||
// bypassing the API path — the same audit an operator can run on a production store.db.
|
||||
func verifyUsageRowsSQL(t *testing.T, srv *harness.Combined) {
|
||||
t.Helper()
|
||||
|
||||
dbPath, err := srv.SnapshotStoreDB(t.TempDir())
|
||||
require.NoError(t, err, "snapshot management sqlite store")
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
require.NoError(t, err, "open store snapshot")
|
||||
sqlDB, err := db.DB()
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = sqlDB.Close() }()
|
||||
|
||||
rows, err := db.Raw(rawCostVerificationSQL).Rows()
|
||||
require.NoError(t, err, "run raw cost verification query")
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
verified := 0
|
||||
for rows.Next() {
|
||||
var provider, model string
|
||||
var inTok, outTok, readTok, writeTok int64
|
||||
var inCost, cachedInCost, cacheCreateCost, outCost, cost, cacheCost float64
|
||||
var wantInput, wantCachedInput, wantCacheCreation, wantOutput, wantTotal, wantCache float64
|
||||
require.NoError(t, rows.Scan(&provider, &model, &inTok, &outTok, &readTok, &writeTok,
|
||||
&inCost, &cachedInCost, &cacheCreateCost, &outCost, &cost, &cacheCost,
|
||||
&wantInput, &wantCachedInput, &wantCacheCreation, &wantOutput, &wantTotal, &wantCache), "scan usage row")
|
||||
t.Logf("[sql] %s/%s: in=%d out=%d cache_read=%d cache_write=%d stored in/cached/create/out=$%.6f/$%.6f/$%.6f/$%.6f total=$%.6f cache=$%.6f expected total=$%.6f cache=$%.6f",
|
||||
provider, model, inTok, outTok, readTok, writeTok,
|
||||
inCost, cachedInCost, cacheCreateCost, outCost, cost, cacheCost, wantTotal, wantCache)
|
||||
assert.InDeltaf(t, wantInput, inCost, 1e-6, "stored input_cost_usd for %s/%s must match the published-rate recompute", provider, model)
|
||||
assert.InDeltaf(t, wantCachedInput, cachedInCost, 1e-6, "stored cached_input_cost_usd for %s/%s must match the published-rate recompute", provider, model)
|
||||
assert.InDeltaf(t, wantCacheCreation, cacheCreateCost, 1e-6, "stored cache_creation_cost_usd for %s/%s must match the published-rate recompute", provider, model)
|
||||
assert.InDeltaf(t, wantOutput, outCost, 1e-6, "stored output_cost_usd for %s/%s must match the published-rate recompute", provider, model)
|
||||
assert.InDeltaf(t, wantTotal, cost, 1e-6, "derived cost_usd for %s/%s must match the published-rate recompute", provider, model)
|
||||
assert.InDeltaf(t, wantCache, cacheCost, 1e-6, "derived cache_cost_usd for %s/%s must match the published-rate recompute", provider, model)
|
||||
assert.InDeltaf(t, inCost+cachedInCost+cacheCreateCost+outCost, cost, 1e-9,
|
||||
"stored buckets must sum to the derived cost_usd for %s/%s", provider, model)
|
||||
verified++
|
||||
}
|
||||
require.NoError(t, rows.Err(), "iterate usage rows")
|
||||
require.Positive(t, verified, "raw SQL check must cover at least one usage row")
|
||||
t.Logf("[sql] verified %d usage rows in store.db against published rates", verified)
|
||||
|
||||
gwRows, err := db.Raw(`SELECT model,
|
||||
(input_cost_usd + cached_input_cost_usd + cache_creation_cost_usd + output_cost_usd) AS cost_usd
|
||||
FROM agent_network_request_usage WHERE model LIKE '%/%'`).Rows()
|
||||
require.NoError(t, err, "query gateway-prefixed usage rows")
|
||||
defer func() { _ = gwRows.Close() }()
|
||||
for gwRows.Next() {
|
||||
var model string
|
||||
var cost float64
|
||||
require.NoError(t, gwRows.Scan(&model, &cost), "scan gateway usage row")
|
||||
t.Logf("[sql] gateway %s: stored=$%.6f (must be 0 — deliberately unpriced)", model, cost)
|
||||
assert.Zerof(t, cost, "gateway-prefixed model %q must store cost 0, never a guessed rate", model)
|
||||
}
|
||||
require.NoError(t, gwRows.Err(), "iterate gateway usage rows")
|
||||
}
|
||||
|
||||
// validateAccessLogCost recomputes a live access-log row's expected total and cache cost from the
|
||||
// published per-1k rates and the row's persisted token buckets, and asserts both stored values.
|
||||
// Gateway-prefixed model ids the proxy deliberately does not price must store cost 0.
|
||||
func validateAccessLogCost(t *testing.T, pc providerCase, row api.AgentNetworkAccessLog) {
|
||||
t.Helper()
|
||||
model := catalogModel(pc)
|
||||
provider := ""
|
||||
if row.Provider != nil {
|
||||
provider = *row.Provider
|
||||
}
|
||||
t.Logf("[cost] %s: provider=%s model=%s in=%d out=%d total=%d cache_read=%d cache_write=%d cost=$%.6f cache_cost=$%.6f",
|
||||
pc.name, provider, model, row.InputTokens, row.OutputTokens, row.TotalTokens,
|
||||
row.CachedInputTokens, row.CacheCreationTokens, row.CostUsd, row.CacheCostUsd)
|
||||
|
||||
rates, known := publishedPer1k[model]
|
||||
if !known {
|
||||
if strings.Contains(model, "/") {
|
||||
assert.Zerof(t, row.CostUsd, "gateway-prefixed model %q is not priced so the cost meter must skip (cost 0)", model)
|
||||
return
|
||||
}
|
||||
t.Logf("[cost] %s: no published rate on file for model %q (env-overridden?); skipping cost validation", pc.name, model)
|
||||
return
|
||||
}
|
||||
|
||||
// input_tokens may legitimately be 0: Moonshot/Kimi reports fully cached prompts under the cache
|
||||
// buckets only. Output and total must always be present on a priced row.
|
||||
require.Positive(t, row.OutputTokens, "priced row must carry output tokens")
|
||||
require.Positive(t, row.TotalTokens, "priced row must carry total tokens")
|
||||
|
||||
var wantInput, wantCachedInput, wantCacheCreation float64
|
||||
if provider == "openai" {
|
||||
cached := min(row.CachedInputTokens, row.InputTokens) // cached is a subset of input
|
||||
wantInput = float64(row.InputTokens-cached) / 1000 * rates.in
|
||||
wantCachedInput = float64(cached) / 1000 * rates.read
|
||||
// OpenAI has no cache-write bucket; wantCacheCreation stays 0.
|
||||
} else {
|
||||
// Anthropic / Bedrock shape: cache buckets are additive to input_tokens.
|
||||
wantInput = float64(row.InputTokens) / 1000 * rates.in
|
||||
wantCachedInput = float64(row.CachedInputTokens) / 1000 * rates.read
|
||||
wantCacheCreation = float64(row.CacheCreationTokens) / 1000 * rates.write
|
||||
}
|
||||
wantOutput := float64(row.OutputTokens) / 1000 * rates.out
|
||||
wantCache := wantCachedInput + wantCacheCreation
|
||||
wantTotal := wantInput + wantCache + wantOutput
|
||||
|
||||
t.Logf("[cost] %s: expecting input=$%.6f cached_input=$%.6f cache_creation=$%.6f output=$%.6f total=$%.6f cache=$%.6f from published rates",
|
||||
pc.name, wantInput, wantCachedInput, wantCacheCreation, wantOutput, wantTotal, wantCache)
|
||||
assert.InDeltaf(t, wantInput, row.InputCostUsd, 1e-6, "stored input_cost_usd for %s (%s)", pc.name, model)
|
||||
assert.InDeltaf(t, wantCachedInput, row.CachedInputCostUsd, 1e-6, "stored cached_input_cost_usd for %s (%s)", pc.name, model)
|
||||
assert.InDeltaf(t, wantCacheCreation, row.CacheCreationCostUsd, 1e-6, "stored cache_creation_cost_usd for %s (%s)", pc.name, model)
|
||||
assert.InDeltaf(t, wantOutput, row.OutputCostUsd, 1e-6, "stored output_cost_usd for %s (%s)", pc.name, model)
|
||||
assert.InDeltaf(t, wantTotal, row.CostUsd, 1e-6, "derived cost_usd for %s (%s)", pc.name, model)
|
||||
assert.InDeltaf(t, wantCache, row.CacheCostUsd, 1e-6, "derived cache_cost_usd for %s (%s)", pc.name, model)
|
||||
|
||||
// The aggregates must be exactly the sum of the stored components, not an
|
||||
// independently-computed figure that could drift from the breakdown.
|
||||
assert.InDeltaf(t, row.InputCostUsd+row.CachedInputCostUsd+row.CacheCreationCostUsd+row.OutputCostUsd,
|
||||
row.CostUsd, 1e-9, "stored buckets must sum to the derived cost_usd for %s (%s)", pc.name, model)
|
||||
assert.InDeltaf(t, row.CachedInputCostUsd+row.CacheCreationCostUsd,
|
||||
row.CacheCostUsd, 1e-9, "stored cache buckets must sum to the derived cache_cost_usd for %s (%s)", pc.name, model)
|
||||
}
|
||||
|
||||
// providerCase is one entry in the live provider matrix. The same scenario runs
|
||||
// for every available provider; availability is keyed off env vars so the suite
|
||||
// covers whatever credentials are present (source ~/.llm-keys locally / set the
|
||||
@@ -324,12 +116,12 @@ func availableProviders() []providerCase {
|
||||
if region == "" {
|
||||
region = "eu-central-1"
|
||||
}
|
||||
// A valid Bedrock inference-profile id, overridable per account (AWS_BEDROCK_MODEL, also the
|
||||
// workflow's bedrock_model dispatch input). `global.` profiles work from any region. Defaults to
|
||||
// Sonnet 4.6, whose id convention dropped the -YYYYMMDD-v1:0 suffix that Haiku 4.5 still carries.
|
||||
// A valid Bedrock inference-profile id (region prefix + date + version),
|
||||
// overridable per account. `global.` profiles can be invoked from any
|
||||
// region; set AWS_BEDROCK_MODEL to match the enabled profile for the token.
|
||||
model := os.Getenv("AWS_BEDROCK_MODEL")
|
||||
if model == "" {
|
||||
model = "global.anthropic.claude-sonnet-4-6"
|
||||
model = "global.anthropic.claude-haiku-4-5-20251001-v1:0"
|
||||
}
|
||||
ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: model, kind: harness.WireBedrock})
|
||||
}
|
||||
@@ -465,10 +257,6 @@ func TestProvidersMatrix(t *testing.T) {
|
||||
// session id and confirm the marker propagated end-to-end.
|
||||
sessionID := "e2e-session-" + pc.name
|
||||
|
||||
// A long-form prompt so completions carry realistic token counts for cost validation;
|
||||
// max_tokens in the harness bodies (2048) lets the full answer through.
|
||||
const matrixPrompt = "explain GitHub workflow in 1000 words"
|
||||
|
||||
// Retry briefly to absorb tunnel/DNS jitter on the first call.
|
||||
var code int
|
||||
var body string
|
||||
@@ -479,11 +267,11 @@ func TestProvidersMatrix(t *testing.T) {
|
||||
var cerr error
|
||||
switch pc.kind {
|
||||
case harness.WireVertex:
|
||||
c, b, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, pc.project, pc.region, pc.model, matrixPrompt, sessionID)
|
||||
c, b, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, pc.project, pc.region, pc.model, "Reply with exactly: pong", sessionID)
|
||||
case harness.WireBedrock:
|
||||
c, b, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, pc.model, matrixPrompt, sessionID)
|
||||
c, b, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, pc.model, "Reply with exactly: pong", sessionID)
|
||||
default:
|
||||
c, b, cerr = cl.ChatPrefixed(ctx, settings.Endpoint, proxyIP, pc.pathPrefix, pc.kind, pc.model, matrixPrompt, sessionID)
|
||||
c, b, cerr = cl.ChatPrefixed(ctx, settings.Endpoint, proxyIP, pc.pathPrefix, pc.kind, pc.model, "Reply with exactly: pong", sessionID)
|
||||
}
|
||||
if cerr == nil {
|
||||
code, body = c, b
|
||||
@@ -502,7 +290,6 @@ func TestProvidersMatrix(t *testing.T) {
|
||||
|
||||
// The session id sent as x-session-id must round-trip into the
|
||||
// access-log row for this provider.
|
||||
var row api.AgentNetworkAccessLog
|
||||
require.Eventually(t, func() bool {
|
||||
logs, lerr := srv.ListAccessLogs(ctx)
|
||||
if lerr != nil {
|
||||
@@ -510,15 +297,11 @@ func TestProvidersMatrix(t *testing.T) {
|
||||
}
|
||||
for _, r := range logs.Data {
|
||||
if r.SessionId != nil && *r.SessionId == sessionID {
|
||||
row = r
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 30*time.Second, 2*time.Second, "session id %q must be recorded in an access-log row for %s", sessionID, pc.name)
|
||||
|
||||
// Stored total and cache cost must match the published rates applied to the row's buckets.
|
||||
validateAccessLogCost(t, pc, row)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -539,7 +322,4 @@ func TestProvidersMatrix(t *testing.T) {
|
||||
}
|
||||
return false
|
||||
}, 60*time.Second, 3*time.Second, "consumption must be recorded with positive token counts after live traffic")
|
||||
|
||||
// Final raw-SQL audit: bypass the API and re-verify every persisted usage row in the store.
|
||||
verifyUsageRowsSQL(t, srv)
|
||||
}
|
||||
|
||||
@@ -256,7 +256,7 @@ func (cl *Client) ChatPrefixed(ctx context.Context, endpoint, proxyIP, pathPrefi
|
||||
case WireMessages:
|
||||
path = "/v1/messages"
|
||||
headers = []string{"anthropic-version: 2023-06-01"}
|
||||
body = fmt.Sprintf(`{"model":%q,"max_tokens":2048,"messages":[{"role":"user","content":%q}]}`, model, prompt)
|
||||
body = fmt.Sprintf(`{"model":%q,"max_tokens":64,"messages":[{"role":"user","content":%q}]}`, model, prompt)
|
||||
default:
|
||||
path = "/v1/chat/completions"
|
||||
body = fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":%q}]}`, model, prompt)
|
||||
@@ -271,7 +271,7 @@ func (cl *Client) ChatPrefixed(ctx context.Context, endpoint, proxyIP, pathPrefi
|
||||
// is sent as the universal x-session-id header the proxy records.
|
||||
func (cl *Client) Vertex(ctx context.Context, endpoint, proxyIP, project, region, model, prompt, sessionID string) (int, string, error) {
|
||||
path := fmt.Sprintf("/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:rawPredict", project, region, model)
|
||||
body := fmt.Sprintf(`{"anthropic_version":"vertex-2023-10-16","max_tokens":2048,"messages":[{"role":"user","content":%q}]}`, prompt)
|
||||
body := fmt.Sprintf(`{"anthropic_version":"vertex-2023-10-16","max_tokens":64,"messages":[{"role":"user","content":%q}]}`, prompt)
|
||||
return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(nil, sessionID))
|
||||
}
|
||||
|
||||
@@ -282,7 +282,7 @@ func (cl *Client) Vertex(ctx context.Context, endpoint, proxyIP, project, region
|
||||
// header the proxy records.
|
||||
func (cl *Client) Bedrock(ctx context.Context, endpoint, proxyIP, model, prompt, sessionID string) (int, string, error) {
|
||||
path := "/model/" + model + "/invoke"
|
||||
body := fmt.Sprintf(`{"anthropic_version":"bedrock-2023-05-31","max_tokens":2048,"messages":[{"role":"user","content":%q}]}`, prompt)
|
||||
body := fmt.Sprintf(`{"anthropic_version":"bedrock-2023-05-31","max_tokens":64,"messages":[{"role":"user","content":%q}]}`, prompt)
|
||||
return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(nil, sessionID))
|
||||
}
|
||||
|
||||
@@ -341,7 +341,7 @@ func (cl *Client) Terminate(ctx context.Context) error {
|
||||
return cl.container.Terminate(ctx)
|
||||
}
|
||||
|
||||
// containerLogs reads up to 4 MiB of a container's logs for diagnostics — enough for a whole provider-matrix run.
|
||||
// containerLogs reads up to 256 KiB of a container's logs for diagnostics.
|
||||
func containerLogs(ctx context.Context, c testcontainers.Container) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
@@ -351,6 +351,6 @@ func containerLogs(ctx context.Context, c testcontainers.Container) string {
|
||||
return fmt.Sprintf("<logs error: %v>", err)
|
||||
}
|
||||
defer r.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(r, 4<<20))
|
||||
b, _ := io.ReadAll(io.LimitReader(r, 256<<10))
|
||||
return string(b)
|
||||
}
|
||||
|
||||
@@ -221,29 +221,6 @@ func (c *Combined) CreateProxyTokenCLI(ctx context.Context, name string) (string
|
||||
return "", fmt.Errorf("token not found in CLI output: %s", string(out))
|
||||
}
|
||||
|
||||
// SnapshotStoreDB copies the management sqlite store (with WAL/SHM sidecars) out of the bind-mounted
|
||||
// data dir into dstDir and returns the copy's path; reading a copy avoids locking against live writes.
|
||||
func (c *Combined) SnapshotStoreDB(dstDir string) (string, error) {
|
||||
src := filepath.Join(c.workDir, "data", "store.db")
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
return "", fmt.Errorf("management store not found at %s: %w", src, err)
|
||||
}
|
||||
dst := filepath.Join(dstDir, "store.db")
|
||||
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||
data, err := os.ReadFile(src + suffix)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) && suffix != "" {
|
||||
continue // sidecar only exists in WAL mode
|
||||
}
|
||||
return "", fmt.Errorf("read %s: %w", src+suffix, err)
|
||||
}
|
||||
if err := os.WriteFile(dst+suffix, data, 0o600); err != nil {
|
||||
return "", fmt.Errorf("write %s: %w", dst+suffix, err)
|
||||
}
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
// Logs returns the combined server container logs, for diagnostics.
|
||||
func (c *Combined) Logs(ctx context.Context) string {
|
||||
return containerLogs(ctx, c.container)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package networkmap_pgsql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
networkmap_pgsql "github.com/netbirdio/netbird/management/internals/network_map_db/pgsql"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetGroups(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
|
||||
s, err := networkmap_pgsql.NewPostgresqlStore(ctx, dsn)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = s.Pool.Query(ctx,
|
||||
"insert into accounts (id) VALUES('account-id-1')")
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = s.Pool.Query(ctx,
|
||||
"insert into groups (id, account_id, name, resources, public_id) VALUES('test-group-id-1','account-id-1','test-group-1', '[{\"ID\":\"host-id-1\",\"Type\":\"host\"}]','public-id-1')")
|
||||
assert.NoError(t, err)
|
||||
_, err = s.Pool.Query(ctx,
|
||||
"insert into groups (id, account_id, name, resources, public_id) VALUES('test-group-id-2','account-id-1','test-group-2', '[{\"ID\":\"subnet-id-1\",\"Type\":\"subnet\"}, {\"ID\":\"host-id-2\",\"Type\":\"host\"}]','public-id-2')")
|
||||
assert.NoError(t, err)
|
||||
|
||||
groups, err := s.GetGroups(ctx, "account-id-1")
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t,
|
||||
groups,
|
||||
nmdata.Group{Name: "test-group-1", PublicID: "public-id-1", Resources: []nmdata.Resource{{ID: "host-id-1", Type: "host"}}},
|
||||
)
|
||||
assert.Contains(t,
|
||||
groups,
|
||||
nmdata.Group{Name: "test-group-2", PublicID: "public-id-2", Resources: []nmdata.Resource{{ID: "subnet-id-1", Type: "subnet"}, {ID: "host-id-2", Type: "host"}}},
|
||||
)
|
||||
}
|
||||
115
integration_tests/management/network_map_db/pgsql/main_test.go
Normal file
115
integration_tests/management/network_map_db/pgsql/main_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package networkmap_pgsql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
gormstore "github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/testutil"
|
||||
)
|
||||
|
||||
var dsn string
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
_, tmpdsn, err := testutil.CreatePostgresTestContainer()
|
||||
if err != nil {
|
||||
log.Fatalf("error starting postres container %v", err)
|
||||
}
|
||||
|
||||
var db *gorm.DB
|
||||
for i := range 5 {
|
||||
db, err = gorm.Open(postgres.Open(tmpdsn), &gorm.Config{})
|
||||
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
|
||||
if i < 5 {
|
||||
waitTime := time.Duration(100*(i+1)) * time.Millisecond
|
||||
time.Sleep(waitTime)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Fatalf("error connecting to postres db %v", err)
|
||||
}
|
||||
|
||||
var cleanup func()
|
||||
dsn, cleanup, err = createRandomDB(tmpdsn, db)
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatalf("error creating postres db %v", err)
|
||||
}
|
||||
|
||||
_, err = gormstore.NewPostgresqlStoreForTests(context.TODO(), dsn, nil, false)
|
||||
if err != nil {
|
||||
log.Fatalf("error running migrations %v", err)
|
||||
}
|
||||
|
||||
code := m.Run()
|
||||
|
||||
cleanup()
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func createRandomDB(dsn string, db *gorm.DB) (string, func(), error) {
|
||||
dbName := fmt.Sprintf("test_db_%s", strings.ReplaceAll(uuid.New().String(), "-", "_"))
|
||||
|
||||
if err := db.Exec(fmt.Sprintf("CREATE DATABASE %s", dbName)).Error; err != nil {
|
||||
return "", nil, fmt.Errorf("failed to create database: %v", err)
|
||||
}
|
||||
|
||||
originalDSN := dsn
|
||||
|
||||
cleanup := func() {
|
||||
var dropDB *gorm.DB
|
||||
var err error
|
||||
|
||||
dropDB, err = gorm.Open(postgres.Open(originalDSN), &gorm.Config{
|
||||
SkipDefaultTransaction: true,
|
||||
PrepareStmt: false,
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorf("failed to connect for dropping database %s: %v", dbName, err)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if sqlDB, _ := dropDB.DB(); sqlDB != nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
if sqlDB, _ := dropDB.DB(); sqlDB != nil {
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
sqlDB.SetMaxIdleConns(0)
|
||||
sqlDB.SetConnMaxLifetime(time.Second)
|
||||
}
|
||||
|
||||
err = dropDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s WITH (FORCE)", dbName)).Error
|
||||
|
||||
if err != nil {
|
||||
log.Errorf("failed to drop database %s: %v", dbName, err)
|
||||
}
|
||||
}
|
||||
|
||||
return replaceDBName(dsn, dbName), cleanup, nil
|
||||
}
|
||||
|
||||
func replaceDBName(dsn, newDBName string) string {
|
||||
re := regexp.MustCompile(`(?P<pre>[:/@])(?P<dbname>[^/?]+)(?P<post>\?|$)`)
|
||||
return re.ReplaceAllString(dsn, `${pre}`+newDBName+`${post}`)
|
||||
}
|
||||
@@ -641,7 +641,7 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, 0, err
|
||||
}
|
||||
return peer, &types.NetworkMapComponents{Network: network.Copy()}, nil, nil, 0, nil
|
||||
return peer, &types.NetworkMapComponents{Network: types.TwinNetwork(network)}, nil, nil, 0, nil
|
||||
}
|
||||
|
||||
account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
|
||||
@@ -794,7 +794,7 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
|
||||
}
|
||||
|
||||
emptyMap := &types.NetworkMap{
|
||||
Network: network.Copy(),
|
||||
Network: types.TwinNetwork(network),
|
||||
}
|
||||
return emptyMap, nil, 0, nil
|
||||
}
|
||||
|
||||
@@ -18,26 +18,21 @@ import (
|
||||
// contract between the proxy and management; management flattens them into
|
||||
// queryable columns. Keep in sync with the proxy side.
|
||||
const (
|
||||
metaKeyProvider = "llm.provider"
|
||||
metaKeyModel = "llm.model"
|
||||
metaKeyResolvedProviderID = "llm.resolved_provider_id"
|
||||
metaKeySelectedPolicyID = "llm.selected_policy_id"
|
||||
metaKeyPolicyDecision = "llm_policy.decision"
|
||||
metaKeyPolicyReason = "llm_policy.reason"
|
||||
metaKeyInputTokens = "llm.input_tokens" //nolint:gosec // metadata key name, not a credential
|
||||
metaKeyOutputTokens = "llm.output_tokens" //nolint:gosec // metadata key name, not a credential
|
||||
metaKeyTotalTokens = "llm.total_tokens" //nolint:gosec // metadata key name, not a credential
|
||||
metaKeyCachedInputTokens = "llm.cached_input_tokens" //nolint:gosec // metadata key name, not a credential
|
||||
metaKeyCacheCreationTokens = "llm.cache_creation_tokens" //nolint:gosec // metadata key name, not a credential
|
||||
metaKeyCostUSDInput = "cost.usd_input"
|
||||
metaKeyCostUSDCachedInput = "cost.usd_cached_input"
|
||||
metaKeyCostUSDCacheCreate = "cost.usd_cache_creation"
|
||||
metaKeyCostUSDOutput = "cost.usd_output"
|
||||
metaKeyStream = "llm.stream"
|
||||
metaKeySessionID = "llm.session_id"
|
||||
metaKeyAuthorisingGroups = "llm.authorising_groups"
|
||||
metaKeyRequestPrompt = "llm.request_prompt"
|
||||
metaKeyResponseCompletion = "llm.response_completion"
|
||||
metaKeyProvider = "llm.provider"
|
||||
metaKeyModel = "llm.model"
|
||||
metaKeyResolvedProviderID = "llm.resolved_provider_id"
|
||||
metaKeySelectedPolicyID = "llm.selected_policy_id"
|
||||
metaKeyPolicyDecision = "llm_policy.decision"
|
||||
metaKeyPolicyReason = "llm_policy.reason"
|
||||
metaKeyInputTokens = "llm.input_tokens" //nolint:gosec // metadata key name, not a credential
|
||||
metaKeyOutputTokens = "llm.output_tokens" //nolint:gosec // metadata key name, not a credential
|
||||
metaKeyTotalTokens = "llm.total_tokens" //nolint:gosec // metadata key name, not a credential
|
||||
metaKeyCostUSDTotal = "cost.usd_total"
|
||||
metaKeyStream = "llm.stream"
|
||||
metaKeySessionID = "llm.session_id"
|
||||
metaKeyAuthorisingGroups = "llm.authorising_groups"
|
||||
metaKeyRequestPrompt = "llm.request_prompt"
|
||||
metaKeyResponseCompletion = "llm.response_completion"
|
||||
)
|
||||
|
||||
// IngestAccessLog flattens the metadata-bearing reverse-proxy access-log entry
|
||||
@@ -113,25 +108,20 @@ func flattenAccessLog(e *accesslogs.AccessLogEntry) (*types.AgentNetworkAccessLo
|
||||
BytesUpload: e.BytesUpload,
|
||||
BytesDownload: e.BytesDownload,
|
||||
|
||||
Provider: meta[metaKeyProvider],
|
||||
Model: meta[metaKeyModel],
|
||||
SessionID: meta[metaKeySessionID],
|
||||
ResolvedProviderID: meta[metaKeyResolvedProviderID],
|
||||
SelectedPolicyID: meta[metaKeySelectedPolicyID],
|
||||
Decision: meta[metaKeyPolicyDecision],
|
||||
DenyReason: meta[metaKeyPolicyReason],
|
||||
InputTokens: parseMetaInt(meta, metaKeyInputTokens),
|
||||
OutputTokens: parseMetaInt(meta, metaKeyOutputTokens),
|
||||
TotalTokens: parseMetaInt(meta, metaKeyTotalTokens),
|
||||
CachedInputTokens: parseMetaInt(meta, metaKeyCachedInputTokens),
|
||||
CacheCreationTokens: parseMetaInt(meta, metaKeyCacheCreationTokens),
|
||||
InputCostUSD: parseMetaFloat(meta, metaKeyCostUSDInput),
|
||||
CachedInputCostUSD: parseMetaFloat(meta, metaKeyCostUSDCachedInput),
|
||||
CacheCreationCostUSD: parseMetaFloat(meta, metaKeyCostUSDCacheCreate),
|
||||
OutputCostUSD: parseMetaFloat(meta, metaKeyCostUSDOutput),
|
||||
Stream: parseMetaBool(meta, metaKeyStream),
|
||||
RequestPrompt: meta[metaKeyRequestPrompt],
|
||||
ResponseCompletion: meta[metaKeyResponseCompletion],
|
||||
Provider: meta[metaKeyProvider],
|
||||
Model: meta[metaKeyModel],
|
||||
SessionID: meta[metaKeySessionID],
|
||||
ResolvedProviderID: meta[metaKeyResolvedProviderID],
|
||||
SelectedPolicyID: meta[metaKeySelectedPolicyID],
|
||||
Decision: meta[metaKeyPolicyDecision],
|
||||
DenyReason: meta[metaKeyPolicyReason],
|
||||
InputTokens: parseMetaInt(meta, metaKeyInputTokens),
|
||||
OutputTokens: parseMetaInt(meta, metaKeyOutputTokens),
|
||||
TotalTokens: parseMetaInt(meta, metaKeyTotalTokens),
|
||||
CostUSD: parseMetaFloat(meta, metaKeyCostUSDTotal),
|
||||
Stream: parseMetaBool(meta, metaKeyStream),
|
||||
RequestPrompt: meta[metaKeyRequestPrompt],
|
||||
ResponseCompletion: meta[metaKeyResponseCompletion],
|
||||
}
|
||||
|
||||
var groups []types.AgentNetworkAccessLogGroup
|
||||
@@ -150,23 +140,18 @@ func flattenAccessLog(e *accesslogs.AccessLogEntry) (*types.AgentNetworkAccessLo
|
||||
// log's ID so the two correlate.
|
||||
func usageFromFlattenedLog(e *types.AgentNetworkAccessLog, groups []types.AgentNetworkAccessLogGroup) (*types.AgentNetworkUsage, []types.AgentNetworkUsageGroup) {
|
||||
usage := &types.AgentNetworkUsage{
|
||||
ID: e.ID,
|
||||
AccountID: e.AccountID,
|
||||
Timestamp: e.Timestamp,
|
||||
UserID: e.UserID,
|
||||
ResolvedProviderID: e.ResolvedProviderID,
|
||||
Provider: e.Provider,
|
||||
Model: e.Model,
|
||||
SessionID: e.SessionID,
|
||||
InputTokens: e.InputTokens,
|
||||
OutputTokens: e.OutputTokens,
|
||||
TotalTokens: e.TotalTokens,
|
||||
CachedInputTokens: e.CachedInputTokens,
|
||||
CacheCreationTokens: e.CacheCreationTokens,
|
||||
InputCostUSD: e.InputCostUSD,
|
||||
CachedInputCostUSD: e.CachedInputCostUSD,
|
||||
CacheCreationCostUSD: e.CacheCreationCostUSD,
|
||||
OutputCostUSD: e.OutputCostUSD,
|
||||
ID: e.ID,
|
||||
AccountID: e.AccountID,
|
||||
Timestamp: e.Timestamp,
|
||||
UserID: e.UserID,
|
||||
ResolvedProviderID: e.ResolvedProviderID,
|
||||
Provider: e.Provider,
|
||||
Model: e.Model,
|
||||
SessionID: e.SessionID,
|
||||
InputTokens: e.InputTokens,
|
||||
OutputTokens: e.OutputTokens,
|
||||
TotalTokens: e.TotalTokens,
|
||||
CostUSD: e.CostUSD,
|
||||
}
|
||||
|
||||
usageGroups := make([]types.AgentNetworkUsageGroup, 0, len(groups))
|
||||
|
||||
@@ -28,22 +28,17 @@ func newIngestTestEntry() *accesslogs.AccessLogEntry {
|
||||
UserId: "user-1",
|
||||
AgentNetwork: true,
|
||||
Metadata: map[string]string{
|
||||
metaKeyProvider: "openai",
|
||||
metaKeyModel: "gpt-5.4",
|
||||
metaKeyResolvedProviderID: "prov-1",
|
||||
metaKeySessionID: "sess-1",
|
||||
metaKeyInputTokens: "100",
|
||||
metaKeyOutputTokens: "50",
|
||||
metaKeyTotalTokens: "1174",
|
||||
metaKeyCachedInputTokens: "256",
|
||||
metaKeyCacheCreationTokens: "768",
|
||||
metaKeyCostUSDInput: "0.0071",
|
||||
metaKeyCostUSDCachedInput: "0.0009",
|
||||
metaKeyCostUSDCacheCreate: "0.0020",
|
||||
metaKeyCostUSDOutput: "0.0023",
|
||||
metaKeyStream: "true",
|
||||
metaKeyRequestPrompt: "hello",
|
||||
metaKeyResponseCompletion: "world",
|
||||
metaKeyProvider: "openai",
|
||||
metaKeyModel: "gpt-5.4",
|
||||
metaKeyResolvedProviderID: "prov-1",
|
||||
metaKeySessionID: "sess-1",
|
||||
metaKeyInputTokens: "100",
|
||||
metaKeyOutputTokens: "50",
|
||||
metaKeyTotalTokens: "150",
|
||||
metaKeyCostUSDTotal: "0.0123",
|
||||
metaKeyStream: "true",
|
||||
metaKeyRequestPrompt: "hello",
|
||||
metaKeyResponseCompletion: "world",
|
||||
// repeated id must be de-duplicated before the group rows insert.
|
||||
metaKeyAuthorisingGroups: "grp-eng,grp-eng,grp-ops",
|
||||
},
|
||||
@@ -70,19 +65,7 @@ func TestIngestAccessLog_RealStore_LogCollectionOff(t *testing.T) {
|
||||
require.Len(t, usage, 1, "usage row must be written even with log collection off")
|
||||
assert.Equal(t, int64(100), usage[0].InputTokens, "input tokens must round-trip from metadata")
|
||||
assert.Equal(t, int64(50), usage[0].OutputTokens, "output tokens must round-trip from metadata")
|
||||
assert.Equal(t, int64(256), usage[0].CachedInputTokens, "cache-read tokens must round-trip from metadata")
|
||||
assert.Equal(t, int64(768), usage[0].CacheCreationTokens, "cache-write tokens must round-trip from metadata")
|
||||
// The per-bucket breakdown is the only cost state stored, and must survive
|
||||
// the write/read cycle as real columns — usage rows are the only cost
|
||||
// record for accounts with log collection off, so a dropped column here
|
||||
// loses the split permanently.
|
||||
assert.InDelta(t, 0.0071, usage[0].InputCostUSD, 1e-9, "input cost must round-trip from metadata")
|
||||
assert.InDelta(t, 0.0009, usage[0].CachedInputCostUSD, 1e-9, "cache-read cost must round-trip from metadata")
|
||||
assert.InDelta(t, 0.0020, usage[0].CacheCreationCostUSD, 1e-9, "cache-write cost must round-trip from metadata")
|
||||
assert.InDelta(t, 0.0023, usage[0].OutputCostUSD, 1e-9, "output cost must round-trip from metadata")
|
||||
// Aggregates are derived from the stored columns, never stored themselves.
|
||||
assert.InDelta(t, 0.0123, usage[0].TotalCostUSD(), 1e-9, "total is derived from the stored buckets")
|
||||
assert.InDelta(t, 0.0029, usage[0].CacheCostUSD(), 1e-9, "cache cost is derived from the two cache buckets")
|
||||
assert.InDelta(t, 0.0123, usage[0].CostUSD, 1e-9, "cost must round-trip from metadata")
|
||||
|
||||
logs, _, err := s.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{})
|
||||
require.NoError(t, err)
|
||||
@@ -113,14 +96,6 @@ func TestIngestAccessLog_RealStore_LogCollectionOn(t *testing.T) {
|
||||
require.Equal(t, int64(1), total, "exactly one access-log row expected")
|
||||
require.Len(t, logs, 1, "full access-log row must be written when log collection is on")
|
||||
assert.Equal(t, "gpt-5.4", logs[0].Model, "model must flatten from metadata")
|
||||
assert.Equal(t, int64(256), logs[0].CachedInputTokens, "cache-read tokens must flatten from metadata")
|
||||
assert.Equal(t, int64(768), logs[0].CacheCreationTokens, "cache-write tokens must flatten from metadata")
|
||||
assert.InDelta(t, 0.0029, logs[0].CacheCostUSD(), 1e-9, "cache cost is derived from the two cache buckets")
|
||||
assert.InDelta(t, 0.0123, logs[0].TotalCostUSD(), 1e-9, "total is derived from the stored buckets")
|
||||
assert.InDelta(t, 0.0071, logs[0].InputCostUSD, 1e-9, "input cost must flatten from metadata")
|
||||
assert.InDelta(t, 0.0009, logs[0].CachedInputCostUSD, 1e-9, "cache-read cost must flatten from metadata")
|
||||
assert.InDelta(t, 0.0020, logs[0].CacheCreationCostUSD, 1e-9, "cache-write cost must flatten from metadata")
|
||||
assert.InDelta(t, 0.0023, logs[0].OutputCostUSD, 1e-9, "output cost must flatten from metadata")
|
||||
assert.Equal(t, "hello", logs[0].RequestPrompt, "prompt must be retained when log collection is on")
|
||||
assert.Equal(t, "world", logs[0].ResponseCompletion, "completion must be retained when log collection is on")
|
||||
assert.True(t, logs[0].Stream, "stream flag must flatten from metadata")
|
||||
|
||||
@@ -38,7 +38,7 @@ func accessLogRow(id, sessionID string, ts time.Time, opts ...func(*types.AgentN
|
||||
InputTokens: 100,
|
||||
OutputTokens: 50,
|
||||
TotalTokens: 150,
|
||||
InputCostUSD: 0.01,
|
||||
CostUSD: 0.01,
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(e)
|
||||
@@ -74,7 +74,7 @@ func withTokens(in, out, total int64, cost float64) func(*types.AgentNetworkAcce
|
||||
e.InputTokens = in
|
||||
e.OutputTokens = out
|
||||
e.TotalTokens = total
|
||||
e.InputCostUSD = cost
|
||||
e.CostUSD = cost
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ func TestAccessLogSessions_FoldAndAggregate(t *testing.T) {
|
||||
assert.Equal(t, int64(310), a.InputTokens, "input tokens summed")
|
||||
assert.Equal(t, int64(135), a.OutputTokens, "output tokens summed")
|
||||
assert.Equal(t, int64(445), a.TotalTokens, "total tokens summed")
|
||||
assert.InDelta(t, 0.031, a.TotalCostUSD(), 1e-9, "cost summed")
|
||||
assert.InDelta(t, 0.031, a.CostUSD, 1e-9, "cost summed")
|
||||
assert.Equal(t, "deny", a.Decision, "any deny makes the session a deny")
|
||||
assert.ElementsMatch(t, []string{"openai", "anthropic"}, a.Providers, "distinct providers")
|
||||
assert.ElementsMatch(t, []string{"gpt-5.4", "claude-haiku-4-5"}, a.Models, "distinct models")
|
||||
|
||||
@@ -41,24 +41,8 @@ type AgentNetworkAccessLog struct {
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
TotalTokens int64
|
||||
// Prompt-cache buckets: read + write token counts.
|
||||
CachedInputTokens int64
|
||||
CacheCreationTokens int64
|
||||
// Per-bucket cost breakdown — one column per token bucket the provider
|
||||
// bills separately. These four are the only cost state stored: the total
|
||||
// and the cache portion are derived on read (TotalCostUSD / CacheCostUSD)
|
||||
// rather than stored alongside, so a stored aggregate can never drift out
|
||||
// of step with the components it summarises.
|
||||
//
|
||||
// default:0 matters on upgrade: these columns are ALTER TABLE ADD COLUMN
|
||||
// on an existing table, and without it every historical row holds NULL —
|
||||
// which a raw SUM()/scan into float64 can't read. The default backfills
|
||||
// them as 0, so pre-upgrade rows report an unknown split, not an error.
|
||||
InputCostUSD float64 `gorm:"not null;default:0"`
|
||||
CachedInputCostUSD float64 `gorm:"not null;default:0"`
|
||||
CacheCreationCostUSD float64 `gorm:"not null;default:0"`
|
||||
OutputCostUSD float64 `gorm:"not null;default:0"`
|
||||
Stream bool
|
||||
CostUSD float64
|
||||
Stream bool
|
||||
|
||||
// Prompt capture. Only populated when prompt collection is enabled
|
||||
// (account master switch AND policy guardrail). Heavy free text.
|
||||
@@ -76,44 +60,19 @@ type AgentNetworkAccessLog struct {
|
||||
// the reverse-proxy AccessLogEntry table.
|
||||
func (AgentNetworkAccessLog) TableName() string { return "agent_network_access_log" }
|
||||
|
||||
// CostUSDSQLExpr is the SQL sum of the per-bucket cost columns — the total cost
|
||||
// of a row. Used wherever a query has to sort or aggregate on total cost now
|
||||
// that no cost_usd column is stored. Plain arithmetic over NOT NULL columns, so
|
||||
// it stays portable across SQLite and Postgres.
|
||||
const CostUSDSQLExpr = "(input_cost_usd + cached_input_cost_usd + cache_creation_cost_usd + output_cost_usd)"
|
||||
|
||||
// TotalCostUSD is the request's total cost: the sum of the four per-bucket
|
||||
// costs. Derived rather than stored so it cannot disagree with the breakdown.
|
||||
func (a *AgentNetworkAccessLog) TotalCostUSD() float64 {
|
||||
return a.InputCostUSD + a.CachedInputCostUSD + a.CacheCreationCostUSD + a.OutputCostUSD
|
||||
}
|
||||
|
||||
// CacheCostUSD is the portion of the total billed for prompt-cache buckets:
|
||||
// cache reads plus cache writes.
|
||||
func (a *AgentNetworkAccessLog) CacheCostUSD() float64 {
|
||||
return a.CachedInputCostUSD + a.CacheCreationCostUSD
|
||||
}
|
||||
|
||||
// ToAPIResponse renders the flattened entry as the API representation.
|
||||
func (a *AgentNetworkAccessLog) ToAPIResponse() api.AgentNetworkAccessLog {
|
||||
out := api.AgentNetworkAccessLog{
|
||||
Id: a.ID,
|
||||
ServiceId: a.ServiceID,
|
||||
Timestamp: a.Timestamp,
|
||||
StatusCode: a.StatusCode,
|
||||
DurationMs: int(a.Duration.Milliseconds()),
|
||||
InputTokens: a.InputTokens,
|
||||
OutputTokens: a.OutputTokens,
|
||||
TotalTokens: a.TotalTokens,
|
||||
CachedInputTokens: a.CachedInputTokens,
|
||||
CacheCreationTokens: a.CacheCreationTokens,
|
||||
InputCostUsd: a.InputCostUSD,
|
||||
CachedInputCostUsd: a.CachedInputCostUSD,
|
||||
CacheCreationCostUsd: a.CacheCreationCostUSD,
|
||||
OutputCostUsd: a.OutputCostUSD,
|
||||
CostUsd: a.TotalCostUSD(),
|
||||
CacheCostUsd: a.CacheCostUSD(),
|
||||
Stream: &a.Stream,
|
||||
Id: a.ID,
|
||||
ServiceId: a.ServiceID,
|
||||
Timestamp: a.Timestamp,
|
||||
StatusCode: a.StatusCode,
|
||||
DurationMs: int(a.Duration.Milliseconds()),
|
||||
InputTokens: a.InputTokens,
|
||||
OutputTokens: a.OutputTokens,
|
||||
TotalTokens: a.TotalTokens,
|
||||
CostUsd: a.CostUSD,
|
||||
Stream: &a.Stream,
|
||||
}
|
||||
|
||||
out.UserId = strPtr(a.UserID)
|
||||
@@ -153,36 +112,20 @@ func strPtr(s string) *string {
|
||||
// summary plus its ordered entries. Assembled in Go from a page of entries — it
|
||||
// is not a stored table.
|
||||
type AgentNetworkAccessLogSession struct {
|
||||
SessionID string // empty for a session-less (singleton) request
|
||||
UserID string
|
||||
GroupIDs []string // union of the entries' authorising groups
|
||||
StartedAt time.Time
|
||||
EndedAt time.Time
|
||||
RequestCount int
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
TotalTokens int64
|
||||
CachedInputTokens int64
|
||||
CacheCreationTokens int64
|
||||
InputCostUSD float64
|
||||
CachedInputCostUSD float64
|
||||
CacheCreationCostUSD float64
|
||||
OutputCostUSD float64
|
||||
Providers []string // distinct vendors seen in the session
|
||||
Models []string // distinct models seen in the session
|
||||
Decision string // "deny" if any entry was denied, else "allow"
|
||||
Entries []*AgentNetworkAccessLog
|
||||
}
|
||||
|
||||
// TotalCostUSD is the session's total cost: the sum of the four per-bucket
|
||||
// costs accumulated across its entries.
|
||||
func (sess *AgentNetworkAccessLogSession) TotalCostUSD() float64 {
|
||||
return sess.InputCostUSD + sess.CachedInputCostUSD + sess.CacheCreationCostUSD + sess.OutputCostUSD
|
||||
}
|
||||
|
||||
// CacheCostUSD is the session's prompt-cache spend: cache reads plus writes.
|
||||
func (sess *AgentNetworkAccessLogSession) CacheCostUSD() float64 {
|
||||
return sess.CachedInputCostUSD + sess.CacheCreationCostUSD
|
||||
SessionID string // empty for a session-less (singleton) request
|
||||
UserID string
|
||||
GroupIDs []string // union of the entries' authorising groups
|
||||
StartedAt time.Time
|
||||
EndedAt time.Time
|
||||
RequestCount int
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
TotalTokens int64
|
||||
CostUSD float64
|
||||
Providers []string // distinct vendors seen in the session
|
||||
Models []string // distinct models seen in the session
|
||||
Decision string // "deny" if any entry was denied, else "allow"
|
||||
Entries []*AgentNetworkAccessLog
|
||||
}
|
||||
|
||||
// sessionKey is the grouping key for an entry: its session id, or — when the
|
||||
@@ -262,12 +205,7 @@ func (sess *AgentNetworkAccessLogSession) foldEntry(sk *sessionSeen, e *AgentNet
|
||||
sess.InputTokens += e.InputTokens
|
||||
sess.OutputTokens += e.OutputTokens
|
||||
sess.TotalTokens += e.TotalTokens
|
||||
sess.CachedInputTokens += e.CachedInputTokens
|
||||
sess.CacheCreationTokens += e.CacheCreationTokens
|
||||
sess.InputCostUSD += e.InputCostUSD
|
||||
sess.CachedInputCostUSD += e.CachedInputCostUSD
|
||||
sess.CacheCreationCostUSD += e.CacheCreationCostUSD
|
||||
sess.OutputCostUSD += e.OutputCostUSD
|
||||
sess.CostUSD += e.CostUSD
|
||||
if e.Timestamp.Before(sess.StartedAt) {
|
||||
sess.StartedAt = e.Timestamp
|
||||
}
|
||||
@@ -310,22 +248,15 @@ func (sess *AgentNetworkAccessLogSession) ToAPIResponse() api.AgentNetworkAccess
|
||||
}
|
||||
|
||||
out := api.AgentNetworkAccessLogSession{
|
||||
StartedAt: sess.StartedAt,
|
||||
EndedAt: sess.EndedAt,
|
||||
RequestCount: sess.RequestCount,
|
||||
InputTokens: sess.InputTokens,
|
||||
OutputTokens: sess.OutputTokens,
|
||||
TotalTokens: sess.TotalTokens,
|
||||
CachedInputTokens: sess.CachedInputTokens,
|
||||
CacheCreationTokens: sess.CacheCreationTokens,
|
||||
InputCostUsd: sess.InputCostUSD,
|
||||
CachedInputCostUsd: sess.CachedInputCostUSD,
|
||||
CacheCreationCostUsd: sess.CacheCreationCostUSD,
|
||||
OutputCostUsd: sess.OutputCostUSD,
|
||||
CostUsd: sess.TotalCostUSD(),
|
||||
CacheCostUsd: sess.CacheCostUSD(),
|
||||
Decision: sess.Decision,
|
||||
Entries: entries,
|
||||
StartedAt: sess.StartedAt,
|
||||
EndedAt: sess.EndedAt,
|
||||
RequestCount: sess.RequestCount,
|
||||
InputTokens: sess.InputTokens,
|
||||
OutputTokens: sess.OutputTokens,
|
||||
TotalTokens: sess.TotalTokens,
|
||||
CostUsd: sess.CostUSD,
|
||||
Decision: sess.Decision,
|
||||
Entries: entries,
|
||||
}
|
||||
out.SessionId = strPtr(sess.SessionID)
|
||||
out.UserId = strPtr(sess.UserID)
|
||||
|
||||
@@ -54,7 +54,7 @@ var accessLogSortFields = map[string]string{
|
||||
"provider": "provider",
|
||||
"status_code": "status_code",
|
||||
"duration": "duration",
|
||||
"cost_usd": CostUSDSQLExpr,
|
||||
"cost_usd": "cost_usd",
|
||||
"total_tokens": "total_tokens",
|
||||
"user_id": "user_id",
|
||||
"decision": "decision",
|
||||
@@ -70,7 +70,7 @@ var accessLogSortFields = map[string]string{
|
||||
var sessionSortExprs = map[string]string{ //nolint:gosec // G101 false positive: "total_tokens" sort key, not a credential
|
||||
"timestamp": "MAX(timestamp)",
|
||||
"started_at": "MIN(timestamp)",
|
||||
"cost_usd": "SUM" + CostUSDSQLExpr,
|
||||
"cost_usd": "SUM(cost_usd)",
|
||||
"total_tokens": "SUM(total_tokens)",
|
||||
"duration": "SUM(duration)",
|
||||
"request_count": "COUNT(*)",
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// costRow builds an access-log entry carrying only a cost breakdown — the rest
|
||||
// of the row is irrelevant to the summation identities under test.
|
||||
func costRow(id, session string, ts time.Time, in, cachedIn, cacheCreate, out float64) *AgentNetworkAccessLog {
|
||||
return &AgentNetworkAccessLog{
|
||||
ID: id,
|
||||
SessionID: session,
|
||||
Timestamp: ts,
|
||||
InputCostUSD: in,
|
||||
CachedInputCostUSD: cachedIn,
|
||||
CacheCreationCostUSD: cacheCreate,
|
||||
OutputCostUSD: out,
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIResponse_CostComponentsSumToAggregates is the contract a client adding
|
||||
// up an API response depends on: within a single rendered object, the four
|
||||
// per-bucket fields sum to cost_usd, and the two cache fields sum to
|
||||
// cache_cost_usd. Uses rates that are not exactly representable in binary
|
||||
// floating point, so the identity is checked against real arithmetic rather
|
||||
// than round numbers.
|
||||
func TestAPIResponse_CostComponentsSumToAggregates(t *testing.T) {
|
||||
row := costRow("r1", "s1", time.Now(), 0.000768, 0.0002304, 0.00192, 0.003)
|
||||
|
||||
api := row.ToAPIResponse()
|
||||
assert.InDelta(t, api.InputCostUsd+api.CachedInputCostUsd+api.CacheCreationCostUsd+api.OutputCostUsd,
|
||||
api.CostUsd, 1e-12, "rendered buckets must sum to the rendered cost_usd")
|
||||
assert.InDelta(t, api.CachedInputCostUsd+api.CacheCreationCostUsd, api.CacheCostUsd, 1e-12,
|
||||
"rendered cache buckets must sum to the rendered cache_cost_usd")
|
||||
assert.InDelta(t, 0.0059184, api.CostUsd, 1e-12, "total is the exact sum, not a separately rounded figure")
|
||||
assert.InDelta(t, 0.0021504, api.CacheCostUsd, 1e-12, "cache cost is the exact sum of the two cache buckets")
|
||||
}
|
||||
|
||||
// TestSessionSummary_SumsMatchSummedEntries proves a session summary equals the
|
||||
// sum of the entries it renders: a client that adds up the entries itself must
|
||||
// land on the same number the summary reports, per bucket and in total.
|
||||
func TestSessionSummary_SumsMatchSummedEntries(t *testing.T) {
|
||||
base := time.Date(2026, 5, 5, 10, 0, 0, 0, time.UTC)
|
||||
entries := []*AgentNetworkAccessLog{
|
||||
costRow("r1", "s1", base, 0.000768, 0.0002304, 0.00192, 0.003),
|
||||
costRow("r2", "s1", base.Add(time.Minute), 0.000625, 0.0009375, 0, 0.005),
|
||||
costRow("r3", "s1", base.Add(2*time.Minute), 0.0000016, 0, 0, 0.0000032),
|
||||
}
|
||||
|
||||
sessions := FoldAccessLogSessions([]string{"s1"}, entries)
|
||||
require.Len(t, sessions, 1)
|
||||
sess := sessions[0].ToAPIResponse()
|
||||
|
||||
var wantInput, wantCachedInput, wantCacheCreation, wantOutput float64
|
||||
for _, e := range entries {
|
||||
wantInput += e.InputCostUSD
|
||||
wantCachedInput += e.CachedInputCostUSD
|
||||
wantCacheCreation += e.CacheCreationCostUSD
|
||||
wantOutput += e.OutputCostUSD
|
||||
}
|
||||
|
||||
assert.InDelta(t, wantInput, sess.InputCostUsd, 1e-12, "session input cost is the sum of its entries")
|
||||
assert.InDelta(t, wantCachedInput, sess.CachedInputCostUsd, 1e-12, "session cache-read cost is the sum of its entries")
|
||||
assert.InDelta(t, wantCacheCreation, sess.CacheCreationCostUsd, 1e-12, "session cache-write cost is the sum of its entries")
|
||||
assert.InDelta(t, wantOutput, sess.OutputCostUsd, 1e-12, "session output cost is the sum of its entries")
|
||||
assert.InDelta(t, wantInput+wantCachedInput+wantCacheCreation+wantOutput, sess.CostUsd, 1e-12,
|
||||
"session total equals the summed entry buckets")
|
||||
|
||||
// Summing the rendered entries must give the same answer as reading the
|
||||
// summary — the property a UI relies on when it totals a table itself.
|
||||
var fromEntries float64
|
||||
for _, e := range sess.Entries {
|
||||
fromEntries += e.CostUsd
|
||||
}
|
||||
assert.InDelta(t, sess.CostUsd, fromEntries, 1e-12, "summary total must match the summed rendered entries")
|
||||
|
||||
// The sub-microdollar row must still contribute; it would vanish under
|
||||
// 6-decimal quantisation.
|
||||
assert.Greater(t, sess.InputCostUsd, 0.001393, "small-cost rows must not be quantised away")
|
||||
}
|
||||
|
||||
// TestUsageBuckets_SumsMatchSummedRows proves the same identity one level up:
|
||||
// a usage bucket equals the sum of the ledger rows folded into it, and the
|
||||
// buckets together equal the whole range.
|
||||
func TestUsageBuckets_SumsMatchSummedRows(t *testing.T) {
|
||||
day1 := time.Date(2026, 5, 5, 9, 0, 0, 0, time.UTC)
|
||||
day2 := time.Date(2026, 5, 6, 9, 0, 0, 0, time.UTC)
|
||||
rows := []*AgentNetworkUsage{
|
||||
{ID: "u1", Timestamp: day1, InputCostUSD: 0.000768, CachedInputCostUSD: 0.0002304, CacheCreationCostUSD: 0.00192, OutputCostUSD: 0.003},
|
||||
{ID: "u2", Timestamp: day1.Add(time.Hour), InputCostUSD: 0.000625, CachedInputCostUSD: 0.0009375, OutputCostUSD: 0.005},
|
||||
{ID: "u3", Timestamp: day2, InputCostUSD: 0.0000016, OutputCostUSD: 0.0000032},
|
||||
}
|
||||
|
||||
buckets := AggregateUsageByGranularity(rows, UsageGranularityDay)
|
||||
require.Len(t, buckets, 2, "two distinct days expected")
|
||||
|
||||
var total, cache float64
|
||||
for _, b := range buckets {
|
||||
api := b.ToAPIResponse()
|
||||
assert.InDelta(t, api.InputCostUsd+api.CachedInputCostUsd+api.CacheCreationCostUsd+api.OutputCostUsd,
|
||||
api.CostUsd, 1e-12, "each bucket's components must sum to its cost_usd")
|
||||
total += api.CostUsd
|
||||
cache += api.CacheCostUsd
|
||||
}
|
||||
|
||||
var wantTotal, wantCache float64
|
||||
for _, r := range rows {
|
||||
wantTotal += r.TotalCostUSD()
|
||||
wantCache += r.CacheCostUSD()
|
||||
}
|
||||
assert.InDelta(t, wantTotal, total, 1e-12, "buckets must sum to the total across all ledger rows")
|
||||
assert.InDelta(t, wantCache, cache, 1e-12, "buckets must sum to the cache spend across all ledger rows")
|
||||
|
||||
// A month bucket over the same rows must total identically — regrouping
|
||||
// changes the partition, never the sum.
|
||||
monthly := AggregateUsageByGranularity(rows, UsageGranularityMonth)
|
||||
require.Len(t, monthly, 1)
|
||||
assert.InDelta(t, wantTotal, monthly[0].ToAPIResponse().CostUsd, 1e-12,
|
||||
"re-bucketing at a different granularity must preserve the total")
|
||||
}
|
||||
@@ -25,19 +25,8 @@ type AgentNetworkUsage struct {
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
TotalTokens int64
|
||||
// Prompt-cache buckets: read + write token counts.
|
||||
CachedInputTokens int64
|
||||
CacheCreationTokens int64
|
||||
// Per-bucket cost breakdown, mirroring AgentNetworkAccessLog — the only
|
||||
// cost state stored; total and cache portion are derived on read. Kept on
|
||||
// the usage ledger too so spend can be attributed per bucket even for
|
||||
// accounts with log collection turned off. See AgentNetworkAccessLog for
|
||||
// why the columns carry a zero default.
|
||||
InputCostUSD float64 `gorm:"not null;default:0"`
|
||||
CachedInputCostUSD float64 `gorm:"not null;default:0"`
|
||||
CacheCreationCostUSD float64 `gorm:"not null;default:0"`
|
||||
OutputCostUSD float64 `gorm:"not null;default:0"`
|
||||
CreatedAt time.Time
|
||||
CostUSD float64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// TableName keeps usage records in their own stripped table. Named
|
||||
@@ -45,17 +34,6 @@ type AgentNetworkUsage struct {
|
||||
// agent_network_usage table in a shared database.
|
||||
func (AgentNetworkUsage) TableName() string { return "agent_network_request_usage" }
|
||||
|
||||
// TotalCostUSD is the request's total cost: the sum of the four per-bucket
|
||||
// costs. Derived rather than stored so it cannot disagree with the breakdown.
|
||||
func (u *AgentNetworkUsage) TotalCostUSD() float64 {
|
||||
return u.InputCostUSD + u.CachedInputCostUSD + u.CacheCreationCostUSD + u.OutputCostUSD
|
||||
}
|
||||
|
||||
// CacheCostUSD is the portion of the total billed for prompt-cache buckets.
|
||||
func (u *AgentNetworkUsage) CacheCostUSD() float64 {
|
||||
return u.CachedInputCostUSD + u.CacheCreationCostUSD
|
||||
}
|
||||
|
||||
// AgentNetworkUsageGroup is the normalised many-to-many row linking a usage
|
||||
// record to one authorising group, mirroring AgentNetworkAccessLogGroup so the
|
||||
// usage overview can filter by group with a `group_id IN (...)` join.
|
||||
|
||||
@@ -33,45 +33,21 @@ func ParseUsageGranularity(s string) UsageGranularity {
|
||||
// AgentNetworkUsageBucket is one aggregated usage time bucket. PeriodStart is
|
||||
// the UTC start of the bucket as YYYY-MM-DD.
|
||||
type AgentNetworkUsageBucket struct {
|
||||
PeriodStart string
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
TotalTokens int64
|
||||
CachedInputTokens int64
|
||||
CacheCreationTokens int64
|
||||
InputCostUSD float64
|
||||
CachedInputCostUSD float64
|
||||
CacheCreationCostUSD float64
|
||||
OutputCostUSD float64
|
||||
}
|
||||
|
||||
// TotalCostUSD is the bucket's total spend: the sum of the four per-bucket
|
||||
// costs. Derived rather than accumulated separately so it cannot disagree with
|
||||
// the components.
|
||||
func (b *AgentNetworkUsageBucket) TotalCostUSD() float64 {
|
||||
return b.InputCostUSD + b.CachedInputCostUSD + b.CacheCreationCostUSD + b.OutputCostUSD
|
||||
}
|
||||
|
||||
// CacheCostUSD is the bucket's prompt-cache spend: cache reads plus writes.
|
||||
func (b *AgentNetworkUsageBucket) CacheCostUSD() float64 {
|
||||
return b.CachedInputCostUSD + b.CacheCreationCostUSD
|
||||
PeriodStart string
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
TotalTokens int64
|
||||
CostUSD float64
|
||||
}
|
||||
|
||||
// ToAPIResponse renders the bucket as the API representation.
|
||||
func (b *AgentNetworkUsageBucket) ToAPIResponse() api.AgentNetworkUsageBucket {
|
||||
return api.AgentNetworkUsageBucket{
|
||||
PeriodStart: b.PeriodStart,
|
||||
InputTokens: b.InputTokens,
|
||||
OutputTokens: b.OutputTokens,
|
||||
TotalTokens: b.TotalTokens,
|
||||
CachedInputTokens: b.CachedInputTokens,
|
||||
CacheCreationTokens: b.CacheCreationTokens,
|
||||
InputCostUsd: b.InputCostUSD,
|
||||
CachedInputCostUsd: b.CachedInputCostUSD,
|
||||
CacheCreationCostUsd: b.CacheCreationCostUSD,
|
||||
OutputCostUsd: b.OutputCostUSD,
|
||||
CostUsd: b.TotalCostUSD(),
|
||||
CacheCostUsd: b.CacheCostUSD(),
|
||||
PeriodStart: b.PeriodStart,
|
||||
InputTokens: b.InputTokens,
|
||||
OutputTokens: b.OutputTokens,
|
||||
TotalTokens: b.TotalTokens,
|
||||
CostUsd: b.CostUSD,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,12 +84,7 @@ func AggregateUsageByGranularity(rows []*AgentNetworkUsage, g UsageGranularity)
|
||||
b.InputTokens += r.InputTokens
|
||||
b.OutputTokens += r.OutputTokens
|
||||
b.TotalTokens += r.TotalTokens
|
||||
b.CachedInputTokens += r.CachedInputTokens
|
||||
b.CacheCreationTokens += r.CacheCreationTokens
|
||||
b.InputCostUSD += r.InputCostUSD
|
||||
b.CachedInputCostUSD += r.CachedInputCostUSD
|
||||
b.CacheCreationCostUSD += r.CacheCreationCostUSD
|
||||
b.OutputCostUSD += r.OutputCostUSD
|
||||
b.CostUSD += r.CostUSD
|
||||
}
|
||||
|
||||
out := make([]*AgentNetworkUsageBucket, 0, len(byPeriod))
|
||||
|
||||
127
management/internals/network_map_db/db_store.go
Normal file
127
management/internals/network_map_db/db_store.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package networkmapdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
const (
|
||||
NMAP_STRUCT_TAG = "nmap"
|
||||
NMAP_SKIP = "skip"
|
||||
NMAP_MAP_TO = "map_to"
|
||||
)
|
||||
|
||||
type NetworkMapDBStore interface {
|
||||
GetGroups(ctx context.Context, accountId string) ([]nmdata.Group, error)
|
||||
GetPeers(ctx context.Context, accountId string) ([]nmdata.Peer, error)
|
||||
GetPolicies(ctx context.Context, accountId string) ([]nmdata.Policy, error)
|
||||
GetRoutes(ctx context.Context, accountId string) ([]nmdata.Route, error)
|
||||
GetNameServerGroups(ctx context.Context, accountId string) ([]nmdata.NameServerGroup, error)
|
||||
GetNetworkResources(ctx context.Context, accountId string) ([]nmdata.NetworkResource, error)
|
||||
GetNetworkRouters(ctx context.Context, accountId string) ([]nmdata.NetworkRouter, error)
|
||||
GetNetwork(ctx context.Context, accountId string) (nmdata.Network, error)
|
||||
GetAccountZones(ctx context.Context, accountId string) ([]nmdata.CustomZone, error)
|
||||
GetAccountSettings(ctx context.Context, accountId string) (nmdata.AccountSettingsInfo, error)
|
||||
GetPostureChecks(ctx context.Context, accountId string) ([]nmdata.PostureChecks, error)
|
||||
GetNetworkMapData(ctx context.Context, accountId string) (*networkmap.NetworkMapData, error)
|
||||
}
|
||||
|
||||
type NetworkMapDBStoreImpl struct {
|
||||
store NetworkMapDBStore
|
||||
}
|
||||
|
||||
func FromSqlTypesToSharedTypes(src reflect.Value, dst reflect.Value) error {
|
||||
typ := src.Elem().Type()
|
||||
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
f := typ.Field(i)
|
||||
|
||||
fieldTags := make(map[string]string)
|
||||
if v := f.Tag.Get(NMAP_STRUCT_TAG); v != "" {
|
||||
for _, t := range strings.Split(v, ",") {
|
||||
kv := tagFromString(t)
|
||||
fieldTags[kv.Key] = kv.Value
|
||||
}
|
||||
}
|
||||
if _, ok := fieldTags[NMAP_SKIP]; ok {
|
||||
continue
|
||||
}
|
||||
if f.PkgPath != "" { // skip unexported fields
|
||||
continue
|
||||
}
|
||||
dstFieldName := f.Name
|
||||
if override, ok := fieldTags[NMAP_MAP_TO]; ok {
|
||||
dstFieldName = override
|
||||
}
|
||||
|
||||
dstField := dst.Elem().FieldByName(dstFieldName)
|
||||
if !dstField.IsValid() {
|
||||
return errors.New("unsupported type in destination field: " + dstFieldName)
|
||||
}
|
||||
|
||||
srcField := src.Elem().Field(i)
|
||||
srcFieldType := srcField.Type().String()
|
||||
switch srcFieldType {
|
||||
case "string":
|
||||
s := srcField.Interface().(string)
|
||||
dstField.SetString(s)
|
||||
case "sql.NullString":
|
||||
s := srcField.Interface().(sql.NullString)
|
||||
if s.Valid {
|
||||
dstField.SetString(s.String)
|
||||
}
|
||||
case "sql.NullTime":
|
||||
s := srcField.Interface().(sql.NullTime)
|
||||
if s.Valid {
|
||||
if dstField.Kind() == reflect.Ptr {
|
||||
t := reflect.ValueOf(&s.Time).Elem()
|
||||
dstField.Set(t.Addr())
|
||||
} else {
|
||||
dstField.Set(reflect.ValueOf(s.Time))
|
||||
}
|
||||
}
|
||||
case "sql.NullBool":
|
||||
s := srcField.Interface().(sql.NullBool)
|
||||
if s.Valid {
|
||||
dstField.SetBool(s.Bool)
|
||||
}
|
||||
case "sql.NullInt64":
|
||||
s := srcField.Interface().(sql.NullInt64)
|
||||
if s.Valid {
|
||||
dstField.SetInt(s.Int64)
|
||||
}
|
||||
case "json.RawMessage":
|
||||
s := srcField.Interface().(json.RawMessage)
|
||||
json.Unmarshal(s, dstField.Addr().Interface())
|
||||
case "[]string":
|
||||
if srcField.IsNil() {
|
||||
return nil
|
||||
}
|
||||
dstv := reflect.MakeSlice(dstField.Type(), srcField.Len(), srcField.Cap())
|
||||
reflect.Copy(dstv, srcField)
|
||||
dstField.Set(dstv)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type fieldTag struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
func tagFromString(t string) fieldTag {
|
||||
kv := strings.Split(t, ":")
|
||||
if len(kv) == 1 {
|
||||
return fieldTag{Key: strings.TrimSpace(kv[0])}
|
||||
}
|
||||
return fieldTag{Key: strings.TrimSpace(kv[0]), Value: strings.TrimSpace(kv[1])}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package networkmap_pgsql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
const (
|
||||
GetAccountSettingsQuery = `
|
||||
select settings_peer_login_expiration_enabled as peer_login_expiration_enabled,
|
||||
settings_peer_login_expiration as peer_login_expiration,
|
||||
settings_peer_inactivity_expiration_enabled as peer_inactivity_expiration_enabled,
|
||||
settings_peer_inactivity_expiration as peer_inactivity_expiration
|
||||
from accounts
|
||||
where id=$1
|
||||
`
|
||||
)
|
||||
|
||||
func (pg *PgStore) GetAccountSettings(ctx context.Context, accountId string) (nmdata.AccountSettingsInfo, error) {
|
||||
c, err := pg.Pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return nmdata.AccountSettingsInfo{}, err
|
||||
}
|
||||
return GetAccountSettingsViaPgxConnection(ctx, c.Conn(), accountId)
|
||||
|
||||
}
|
||||
|
||||
func GetAccountSettingsViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) (nmdata.AccountSettingsInfo, error) {
|
||||
rows, err := con.Query(ctx, GetAccountSettingsQuery, accountId)
|
||||
if err != nil {
|
||||
return nmdata.AccountSettingsInfo{}, err
|
||||
}
|
||||
|
||||
settings, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[account])
|
||||
if err != nil {
|
||||
return nmdata.AccountSettingsInfo{}, err
|
||||
}
|
||||
|
||||
return nmdata.AccountSettingsInfo{
|
||||
PeerLoginExpirationEnabled: settings.PeerLoginExpirationEnabled.Bool,
|
||||
PeerLoginExpiration: time.Duration(settings.PeerLoginExpiration.Int64),
|
||||
PeerInactivityExpirationEnabled: settings.PeerLoginExpirationEnabled.Bool,
|
||||
PeerInactivityExpiration: time.Duration(settings.PeerInactivityExpiration.Int64),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type account struct {
|
||||
PeerLoginExpirationEnabled sql.NullBool
|
||||
PeerLoginExpiration sql.NullInt64
|
||||
PeerInactivityExpirationEnabled sql.NullBool
|
||||
PeerInactivityExpiration sql.NullInt64
|
||||
}
|
||||
110
management/internals/network_map_db/pgsql/dns.go
Normal file
110
management/internals/network_map_db/pgsql/dns.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package networkmap_pgsql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/miekg/dns"
|
||||
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
var DnsUnsupportedRecordTypeError = errors.New("unsupported record type")
|
||||
|
||||
const (
|
||||
GetAccountZonesQuery = `
|
||||
select zones.id as id, domain, enable_search_domain as search_domain_disabled,
|
||||
r.name as record_name, r.type as record_type, 'IN' record_class, r.ttl as record_ttl, r.content as record_rdata
|
||||
from zones
|
||||
left join records as r on r.zone_id = zones.id
|
||||
where zones.account_id=$1
|
||||
`
|
||||
)
|
||||
|
||||
func (pg *PgStore) GetAccountZones(ctx context.Context, accountId string) ([]nmdata.CustomZone, error) {
|
||||
c, err := pg.Pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return GetAccountZonesViaPgxConnection(ctx, c.Conn(), accountId)
|
||||
}
|
||||
|
||||
func GetAccountZonesViaPgxConnection(ctx context.Context, conn *pgx.Conn, accountId string) ([]nmdata.CustomZone, error) {
|
||||
rows, err := conn.Query(ctx, GetAccountZonesQuery, accountId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
zones, err := pgx.CollectRows(rows, pgx.RowToStructByName[zone])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
toret := make([]nmdata.CustomZone, 0, len(zones))
|
||||
currentZoneId := ""
|
||||
for _, z := range zones {
|
||||
zone := nmdata.CustomZone{}
|
||||
err := networkmapdb.FromSqlTypesToSharedTypes(
|
||||
reflect.ValueOf(&z), reflect.ValueOf(&zone))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rtype, rdata, err := recordTypeAndRdata(z.RecordType.String, z.RecordRData.String)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record := nmdata.SimpleRecord{
|
||||
Name: z.RecordName.String,
|
||||
Class: z.RecordClass.String,
|
||||
TTL: int(z.RecordTTL.Int64),
|
||||
RData: rdata,
|
||||
Type: rtype,
|
||||
}
|
||||
zone.Records = []nmdata.SimpleRecord{record}
|
||||
|
||||
if len(toret) == 0 {
|
||||
toret = append(toret, zone)
|
||||
currentZoneId = z.Id
|
||||
continue
|
||||
}
|
||||
|
||||
if z.Id == currentZoneId {
|
||||
lastZone := &toret[len(toret)-1]
|
||||
lastZone.Records = append(lastZone.Records, record)
|
||||
continue
|
||||
}
|
||||
|
||||
toret = append(toret, zone)
|
||||
currentZoneId = z.Id
|
||||
}
|
||||
return toret, nil
|
||||
}
|
||||
|
||||
type zone struct {
|
||||
Id string `nmap:"skip"`
|
||||
Domain sql.NullString
|
||||
SearchDomainDisabled sql.NullBool
|
||||
RecordName sql.NullString `nmap:"skip"`
|
||||
RecordType sql.NullString `nmap:"skip"`
|
||||
RecordClass sql.NullString `nmap:"skip"`
|
||||
RecordTTL sql.NullInt64 `nmap:"skip"`
|
||||
RecordRData sql.NullString `nmap:"skip"`
|
||||
}
|
||||
|
||||
func recordTypeAndRdata(t, rdata string) (int, string, error) {
|
||||
switch t {
|
||||
case "A":
|
||||
return int(dns.TypeA), rdata, nil
|
||||
case "AAAA":
|
||||
return int(dns.TypeAAAA), rdata, nil
|
||||
case "CNAME":
|
||||
return int(dns.TypeCNAME), dns.Fqdn(rdata), nil
|
||||
default:
|
||||
return 0, "", fmt.Errorf("record type: %s %w", t, DnsUnsupportedRecordTypeError)
|
||||
}
|
||||
}
|
||||
38
management/internals/network_map_db/pgsql/dns_test.go
Normal file
38
management/internals/network_map_db/pgsql/dns_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package networkmap_pgsql
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRecordTypeAndRdata(t *testing.T) {
|
||||
var tests = []struct {
|
||||
recordType string
|
||||
expectedRecordType int
|
||||
rdata string
|
||||
expectedRdata string
|
||||
expectedErr error
|
||||
}{
|
||||
{recordType: "A", expectedRecordType: 1, rdata: "test.com", expectedRdata: "test.com", expectedErr: nil},
|
||||
{recordType: "AAAA", expectedRecordType: 28, rdata: "test.com", expectedRdata: "test.com", expectedErr: nil},
|
||||
{recordType: "CNAME", expectedRecordType: 5, rdata: "test.com", expectedRdata: "test.com.", expectedErr: nil},
|
||||
{recordType: "CNAME", expectedRecordType: 5, rdata: "test.com.", expectedRdata: "test.com.", expectedErr: nil},
|
||||
{recordType: "TypeMX", expectedErr: DnsUnsupportedRecordTypeError},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.recordType, func(t *testing.T) {
|
||||
recordType, rdata, err := recordTypeAndRdata(tt.recordType, tt.rdata)
|
||||
|
||||
if tt.expectedErr != nil {
|
||||
assert.ErrorIs(t, err, DnsUnsupportedRecordTypeError)
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, recordType, tt.expectedRecordType)
|
||||
assert.Equal(t, rdata, tt.expectedRdata)
|
||||
})
|
||||
}
|
||||
}
|
||||
60
management/internals/network_map_db/pgsql/group.go
Normal file
60
management/internals/network_map_db/pgsql/group.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package networkmap_pgsql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
const (
|
||||
GetGroupsQuery = `
|
||||
select name, public_id, resources,
|
||||
(
|
||||
select array_agg(group_peers.peer_id)
|
||||
from group_peers
|
||||
where group_peers.group_id = groups.id
|
||||
) as peers
|
||||
from groups where account_id=$1
|
||||
`
|
||||
)
|
||||
|
||||
func (pg *PgStore) GetGroups(ctx context.Context, accountId string) ([]nmdata.Group, error) {
|
||||
c, err := pg.Pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return GetGroupsViaPgxConnection(ctx, c.Conn(), accountId)
|
||||
}
|
||||
|
||||
func GetGroupsViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) ([]nmdata.Group, error) {
|
||||
rows, err := con.Query(ctx, GetGroupsQuery, accountId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
groups, err := pgx.CollectRows(rows, pgx.RowToStructByName[group])
|
||||
toret := make([]nmdata.Group, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
dg := nmdata.Group{}
|
||||
err := networkmapdb.FromSqlTypesToSharedTypes(
|
||||
reflect.ValueOf(&g), reflect.ValueOf(&dg))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toret = append(toret, dg)
|
||||
}
|
||||
|
||||
return toret, err
|
||||
}
|
||||
|
||||
type group struct {
|
||||
Name sql.NullString
|
||||
PublicID sql.NullString
|
||||
Resources json.RawMessage
|
||||
Peers []string
|
||||
}
|
||||
270
management/internals/network_map_db/pgsql/group_test.go
Normal file
270
management/internals/network_map_db/pgsql/group_test.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package networkmap_pgsql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "embed"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetGroups(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
|
||||
s, err := NewPostgresqlStore(ctx, "postgresql://root:netbird@localhost:5432/netbird")
|
||||
assert.NoError(t, err)
|
||||
// err = loadSQL(ctx, s.pool, initDb)
|
||||
//assert.NoError(t, err)
|
||||
|
||||
_, err = s.Pool.Query(ctx, "insert into groups (id, account_id, name, resources, public_id) VALUES('test-group-id-1','ck7bnf2t2r9s739pkug0','test-group-1', '[{\"ID\":\"cui7q2jl0ubs73d8qpi0\",\"Type\":\"host\"}]','public-id-1')")
|
||||
assert.NoError(t, err)
|
||||
|
||||
groups, err := s.GetGroups(ctx, "ck7bnf2t2r9s739pkug0") //"ckd7ee2fic3c73dtendg")
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t,
|
||||
groups,
|
||||
nmdata.Group{Name: "test-group-1", PublicID: "public-id-1", Resources: []nmdata.Resource{{ID: "cui7q2jl0ubs73d8qpi0", Type: "host"}}},
|
||||
)
|
||||
assert.Contains(t,
|
||||
groups,
|
||||
nmdata.Group{Name: "All", PublicID: "d9aejspvcsu517nkh4a0", Resources: []nmdata.Resource{{ID: "cui7olrl0ubs73d8qpe0", Type: "subnet"}, {ID: "cui7q2jl0ubs73d8qpi0", Type: "host"}}},
|
||||
)
|
||||
}
|
||||
|
||||
func TestGetPeers(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
|
||||
s, err := NewPostgresqlStore(ctx, "postgresql://root:netbird@localhost:5432/netbird")
|
||||
assert.NoError(t, err)
|
||||
// err = loadSQL(ctx, s.pool, initDb)
|
||||
//assert.NoError(t, err)
|
||||
|
||||
peers, err := s.GetPeers(ctx, "ck7bnf2t2r9s739pkug0") //"ckd7ee2fic3c73dtendg")
|
||||
assert.NoError(t, err)
|
||||
|
||||
fmt.Print(peers)
|
||||
// assert.Contains(t,
|
||||
// groups,
|
||||
// nmdata.Group{Name: "test-group-1", PublicID: "public-id-1", Resources: []nmdata.Resource{{ID: "cui7q2jl0ubs73d8qpi0", Type: "host"}}},
|
||||
// )
|
||||
// assert.Contains(t,
|
||||
// groups,
|
||||
// nmdata.Group{Name: "All", PublicID: "d9aejspvcsu517nkh4a0", Resources: []nmdata.Resource{{ID: "cui7olrl0ubs73d8qpe0", Type: "subnet"}, {ID: "cui7q2jl0ubs73d8qpi0", Type: "host"}}},
|
||||
// )
|
||||
}
|
||||
|
||||
func TestGetPolocies(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
|
||||
s, err := NewPostgresqlStore(ctx, "postgresql://root:netbird@localhost:5432/netbird")
|
||||
assert.NoError(t, err)
|
||||
// err = loadSQL(ctx, s.pool, initDb)
|
||||
//assert.NoError(t, err)
|
||||
|
||||
peers, err := s.GetPolicies(ctx, "ck7bnf2t2r9s739pkug0") //"ckd7ee2fic3c73dtendg")
|
||||
assert.NoError(t, err)
|
||||
|
||||
fmt.Print(peers)
|
||||
// assert.Contains(t,
|
||||
// groups,
|
||||
// nmdata.Group{Name: "test-group-1", PublicID: "public-id-1", Resources: []nmdata.Resource{{ID: "cui7q2jl0ubs73d8qpi0", Type: "host"}}},
|
||||
// )
|
||||
// assert.Contains(t,
|
||||
// groups,
|
||||
// nmdata.Group{Name: "All", PublicID: "d9aejspvcsu517nkh4a0", Resources: []nmdata.Resource{{ID: "cui7olrl0ubs73d8qpe0", Type: "subnet"}, {ID: "cui7q2jl0ubs73d8qpi0", Type: "host"}}},
|
||||
// )
|
||||
}
|
||||
|
||||
func TestGetRoutes(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
|
||||
s, err := NewPostgresqlStore(ctx, "postgresql://root:netbird@localhost:5432/netbird")
|
||||
assert.NoError(t, err)
|
||||
// err = loadSQL(ctx, s.pool, initDb)
|
||||
//assert.NoError(t, err)
|
||||
|
||||
peers, err := s.GetRoutes(ctx, "csg5iabl0ubs7398nf1g") //"ckd7ee2fic3c73dtendg")
|
||||
assert.NoError(t, err)
|
||||
|
||||
fmt.Print(peers)
|
||||
// assert.Contains(t,
|
||||
// groups,
|
||||
// nmdata.Group{Name: "test-group-1", PublicID: "public-id-1", Resources: []nmdata.Resource{{ID: "cui7q2jl0ubs73d8qpi0", Type: "host"}}},
|
||||
// )
|
||||
// assert.Contains(t,
|
||||
// groups,
|
||||
// nmdata.Group{Name: "All", PublicID: "d9aejspvcsu517nkh4a0", Resources: []nmdata.Resource{{ID: "cui7olrl0ubs73d8qpe0", Type: "subnet"}, {ID: "cui7q2jl0ubs73d8qpi0", Type: "host"}}},
|
||||
// )
|
||||
}
|
||||
|
||||
func TestGetNSGroups(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
|
||||
s, err := NewPostgresqlStore(ctx, "postgresql://root:netbird@localhost:5432/netbird")
|
||||
assert.NoError(t, err)
|
||||
// err = loadSQL(ctx, s.pool, initDb)
|
||||
//assert.NoError(t, err)
|
||||
|
||||
groups, err := s.GetNameServerGroups(ctx, "cl3h77qfic3c738mkja0") //"ckd7ee2fic3c73dtendg")
|
||||
assert.NoError(t, err)
|
||||
|
||||
fmt.Print(groups)
|
||||
// assert.Contains(t,
|
||||
// groups,
|
||||
// nmdata.Group{Name: "test-group-1", PublicID: "public-id-1", Resources: []nmdata.Resource{{ID: "cui7q2jl0ubs73d8qpi0", Type: "host"}}},
|
||||
// )
|
||||
// assert.Contains(t,
|
||||
// groups,
|
||||
// nmdata.Group{Name: "All", PublicID: "d9aejspvcsu517nkh4a0", Resources: []nmdata.Resource{{ID: "cui7olrl0ubs73d8qpe0", Type: "subnet"}, {ID: "cui7q2jl0ubs73d8qpi0", Type: "host"}}},
|
||||
// )
|
||||
}
|
||||
|
||||
func TestGetNetworkResources(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
|
||||
s, err := NewPostgresqlStore(ctx, "postgresql://root:netbird@localhost:5432/netbird")
|
||||
assert.NoError(t, err)
|
||||
// err = loadSQL(ctx, s.pool, initDb)
|
||||
//assert.NoError(t, err)
|
||||
|
||||
res, err := s.GetNetworkResources(ctx, "cag86v2t2r9s73d0416g") //"ckd7ee2fic3c73dtendg")
|
||||
assert.NoError(t, err)
|
||||
|
||||
fmt.Print(res)
|
||||
// assert.Contains(t,
|
||||
// groups,
|
||||
// nmdata.Group{Name: "test-group-1", PublicID: "public-id-1", Resources: []nmdata.Resource{{ID: "cui7q2jl0ubs73d8qpi0", Type: "host"}}},
|
||||
// )
|
||||
// assert.Contains(t,
|
||||
// groups,
|
||||
// nmdata.Group{Name: "All", PublicID: "d9aejspvcsu517nkh4a0", Resources: []nmdata.Resource{{ID: "cui7olrl0ubs73d8qpe0", Type: "subnet"}, {ID: "cui7q2jl0ubs73d8qpi0", Type: "host"}}},
|
||||
// )
|
||||
}
|
||||
|
||||
func TestGetNetworkRouters(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
|
||||
s, err := NewPostgresqlStore(ctx, "postgresql://root:netbird@localhost:5432/netbird")
|
||||
assert.NoError(t, err)
|
||||
// err = loadSQL(ctx, s.pool, initDb)
|
||||
//assert.NoError(t, err)
|
||||
|
||||
res, err := s.GetNetworkRouters(ctx, "d29f99jl0ubs73cm8ce0") //"ckd7ee2fic3c73dtendg")
|
||||
assert.NoError(t, err)
|
||||
|
||||
fmt.Print(res)
|
||||
// assert.Contains(t,
|
||||
// groups,
|
||||
// nmdata.Group{Name: "test-group-1", PublicID: "public-id-1", Resources: []nmdata.Resource{{ID: "cui7q2jl0ubs73d8qpi0", Type: "host"}}},
|
||||
// )
|
||||
// assert.Contains(t,
|
||||
// groups,
|
||||
// nmdata.Group{Name: "All", PublicID: "d9aejspvcsu517nkh4a0", Resources: []nmdata.Resource{{ID: "cui7olrl0ubs73d8qpe0", Type: "subnet"}, {ID: "cui7q2jl0ubs73d8qpi0", Type: "host"}}},
|
||||
// )
|
||||
}
|
||||
|
||||
func TestGetNetwork(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
|
||||
s, err := NewPostgresqlStore(ctx, "postgresql://root:netbird@localhost:5432/netbird")
|
||||
assert.NoError(t, err)
|
||||
// err = loadSQL(ctx, s.pool, initDb)
|
||||
//assert.NoError(t, err)
|
||||
|
||||
n, err := s.GetNetwork(ctx, "d29f99jl0ubs73cm8ce0") //"ckd7ee2fic3c73dtendg")
|
||||
assert.NoError(t, err)
|
||||
|
||||
fmt.Print(n)
|
||||
// assert.Contains(t,
|
||||
// groups,
|
||||
// nmdata.Group{Name: "test-group-1", PublicID: "public-id-1", Resources: []nmdata.Resource{{ID: "cui7q2jl0ubs73d8qpi0", Type: "host"}}},
|
||||
// )
|
||||
// assert.Contains(t,
|
||||
// groups,
|
||||
// nmdata.Group{Name: "All", PublicID: "d9aejspvcsu517nkh4a0", Resources: []nmdata.Resource{{ID: "cui7olrl0ubs73d8qpe0", Type: "subnet"}, {ID: "cui7q2jl0ubs73d8qpi0", Type: "host"}}},
|
||||
// )
|
||||
}
|
||||
|
||||
func TestGetAccountZones(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
|
||||
s, err := NewPostgresqlStore(ctx, "postgresql://root:netbird@localhost:5432/netbird")
|
||||
assert.NoError(t, err)
|
||||
// err = loadSQL(ctx, s.pool, initDb)
|
||||
//assert.NoError(t, err)
|
||||
|
||||
zones, err := s.GetAccountZones(ctx, "d4g66rjl0ubs73b2q3b0") //"ckd7ee2fic3c73dtendg")
|
||||
assert.NoError(t, err)
|
||||
|
||||
fmt.Print(zones)
|
||||
// assert.Contains(t,
|
||||
// groups,
|
||||
// nmdata.Group{Name: "test-group-1", PublicID: "public-id-1", Resources: []nmdata.Resource{{ID: "cui7q2jl0ubs73d8qpi0", Type: "host"}}},
|
||||
// )
|
||||
// assert.Contains(t,
|
||||
// groups,
|
||||
// nmdata.Group{Name: "All", PublicID: "d9aejspvcsu517nkh4a0", Resources: []nmdata.Resource{{ID: "cui7olrl0ubs73d8qpe0", Type: "subnet"}, {ID: "cui7q2jl0ubs73d8qpi0", Type: "host"}}},
|
||||
// )
|
||||
}
|
||||
|
||||
func TestGetAccountSetings(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
|
||||
s, err := NewPostgresqlStore(ctx, "postgresql://root:netbird@localhost:5432/netbird")
|
||||
assert.NoError(t, err)
|
||||
// err = loadSQL(ctx, s.pool, initDb)
|
||||
//assert.NoError(t, err)
|
||||
|
||||
settings, err := s.GetAccountSettings(ctx, "d5n27dafadhs73bt5ovg") //"ckd7ee2fic3c73dtendg")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, nmdata.AccountSettingsInfo{
|
||||
PeerLoginExpirationEnabled: true,
|
||||
PeerLoginExpiration: 86400000000000 * time.Nanosecond,
|
||||
PeerInactivityExpirationEnabled: true,
|
||||
PeerInactivityExpiration: 600000000000 * time.Nanosecond,
|
||||
}, settings)
|
||||
}
|
||||
|
||||
func TestGetPostureChecks(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
|
||||
s, err := NewPostgresqlStore(ctx, "postgresql://root:netbird@localhost:5432/netbird")
|
||||
assert.NoError(t, err)
|
||||
// err = loadSQL(ctx, s.pool, initDb)
|
||||
//assert.NoError(t, err)
|
||||
|
||||
checks, err := s.GetPostureChecks(ctx, "cdfcks2t2r9s73a58us0") //"ckd7ee2fic3c73dtendg")
|
||||
assert.NoError(t, err)
|
||||
|
||||
fmt.Print(checks)
|
||||
// assert.Contains(t,
|
||||
// groups,
|
||||
// nmdata.Group{Name: "test-group-1", PublicID: "public-id-1", Resources: []nmdata.Resource{{ID: "cui7q2jl0ubs73d8qpi0", Type: "host"}}},
|
||||
// )
|
||||
// assert.Contains(t,
|
||||
// groups,
|
||||
// nmdata.Group{Name: "All", PublicID: "d9aejspvcsu517nkh4a0", Resources: []nmdata.Resource{{ID: "cui7olrl0ubs73d8qpe0", Type: "subnet"}, {ID: "cui7q2jl0ubs73d8qpi0", Type: "host"}}},
|
||||
// )
|
||||
}
|
||||
|
||||
func loadSQL(ctx context.Context, pool *pgxpool.Pool, initdb string) error {
|
||||
queries := strings.Split(string(initdb), ";")
|
||||
|
||||
for _, query := range queries {
|
||||
query = strings.TrimSpace(query)
|
||||
if query != "" {
|
||||
_, err := pool.Query(ctx, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
65
management/internals/network_map_db/pgsql/nameserver.go
Normal file
65
management/internals/network_map_db/pgsql/nameserver.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package networkmap_pgsql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
const (
|
||||
GetNameserversQuery = `
|
||||
select id, public_id, name, description, name_servers, groups, "primary", domains, enabled, search_domains_enabled
|
||||
from name_server_groups
|
||||
where account_id=$1
|
||||
`
|
||||
)
|
||||
|
||||
func (pg *PgStore) GetNameServerGroups(ctx context.Context, accountId string) ([]nmdata.NameServerGroup, error) {
|
||||
c, err := pg.Pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return GetNameServerGroupsViaPgxConnection(ctx, c.Conn(), accountId)
|
||||
}
|
||||
|
||||
func GetNameServerGroupsViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) ([]nmdata.NameServerGroup, error) {
|
||||
rows, err := con.Query(ctx, GetNameserversQuery, accountId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nsgroups, err := pgx.CollectRows(rows, pgx.RowToStructByName[nameserverGroup])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
toret := make([]nmdata.NameServerGroup, 0, len(nsgroups))
|
||||
for _, nsg := range nsgroups {
|
||||
group := nmdata.NameServerGroup{}
|
||||
err := networkmapdb.FromSqlTypesToSharedTypes(
|
||||
reflect.ValueOf(&nsg), reflect.ValueOf(&group))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toret = append(toret, group)
|
||||
}
|
||||
return toret, nil
|
||||
}
|
||||
|
||||
type nameserverGroup struct {
|
||||
ID string
|
||||
PublicID sql.NullString
|
||||
Name sql.NullString
|
||||
Description sql.NullString
|
||||
NameServers json.RawMessage
|
||||
Groups json.RawMessage
|
||||
Primary sql.NullBool
|
||||
Domains json.RawMessage
|
||||
Enabled sql.NullBool
|
||||
SearchDomainsEnabled sql.NullBool
|
||||
}
|
||||
57
management/internals/network_map_db/pgsql/network.go
Normal file
57
management/internals/network_map_db/pgsql/network.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package networkmap_pgsql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
const (
|
||||
GetNetworkQuery = `
|
||||
select network_identifier as identifier, network_net as net, network_net_v6 as net_v6, network_dns as dns, network_serial as serial
|
||||
from accounts
|
||||
where id=$1
|
||||
`
|
||||
)
|
||||
|
||||
func (pg *PgStore) GetNetwork(ctx context.Context, accountId string) (nmdata.Network, error) {
|
||||
c, err := pg.Pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return nmdata.Network{}, err
|
||||
}
|
||||
return GetNetworkViaPgxConnection(ctx, c.Conn(), accountId)
|
||||
}
|
||||
|
||||
func GetNetworkViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) (nmdata.Network, error) {
|
||||
rows, err := con.Query(ctx, GetNetworkQuery, accountId)
|
||||
if err != nil {
|
||||
return nmdata.Network{}, err
|
||||
}
|
||||
|
||||
n, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[accountnetwork])
|
||||
if err != nil {
|
||||
return nmdata.Network{}, err
|
||||
}
|
||||
|
||||
toret := nmdata.Network{}
|
||||
err = networkmapdb.FromSqlTypesToSharedTypes(
|
||||
reflect.ValueOf(&n), reflect.ValueOf(&toret))
|
||||
if err != nil {
|
||||
return nmdata.Network{}, err
|
||||
}
|
||||
|
||||
return toret, nil
|
||||
}
|
||||
|
||||
type accountnetwork struct {
|
||||
Identifier sql.NullString
|
||||
Net json.RawMessage
|
||||
NetV6 json.RawMessage
|
||||
Dns sql.NullString
|
||||
Serial sql.NullInt64
|
||||
}
|
||||
148
management/internals/network_map_db/pgsql/network_map_data.go
Normal file
148
management/internals/network_map_db/pgsql/network_map_data.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package networkmap_pgsql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
func (pg *PgStore) GetNetworkMapData(ctx context.Context, accountId string) (*networkmap.NetworkMapData, error) {
|
||||
tx, err := pg.Pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// acctSettings, err := GetAccountSettingsViaPgxConnection(ctx, tx.Conn(), accountId)
|
||||
// if err != nil {
|
||||
// return rollbackAndReturnError(ctx, tx, err)
|
||||
// }
|
||||
// dnsZones, err := GetAccountZonesViaPgxConnection(ctx, tx.Conn(), accountId)
|
||||
// if err != nil {
|
||||
// return rollbackAndReturnError(ctx, tx, err)
|
||||
// }
|
||||
groups, err := GetGroupsViaPgxConnection(ctx, tx.Conn(), accountId)
|
||||
if err != nil {
|
||||
return rollbackAndReturnError(ctx, tx, err)
|
||||
}
|
||||
nsGroups, err := GetNameServerGroupsViaPgxConnection(ctx, tx.Conn(), accountId)
|
||||
if err != nil {
|
||||
return rollbackAndReturnError(ctx, tx, err)
|
||||
}
|
||||
networkResources, err := GetNetworkResourcesViaPgxConnection(ctx, tx.Conn(), accountId)
|
||||
if err != nil {
|
||||
return rollbackAndReturnError(ctx, tx, err)
|
||||
}
|
||||
// TODO (dmitri) this needs cleaning up -- returns an internal struct
|
||||
routers, err := GetNetworkRoutersViaPgxConnection(ctx, tx.Conn(), accountId)
|
||||
if err != nil {
|
||||
return rollbackAndReturnError(ctx, tx, err)
|
||||
}
|
||||
network, err := GetNetworkViaPgxConnection(ctx, tx.Conn(), accountId)
|
||||
if err != nil {
|
||||
return rollbackAndReturnError(ctx, tx, err)
|
||||
}
|
||||
peers, err := GetPeersViaPgxConnection(ctx, tx.Conn(), accountId)
|
||||
if err != nil {
|
||||
return rollbackAndReturnError(ctx, tx, err)
|
||||
}
|
||||
policies, err := GetPoliciesViaPgxConnection(ctx, tx.Conn(), accountId)
|
||||
if err != nil {
|
||||
return rollbackAndReturnError(ctx, tx, err)
|
||||
}
|
||||
postureChecks, err := GetPostureChecksViaPgxConnection(ctx, tx.Conn(), accountId)
|
||||
if err != nil {
|
||||
return rollbackAndReturnError(ctx, tx, err)
|
||||
}
|
||||
routes, err := GetRoutesViaPgxConnection(ctx, tx.Conn(), accountId)
|
||||
if err != nil {
|
||||
return rollbackAndReturnError(ctx, tx, err)
|
||||
}
|
||||
networkXIDToPublicID, err := GetNetworkXIDToPublicIdMapViaPgxConnection(ctx, tx.Conn(), accountId)
|
||||
if err != nil {
|
||||
return rollbackAndReturnError(ctx, tx, err)
|
||||
}
|
||||
|
||||
err = tx.Commit(ctx)
|
||||
if err != nil {
|
||||
// TODO log and ignore?
|
||||
}
|
||||
|
||||
toret := networkmap.NetworkMapData{
|
||||
Network: &network,
|
||||
Peers: toMap(peers, func(p nmdata.Peer) string { return p.ID }),
|
||||
Groups: toMap(groups, func(g nmdata.Group) string { return g.PublicID }),
|
||||
Policies: toSliceOfPtrs(policies),
|
||||
Routes: toSliceOfPtrs(routes),
|
||||
NameServerGroups: toSliceOfPtrs(nsGroups),
|
||||
NetworkResources: toSliceOfPtrs(networkResources),
|
||||
PostureChecks: toMap(postureChecks, func(pc nmdata.PostureChecks) string { return pc.ID }),
|
||||
NetworkXIDToPublicID: networkXIDToPublicID, // TODO (dmitri-d) do we still need it now?
|
||||
}
|
||||
|
||||
networktoRouters := make(map[string]map[string]*nmdata.NetworkRouter)
|
||||
for _, router := range routers {
|
||||
if !router.Enabled.Bool {
|
||||
continue
|
||||
}
|
||||
|
||||
if router.NetworkID.String == "" {
|
||||
return nil, fmt.Errorf("router with public_id %s doesn't have network_id set", router.PublicID.String)
|
||||
}
|
||||
networkPublicId := networkXIDToPublicID[router.NetworkID.String]
|
||||
if networkPublicId == "" {
|
||||
return nil, fmt.Errorf("network with id %s has no public_id", router.NetworkID.String)
|
||||
}
|
||||
|
||||
nmdatarouter := nmdata.NetworkRouter{}
|
||||
err := networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&router), reflect.ValueOf(&nmdatarouter))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if networktoRouters[networkPublicId] == nil {
|
||||
networktoRouters[networkPublicId] = make(map[string]*nmdata.NetworkRouter)
|
||||
}
|
||||
if router.Peer.String != "" {
|
||||
networktoRouters[networkPublicId][router.Peer.String] = &nmdatarouter
|
||||
}
|
||||
for _, peerGroup := range nmdatarouter.PeerGroups {
|
||||
g := toret.Groups[peerGroup]
|
||||
if g != nil {
|
||||
for _, peerID := range g.Peers {
|
||||
networktoRouters[networkPublicId][peerID] = &nmdatarouter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
toret.Routers = networktoRouters
|
||||
|
||||
return &toret, nil
|
||||
}
|
||||
|
||||
func rollbackAndReturnError(ctx context.Context, tx pgx.Tx, err error) (*networkmap.NetworkMapData, error) {
|
||||
if errr := tx.Rollback(ctx); errr != nil {
|
||||
// TODO log and ignore?
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func toMap[T any](all []T, id func(t T) string) map[string]*T {
|
||||
toret := make(map[string]*T, len(all))
|
||||
for _, t := range all {
|
||||
toret[id(t)] = &t
|
||||
}
|
||||
return toret
|
||||
}
|
||||
|
||||
func toSliceOfPtrs[T any](all []T) []*T {
|
||||
toret := make([]*T, len(all))
|
||||
for _, t := range all {
|
||||
toret = append(toret, &t)
|
||||
}
|
||||
return toret
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package networkmap_pgsql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
const (
|
||||
GetNetworkResourcesQuery = `
|
||||
select id, network_id, account_id, public_id, name, description, type, domain, prefix, enabled
|
||||
from network_resources
|
||||
where account_id=$1
|
||||
`
|
||||
)
|
||||
|
||||
func (pg *PgStore) GetNetworkResources(ctx context.Context, accountId string) ([]nmdata.NetworkResource, error) {
|
||||
c, err := pg.Pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return GetNetworkResourcesViaPgxConnection(ctx, c.Conn(), accountId)
|
||||
}
|
||||
|
||||
func GetNetworkResourcesViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) ([]nmdata.NetworkResource, error) {
|
||||
rows, err := con.Query(ctx, GetNetworkResourcesQuery, accountId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
netresorces, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkresource])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
toret := make([]nmdata.NetworkResource, 0, len(netresorces))
|
||||
for _, nres := range netresorces {
|
||||
resource := nmdata.NetworkResource{}
|
||||
err := networkmapdb.FromSqlTypesToSharedTypes(
|
||||
reflect.ValueOf(&nres), reflect.ValueOf(&resource))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toret = append(toret, resource)
|
||||
}
|
||||
return toret, nil
|
||||
}
|
||||
|
||||
type networkresource struct {
|
||||
ID string
|
||||
NetworkID sql.NullString
|
||||
AccountID sql.NullString
|
||||
PublicID sql.NullString
|
||||
Name sql.NullString
|
||||
Description sql.NullString
|
||||
Type sql.NullString
|
||||
Domain sql.NullString
|
||||
Prefix json.RawMessage
|
||||
Enabled sql.NullBool
|
||||
}
|
||||
59
management/internals/network_map_db/pgsql/network_router.go
Normal file
59
management/internals/network_map_db/pgsql/network_router.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package networkmap_pgsql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
GetNetworkRouterQuery = `
|
||||
select public_id, peer, peer_groups, network_id, masquerade, metric, enabled,
|
||||
from network_routers
|
||||
where account_id=$1
|
||||
`
|
||||
)
|
||||
|
||||
// func (pg *PgStore) GetNetworkRouters(ctx context.Context, accountId string) ([]nmdata.NetworkRouter, error) {
|
||||
// c, err := pg.Pool.Acquire(ctx)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// return GetNetworkRoutersViaPgxConnection(ctx, c.Conn(), accountId)
|
||||
// }
|
||||
|
||||
func GetNetworkRoutersViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) ([]networkrouter, error) {
|
||||
rows, err := con.Query(ctx, GetNetworkRouterQuery, accountId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return pgx.CollectRows(rows, pgx.RowToStructByName[networkrouter])
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
//
|
||||
// toret := make([]nmdata.NetworkRouter, 0, len(netrouters))
|
||||
// for _, nrt := range netrouters {
|
||||
// router := nmdata.NetworkRouter{}
|
||||
// err := networkmapdb.FromSqlTypesToSharedTypes(
|
||||
// reflect.ValueOf(&nrt), reflect.ValueOf(&router))
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// toret = append(toret, router)
|
||||
// }
|
||||
// return toret, nil
|
||||
}
|
||||
|
||||
type networkrouter struct {
|
||||
PublicID sql.NullString
|
||||
NetworkID sql.NullString `nmap:"skip"`
|
||||
Peer sql.NullString `nmap:"skip"`
|
||||
PeerGroups json.RawMessage
|
||||
Masquerade sql.NullBool
|
||||
Metric sql.NullInt64
|
||||
Enabled sql.NullBool
|
||||
}
|
||||
52
management/internals/network_map_db/pgsql/networks.go
Normal file
52
management/internals/network_map_db/pgsql/networks.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package networkmap_pgsql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
GetNetworksQuery = `
|
||||
select id, public_id
|
||||
from networks where account_id=$1
|
||||
`
|
||||
)
|
||||
|
||||
func (pg *PgStore) GetNetworks(ctx context.Context, accountId string) ([]network, error) {
|
||||
c, err := pg.Pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return GetNetworksViaPgxConnection(ctx, c.Conn(), accountId)
|
||||
}
|
||||
|
||||
func GetNetworksViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) ([]network, error) {
|
||||
rows, err := con.Query(ctx, GetGroupsQuery, accountId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pgx.CollectRows(rows, pgx.RowToStructByName[network])
|
||||
}
|
||||
|
||||
func GetNetworkXIDToPublicIdMapViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) (map[string]string, error) {
|
||||
networks, err := GetNetworksViaPgxConnection(ctx, con, accountId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
toret := make(map[string]string)
|
||||
for _, n := range networks {
|
||||
if n.PublicID.Valid {
|
||||
toret[n.ID] = n.PublicID.String
|
||||
}
|
||||
}
|
||||
|
||||
return toret, nil
|
||||
}
|
||||
|
||||
type network struct {
|
||||
ID string
|
||||
PublicID sql.NullString
|
||||
}
|
||||
128
management/internals/network_map_db/pgsql/peer.go
Normal file
128
management/internals/network_map_db/pgsql/peer.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package networkmap_pgsql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
const (
|
||||
GetPeersQuery = `
|
||||
select id, key, ssh_key, dns_label, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
|
||||
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, meta_capabilities, meta_flags,
|
||||
location_country_code, location_city_name, location_connection_ip
|
||||
from peers
|
||||
where account_id = $1
|
||||
`
|
||||
)
|
||||
|
||||
func (pg *PgStore) GetPeers(ctx context.Context, accountId string) ([]nmdata.Peer, error) {
|
||||
c, err := pg.Pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return GetPeersViaPgxConnection(ctx, c.Conn(), accountId)
|
||||
}
|
||||
|
||||
func GetPeersViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) ([]nmdata.Peer, error) {
|
||||
rows, err := con.Query(ctx, GetPeersQuery, accountId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
peers, err := pgx.CollectRows(rows, pgx.RowToStructByName[peer])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
toret := make([]nmdata.Peer, 0, len(peers))
|
||||
for _, p := range peers {
|
||||
dp := nmdata.Peer{}
|
||||
err := networkmapdb.FromSqlTypesToSharedTypes(
|
||||
reflect.ValueOf(&p), reflect.ValueOf(&dp))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if p.MetaWtVersion.Valid {
|
||||
dp.Meta.WtVersion = p.MetaWtVersion.String
|
||||
}
|
||||
if p.MetaGoOS.Valid {
|
||||
dp.Meta.GoOS = p.MetaGoOS.String
|
||||
}
|
||||
if p.MetaOSVersion.Valid {
|
||||
dp.Meta.OSVersion = p.MetaOSVersion.String
|
||||
}
|
||||
if p.MetaKernelVersion.Valid {
|
||||
dp.Meta.KernelVersion = p.MetaKernelVersion.String
|
||||
}
|
||||
if p.LocationCountryCode.Valid {
|
||||
dp.Location.CountryCode = p.LocationCountryCode.String
|
||||
}
|
||||
if p.LocationCityName.Valid {
|
||||
dp.Location.CityName = p.LocationCityName.String
|
||||
}
|
||||
if p.LocationConnectionIp != nil {
|
||||
err := json.Unmarshal(p.LocationConnectionIp, &dp.Location.ConnectionIP)
|
||||
if err != nil {
|
||||
return toret, err
|
||||
}
|
||||
}
|
||||
if p.MetaFiles != nil {
|
||||
err := json.Unmarshal(p.MetaFiles, &dp.Meta.Files)
|
||||
if err != nil {
|
||||
return toret, err
|
||||
}
|
||||
}
|
||||
if p.MetaCapabilities != nil {
|
||||
err := json.Unmarshal(p.MetaCapabilities, &dp.Meta.Capabilities)
|
||||
if err != nil {
|
||||
return toret, err
|
||||
}
|
||||
}
|
||||
if p.MetaFlags != nil {
|
||||
err := json.Unmarshal(p.MetaFlags, &dp.Meta.Flags)
|
||||
if err != nil {
|
||||
return toret, err
|
||||
}
|
||||
}
|
||||
if p.MetaNetworkAddresses != nil {
|
||||
err := json.Unmarshal(p.MetaNetworkAddresses, &dp.Meta.NetworkAddresses)
|
||||
if err != nil {
|
||||
return toret, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return toret, nil
|
||||
}
|
||||
|
||||
// TODO add support for creating struct fields from denormalized fields
|
||||
type peer struct {
|
||||
ID string
|
||||
Key sql.NullString
|
||||
SSHKey sql.NullString
|
||||
DNSLabel sql.NullString
|
||||
UserID sql.NullString
|
||||
LastLogin sql.NullTime
|
||||
SSHEnabled sql.NullBool
|
||||
LoginExpirationEnabled sql.NullBool
|
||||
IP json.RawMessage
|
||||
IPv6 json.RawMessage
|
||||
LocationConnectionIp json.RawMessage `nmap:"skip"`
|
||||
MetaFiles json.RawMessage `nmap:"skip"`
|
||||
MetaCapabilities json.RawMessage `nmap:"skip"`
|
||||
MetaFlags json.RawMessage `nmap:"skip"`
|
||||
MetaNetworkAddresses json.RawMessage `nmap:"skip"`
|
||||
MetaWtVersion sql.NullString `nmap:"skip"`
|
||||
MetaGoOS sql.NullString `nmap:"skip"`
|
||||
MetaOSVersion sql.NullString `nmap:"skip"`
|
||||
MetaKernelVersion sql.NullString `nmap:"skip"`
|
||||
LocationCountryCode sql.NullString `nmap:"skip"`
|
||||
LocationCityName sql.NullString `nmap:"skip"`
|
||||
}
|
||||
56
management/internals/network_map_db/pgsql/pg_store.go
Normal file
56
management/internals/network_map_db/pgsql/pg_store.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package networkmap_pgsql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
|
||||
)
|
||||
|
||||
const (
|
||||
pgMaxConnections = 30
|
||||
pgMinConnections = 1
|
||||
pgMaxConnLifetime = 60 * time.Minute
|
||||
pgHealthCheckPeriod = 1 * time.Minute
|
||||
)
|
||||
|
||||
var _ networkmapdb.NetworkMapDBStore = &PgStore{}
|
||||
|
||||
type PgStore struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPostgresqlStore(ctx context.Context, dsn string) (*PgStore, error) {
|
||||
pool, err := connectToPgDb(context.Background(), dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &PgStore{Pool: pool}, nil
|
||||
}
|
||||
|
||||
func connectToPgDb(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
|
||||
config, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse database config: %w", err)
|
||||
}
|
||||
|
||||
config.MaxConns = pgMaxConnections
|
||||
config.MinConns = pgMinConnections
|
||||
config.MaxConnLifetime = pgMaxConnLifetime
|
||||
config.HealthCheckPeriod = pgHealthCheckPeriod
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to create connection pool: %w", err)
|
||||
}
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("unable to ping database: %w", err)
|
||||
}
|
||||
|
||||
return pool, nil
|
||||
}
|
||||
150
management/internals/network_map_db/pgsql/policy.go
Normal file
150
management/internals/network_map_db/pgsql/policy.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package networkmap_pgsql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
const (
|
||||
GetPoliciesQuery = `
|
||||
select p.id, p.public_id, p.enabled, p.source_posture_checks, pr.enabled as rule_enabled, pr.action, pr.protocol, pr.bidirectional,
|
||||
pr.sources, pr.destinations, pr.source_resource, pr.destination_resource, pr.ports, pr.port_ranges,
|
||||
pr.authorized_groups, pr.authorized_user
|
||||
from policies as p
|
||||
left join policy_rules as pr on p.id = pr.policy_id
|
||||
where account_id=$1
|
||||
`
|
||||
)
|
||||
|
||||
func (pg *PgStore) GetPolicies(ctx context.Context, accountId string) ([]nmdata.Policy, error) {
|
||||
c, err := pg.Pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return GetPoliciesViaPgxConnection(ctx, c.Conn(), accountId)
|
||||
}
|
||||
|
||||
func GetPoliciesViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) ([]nmdata.Policy, error) {
|
||||
rows, err := con.Query(ctx, GetPoliciesQuery, accountId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
policies, err := pgx.CollectRows(rows, pgx.RowToStructByName[policy])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
toret := make([]nmdata.Policy, 0, len(policies))
|
||||
for _, p := range policies {
|
||||
policy := nmdata.Policy{}
|
||||
err := networkmapdb.FromSqlTypesToSharedTypes(
|
||||
reflect.ValueOf(&p), reflect.ValueOf(&policy))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var policyRule *nmdata.PolicyRule
|
||||
pr := func() *nmdata.PolicyRule {
|
||||
if policyRule != nil {
|
||||
return policyRule
|
||||
}
|
||||
|
||||
policyRule = &nmdata.PolicyRule{}
|
||||
return policyRule
|
||||
}
|
||||
|
||||
if p.RuleEnabled.Valid {
|
||||
pr().Enabled = p.RuleEnabled.Bool
|
||||
}
|
||||
if p.Action.Valid {
|
||||
pr().Action = p.Action.String
|
||||
}
|
||||
if p.Protocol.Valid {
|
||||
pr().Protocol = p.Protocol.String
|
||||
}
|
||||
if p.Bidirectional.Valid {
|
||||
pr().Bidirectional = p.Bidirectional.Bool
|
||||
}
|
||||
if len(p.Sources) > 0 {
|
||||
err := json.Unmarshal([]byte(p.Sources), &pr().Sources)
|
||||
if err != nil {
|
||||
return toret, err
|
||||
}
|
||||
}
|
||||
if len(p.Destinations) > 0 {
|
||||
err := json.Unmarshal([]byte(p.Destinations), &pr().Destinations)
|
||||
if err != nil {
|
||||
return toret, err
|
||||
}
|
||||
}
|
||||
if len(p.SourceResource) > 0 {
|
||||
err := json.Unmarshal([]byte(p.SourceResource), &pr().SourceResource)
|
||||
if err != nil {
|
||||
return toret, err
|
||||
}
|
||||
}
|
||||
if len(p.DestinationResource) > 0 {
|
||||
err := json.Unmarshal([]byte(p.DestinationResource), &pr().DestinationResource)
|
||||
if err != nil {
|
||||
return toret, err
|
||||
}
|
||||
}
|
||||
if len(p.Ports) > 0 {
|
||||
err := json.Unmarshal([]byte(p.Ports), &pr().Ports)
|
||||
if err != nil {
|
||||
return toret, err
|
||||
}
|
||||
}
|
||||
if len(p.PortRanges) > 0 {
|
||||
err := json.Unmarshal([]byte(p.PortRanges), &pr().PortRanges)
|
||||
if err != nil {
|
||||
return toret, err
|
||||
}
|
||||
}
|
||||
if len(p.AuthorizedGroups) > 0 {
|
||||
err := json.Unmarshal([]byte(p.AuthorizedGroups), &pr().AuthorizedGroups)
|
||||
if err != nil {
|
||||
return toret, err
|
||||
}
|
||||
}
|
||||
if p.AuthorizedUser.Valid {
|
||||
pr().AuthorizedUser = p.AuthorizedUser.String
|
||||
}
|
||||
|
||||
if policyRule != nil {
|
||||
policyRule.ID = p.ID
|
||||
policyRule.PolicyID = p.ID
|
||||
policy.Rules = []*nmdata.PolicyRule{policyRule}
|
||||
}
|
||||
|
||||
toret = append(toret, policy)
|
||||
}
|
||||
|
||||
return toret, err
|
||||
}
|
||||
|
||||
type policy struct {
|
||||
ID string
|
||||
PublicID sql.NullString
|
||||
SourcePostureChecks json.RawMessage
|
||||
Enabled sql.NullBool
|
||||
RuleEnabled sql.NullBool `nmap:"skip"`
|
||||
Bidirectional sql.NullBool `nmap:"skip"`
|
||||
Action sql.NullString `nmap:"skip"`
|
||||
Protocol sql.NullString `nmap:"skip"`
|
||||
Sources json.RawMessage `nmap:"skip"`
|
||||
Destinations json.RawMessage `nmap:"skip"`
|
||||
SourceResource json.RawMessage `nmap:"skip"`
|
||||
DestinationResource json.RawMessage `nmap:"skip"`
|
||||
Ports json.RawMessage `nmap:"skip"`
|
||||
PortRanges json.RawMessage `nmap:"skip"`
|
||||
AuthorizedGroups json.RawMessage `nmap:"skip"`
|
||||
AuthorizedUser sql.NullString `nmap:"skip"`
|
||||
}
|
||||
56
management/internals/network_map_db/pgsql/posture.go
Normal file
56
management/internals/network_map_db/pgsql/posture.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package networkmap_pgsql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
const (
|
||||
GetPostureChecksQuery = `
|
||||
select public_id as id, checks
|
||||
from posture_checks
|
||||
where account_id=$1
|
||||
`
|
||||
)
|
||||
|
||||
func (pg *PgStore) GetPostureChecks(ctx context.Context, accountId string) ([]nmdata.PostureChecks, error) {
|
||||
c, err := pg.Pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return GetPostureChecksViaPgxConnection(ctx, c.Conn(), accountId)
|
||||
}
|
||||
|
||||
func GetPostureChecksViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) ([]nmdata.PostureChecks, error) {
|
||||
rows, err := con.Query(ctx, GetPostureChecksQuery, accountId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
checks, err := pgx.CollectRows(rows, pgx.RowToStructByName[posturechecks])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
toret := make([]nmdata.PostureChecks, 0, len(checks))
|
||||
for _, c := range checks {
|
||||
checks := nmdata.PostureChecks{}
|
||||
err := networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&c), reflect.ValueOf(&checks))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toret = append(toret, checks)
|
||||
}
|
||||
|
||||
return toret, nil
|
||||
}
|
||||
|
||||
type posturechecks struct {
|
||||
ID string
|
||||
Checks json.RawMessage
|
||||
}
|
||||
75
management/internals/network_map_db/pgsql/route.go
Normal file
75
management/internals/network_map_db/pgsql/route.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package networkmap_pgsql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
const (
|
||||
GetRoutesQuery = `
|
||||
select id, account_id, public_id, network, domains, keep_route, net_id, description,
|
||||
peer, peer as peer_id, peer_groups, network_type, masquerade, metric, enabled,
|
||||
groups, access_control_groups, skip_auto_apply
|
||||
from routes
|
||||
where account_id=$1
|
||||
`
|
||||
)
|
||||
|
||||
func (pg *PgStore) GetRoutes(ctx context.Context, accountId string) ([]nmdata.Route, error) {
|
||||
c, err := pg.Pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return GetRoutesViaPgxConnection(ctx, c.Conn(), accountId)
|
||||
}
|
||||
|
||||
func GetRoutesViaPgxConnection(ctx context.Context, con *pgx.Conn, accountId string) ([]nmdata.Route, error) {
|
||||
rows, err := con.Query(ctx, GetRoutesQuery, accountId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
routes, err := pgx.CollectRows(rows, pgx.RowToStructByName[route])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
toret := make([]nmdata.Route, 0, len(routes))
|
||||
for _, r := range routes {
|
||||
route := nmdata.Route{}
|
||||
err := networkmapdb.FromSqlTypesToSharedTypes(
|
||||
reflect.ValueOf(&r), reflect.ValueOf(&route))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toret = append(toret, route)
|
||||
}
|
||||
return toret, nil
|
||||
}
|
||||
|
||||
type route struct {
|
||||
ID string
|
||||
AccountID sql.NullString
|
||||
PublicID sql.NullString
|
||||
Network json.RawMessage
|
||||
Domains json.RawMessage
|
||||
KeepRoute sql.NullBool
|
||||
NetID sql.NullString
|
||||
Description sql.NullString
|
||||
Peer sql.NullString
|
||||
PeerID sql.NullString
|
||||
PeerGroups json.RawMessage
|
||||
NetworkType sql.NullInt64
|
||||
Masquerade sql.NullBool
|
||||
Metric sql.NullInt64
|
||||
Enabled sql.NullBool
|
||||
Groups json.RawMessage
|
||||
AccessControlGroups json.RawMessage
|
||||
SkipAutoApply sql.NullBool
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package networkmap_pgsql
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNullStringSupport(t *testing.T) {
|
||||
src := withNullString{Name: sql.NullString{String: "string", Valid: true}}
|
||||
dst := withString{}
|
||||
assert.NoError(t, networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
|
||||
assert.Equal(t, withString{Name: "string"}, dst)
|
||||
|
||||
src = withNullString{Name: sql.NullString{Valid: false}}
|
||||
dst = withString{}
|
||||
assert.NoError(t, networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
|
||||
assert.Equal(t, withString{Name: ""}, dst)
|
||||
}
|
||||
|
||||
func TestNullBoolSupport(t *testing.T) {
|
||||
src := withNullBool{TrueOrFalse: sql.NullBool{Bool: true, Valid: true}}
|
||||
dst := withBool{}
|
||||
assert.NoError(t, networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
|
||||
assert.Equal(t, withBool{TrueOrFalse: true}, dst)
|
||||
|
||||
}
|
||||
|
||||
func TestRawJsonSupport(t *testing.T) {
|
||||
jb, _ := json.Marshal(embeddedS{Name: "blob-name", SomeField: 1})
|
||||
src := withRawJson{Blob: json.RawMessage(jb)}
|
||||
dst := fromJson{}
|
||||
assert.NoError(t, networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
|
||||
assert.Equal(t, fromJson{Blob: embeddedS{Name: "blob-name", SomeField: 1}}, dst)
|
||||
|
||||
src1 := withRawJson{}
|
||||
dst1 := fromJson{}
|
||||
assert.NoError(t, networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&src1), reflect.ValueOf(&dst1)))
|
||||
assert.Equal(t, fromJson{}, dst1)
|
||||
}
|
||||
|
||||
func TestShouldSkipTag(t *testing.T) {
|
||||
src5 := withSkipTag{Field: "shouldskip"}
|
||||
dst5 := emptySkipTagTarget{}
|
||||
assert.NoError(t, networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&src5), reflect.ValueOf(&dst5)))
|
||||
assert.Equal(t, emptySkipTagTarget{}, dst5)
|
||||
|
||||
}
|
||||
|
||||
func TestMapToTag(t *testing.T) {
|
||||
src6 := withMapToTag{Field: "fieldvalue"}
|
||||
dst6 := mapToTagTarget{}
|
||||
assert.NoError(t, networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&src6), reflect.ValueOf(&dst6)))
|
||||
assert.Equal(t, mapToTagTarget{AnotherField: "fieldvalue"}, dst6)
|
||||
}
|
||||
|
||||
func TestNullableInt64Support(t *testing.T) {
|
||||
src := withInt64{Field: sql.NullInt64{Int64: int64(1), Valid: true}}
|
||||
dst := int64Target{}
|
||||
assert.NoError(t, networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
|
||||
assert.Equal(t, int64Target{Field: 1}, dst)
|
||||
}
|
||||
|
||||
func TestNullableTimeSupport(t *testing.T) {
|
||||
now := time.Now()
|
||||
src := withNullableTime{Field: sql.NullTime{Time: now, Valid: true}}
|
||||
dst := nullableTimeTarget{}
|
||||
assert.NoError(t, networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
|
||||
assert.Equal(t, nullableTimeTarget{Field: now}, dst)
|
||||
}
|
||||
|
||||
func TestNullableTimePointerSupport(t *testing.T) {
|
||||
now := time.Now()
|
||||
src := withNullableTime{Field: sql.NullTime{Time: now, Valid: true}}
|
||||
dst := nullableTimePointerTarget{}
|
||||
assert.NoError(t, networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
|
||||
assert.Equal(t, nullableTimePointerTarget{Field: &now}, dst)
|
||||
}
|
||||
|
||||
func TestStringSLiceSupport(t *testing.T) {
|
||||
src := withStringSlice{Field: []string{"one"}}
|
||||
dst := withStringSlice{}
|
||||
assert.NoError(t, networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
|
||||
assert.Equal(t, withStringSlice{Field: []string{"one"}}, dst)
|
||||
}
|
||||
|
||||
func TestNullStringSLiceSupport(t *testing.T) {
|
||||
src := withStringSlice{}
|
||||
dst := withStringSlice{}
|
||||
assert.NoError(t, networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
|
||||
assert.Equal(t, withStringSlice{}, dst)
|
||||
}
|
||||
|
||||
func TestWithMultipleFields(t *testing.T) {
|
||||
now := time.Now()
|
||||
src := withMultipleFields{
|
||||
Field1: sql.NullString{String: "aaa", Valid: true},
|
||||
Field2: sql.NullBool{Bool: true, Valid: true},
|
||||
Field3: sql.NullTime{Time: now, Valid: true},
|
||||
Field4: sql.NullInt64{Int64: 1, Valid: true},
|
||||
Field5: "another",
|
||||
}
|
||||
dst := multipleFieldsTarget{}
|
||||
assert.NoError(t, networkmapdb.FromSqlTypesToSharedTypes(reflect.ValueOf(&src), reflect.ValueOf(&dst)))
|
||||
assert.Equal(t, multipleFieldsTarget{
|
||||
Field1: "aaa",
|
||||
Field2: true,
|
||||
Field3: now,
|
||||
Field4: 1,
|
||||
Field5: "another",
|
||||
}, dst)
|
||||
}
|
||||
|
||||
type withNullString struct {
|
||||
Name sql.NullString
|
||||
}
|
||||
|
||||
type withString struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
type withMultipleFields struct {
|
||||
Field1 sql.NullString
|
||||
Field2 sql.NullBool
|
||||
Field3 sql.NullTime
|
||||
Field4 sql.NullInt64
|
||||
Field5 string
|
||||
}
|
||||
|
||||
type multipleFieldsTarget struct {
|
||||
Field1 string
|
||||
Field2 bool
|
||||
Field3 time.Time
|
||||
Field4 int64
|
||||
Field5 string
|
||||
}
|
||||
|
||||
type withNullBool struct {
|
||||
TrueOrFalse sql.NullBool
|
||||
}
|
||||
|
||||
type withBool struct {
|
||||
TrueOrFalse bool
|
||||
}
|
||||
|
||||
type withRawJson struct {
|
||||
Blob json.RawMessage
|
||||
}
|
||||
|
||||
type embeddedS struct {
|
||||
Name string
|
||||
SomeField int
|
||||
}
|
||||
type fromJson struct {
|
||||
Blob embeddedS
|
||||
}
|
||||
|
||||
type withSkipTag struct {
|
||||
Field string `nmap:"skip"`
|
||||
}
|
||||
|
||||
type emptySkipTagTarget struct {
|
||||
Field string
|
||||
}
|
||||
|
||||
type withMapToTag struct {
|
||||
Field string `nmap:"map_to:AnotherField"`
|
||||
}
|
||||
|
||||
type mapToTagTarget struct {
|
||||
AnotherField string
|
||||
}
|
||||
|
||||
type withInt64 struct {
|
||||
Field sql.NullInt64
|
||||
}
|
||||
|
||||
type int64Target struct {
|
||||
Field int
|
||||
}
|
||||
|
||||
type withNullableTime struct {
|
||||
Field sql.NullTime
|
||||
}
|
||||
|
||||
type nullableTimeTarget struct {
|
||||
Field time.Time
|
||||
}
|
||||
|
||||
type nullableTimePointerTarget struct {
|
||||
Field *time.Time
|
||||
}
|
||||
|
||||
type withStringSlice struct {
|
||||
Field []string
|
||||
}
|
||||
@@ -4,10 +4,9 @@ import (
|
||||
"encoding/base64"
|
||||
"strconv"
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
nbroute "github.com/netbirdio/netbird/route"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap"
|
||||
nmdata "github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
@@ -82,6 +81,7 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel
|
||||
enc := newComponentEncoder(c)
|
||||
enc.indexAllPeers()
|
||||
routerIdxs := enc.indexRouterPeers(c.RouterPeers)
|
||||
enc.indexAllNetworkResources()
|
||||
|
||||
// Phase 2: gather every policy that any consumer references (peer-pair
|
||||
// policies + resource-only policies) so encodeResourcePoliciesMap can
|
||||
@@ -103,7 +103,6 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel
|
||||
DnsSettings: enc.encodeDNSSettings(c.DNSSettings),
|
||||
DnsDomain: in.DNSDomain,
|
||||
CustomZoneDomain: c.CustomZoneDomain,
|
||||
AgentVersions: enc.agentVersions,
|
||||
Peers: enc.peers,
|
||||
RouterPeerIndexes: routerIdxs,
|
||||
Policies: policies,
|
||||
@@ -128,7 +127,7 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel
|
||||
// networkSerial returns c.Network.CurrentSerial() with a nil guard. The
|
||||
// production path always populates c.Network, but the encoder is exported
|
||||
// and a hand-built components struct may omit it.
|
||||
func networkSerial(n *types.Network) uint64 {
|
||||
func networkSerial(n *nmdata.Network) uint64 {
|
||||
if n == nil {
|
||||
return 0
|
||||
}
|
||||
@@ -141,16 +140,15 @@ type componentEncoder struct {
|
||||
peerOrder map[string]uint32
|
||||
peers []*proto.PeerCompact
|
||||
|
||||
agentVersionOrder map[string]uint32
|
||||
agentVersions []string
|
||||
networkIdToPublicId map[string]string
|
||||
}
|
||||
|
||||
func newComponentEncoder(c *types.NetworkMapComponents) *componentEncoder {
|
||||
return &componentEncoder{
|
||||
components: c,
|
||||
peerOrder: make(map[string]uint32, len(c.Peers)),
|
||||
peers: make([]*proto.PeerCompact, 0, len(c.Peers)),
|
||||
agentVersionOrder: make(map[string]uint32),
|
||||
components: c,
|
||||
peerOrder: make(map[string]uint32, len(c.Peers)),
|
||||
peers: make([]*proto.PeerCompact, 0, len(c.Peers)),
|
||||
networkIdToPublicId: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +161,7 @@ func (e *componentEncoder) indexAllPeers() {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *componentEncoder) appendPeer(p *types.ComponentPeer) uint32 {
|
||||
func (e *componentEncoder) appendPeer(p *nmdata.Peer) uint32 {
|
||||
if idx, ok := e.peerOrder[p.ID]; ok {
|
||||
return idx
|
||||
}
|
||||
@@ -177,7 +175,7 @@ func (e *componentEncoder) appendPeer(p *types.ComponentPeer) uint32 {
|
||||
// (c.RouterPeers may contain peers not in c.Peers when validation rules drop
|
||||
// them) and returns their wire indexes for the RouterPeerIndexes field. Must
|
||||
// run before any encoder that resolves peer ids via e.peerOrder.
|
||||
func (e *componentEncoder) indexRouterPeers(routers map[string]*types.ComponentPeer) []uint32 {
|
||||
func (e *componentEncoder) indexRouterPeers(routers map[string]*nmdata.Peer) []uint32 {
|
||||
if len(routers) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -191,6 +189,15 @@ func (e *componentEncoder) indexRouterPeers(routers map[string]*types.ComponentP
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *componentEncoder) indexAllNetworkResources() {
|
||||
for _, r := range e.components.NetworkResources {
|
||||
if !r.Enabled {
|
||||
continue
|
||||
}
|
||||
e.networkIdToPublicId[r.ID] = r.PublicID
|
||||
}
|
||||
}
|
||||
|
||||
func (e *componentEncoder) encodeGroups() []*proto.GroupCompact {
|
||||
if len(e.components.Groups) == 0 {
|
||||
return nil
|
||||
@@ -204,10 +211,20 @@ func (e *componentEncoder) encodeGroups() []*proto.GroupCompact {
|
||||
peerIdxs = append(peerIdxs, idx)
|
||||
}
|
||||
}
|
||||
|
||||
groupCompactResources := func() []*proto.ResourceCompact {
|
||||
var toret []*proto.ResourceCompact
|
||||
for _, r := range g.Resources {
|
||||
toret = append(toret, e.resourceToProto(r))
|
||||
}
|
||||
return toret
|
||||
}
|
||||
|
||||
out = append(out, &proto.GroupCompact{
|
||||
Id: g.PublicID,
|
||||
PeerIndexes: peerIdxs,
|
||||
IsAll: g.IsGroupAll(),
|
||||
Resources: groupCompactResources(),
|
||||
})
|
||||
}
|
||||
return out
|
||||
@@ -217,7 +234,7 @@ func (e *componentEncoder) encodeGroups() []*proto.GroupCompact {
|
||||
// list and a map from policy pointer to the indexes of its emitted rules in
|
||||
// that list — used by encodeResourcePoliciesMap to translate
|
||||
// ResourcePoliciesMap[resourceID][]*Policy into wire-side indexes.
|
||||
func (e *componentEncoder) encodePolicies(policies []*types.Policy) []*proto.PolicyCompact {
|
||||
func (e *componentEncoder) encodePolicies(policies []*nmdata.Policy) []*proto.PolicyCompact {
|
||||
if len(policies) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -239,7 +256,7 @@ func (e *componentEncoder) encodePolicies(policies []*types.Policy) []*proto.Pol
|
||||
}
|
||||
|
||||
// encodePolicyRule maps a single PolicyRule under pol to a PolicyCompact entry.
|
||||
func (e *componentEncoder) encodePolicyRule(pol *types.Policy, r *types.PolicyRule) *proto.PolicyCompact {
|
||||
func (e *componentEncoder) encodePolicyRule(pol *nmdata.Policy, r *nmdata.PolicyRule) *proto.PolicyCompact {
|
||||
return &proto.PolicyCompact{
|
||||
Id: pol.PublicID,
|
||||
Action: networkmap.GetProtoAction(string(r.Action)),
|
||||
@@ -278,14 +295,14 @@ func (e *componentEncoder) groupPublicXids(src []string) []string {
|
||||
// only live in ResourcePoliciesMap; without this union step they'd be lost
|
||||
// from the wire and the client's resource-policy lookup would come back
|
||||
// empty.
|
||||
func unionPolicies(policies []*types.Policy, resourcePolicies map[string][]*types.Policy) []*types.Policy {
|
||||
func unionPolicies(policies []*nmdata.Policy, resourcePolicies map[string][]*nmdata.Policy) []*nmdata.Policy {
|
||||
// Fast path: non-router peers have no resource-only policies, so the
|
||||
// "union" is identical to `policies`. Skip the dedup map allocation.
|
||||
if len(resourcePolicies) == 0 {
|
||||
return policies
|
||||
}
|
||||
seen := make(map[string]struct{}, len(policies))
|
||||
out := make([]*types.Policy, 0, len(policies))
|
||||
out := make([]*nmdata.Policy, 0, len(policies))
|
||||
for _, p := range policies {
|
||||
if p == nil {
|
||||
continue
|
||||
@@ -343,18 +360,31 @@ func (e *componentEncoder) groupPublicXid(groupID string) (string, bool) {
|
||||
// peers array. For other resource types only the type string is shipped
|
||||
// today (Calculate's resource-typed rule path consults SourceResource only
|
||||
// for "peer" — other types fall through to group-based lookup).
|
||||
func (e *componentEncoder) resourceToProto(r types.Resource) *proto.ResourceCompact {
|
||||
if r.ID == "" && r.Type == "" {
|
||||
func (e *componentEncoder) resourceToProto(r nmdata.Resource) *proto.ResourceCompact {
|
||||
t, ok := proto.ResourceCompactType_value[string(r.Type)]
|
||||
if !ok || t == 0 || r.ID == "" {
|
||||
return nil
|
||||
}
|
||||
out := &proto.ResourceCompact{Type: string(r.Type)}
|
||||
if r.Type == types.ResourceTypePeer && r.ID != "" {
|
||||
if idx, ok := e.peerOrder[r.ID]; ok {
|
||||
out.PeerIndexSet = true
|
||||
out.PeerIndex = idx
|
||||
if t == int32(proto.ResourceCompactType_peer) {
|
||||
idx, ok := e.peerOrder[r.ID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &proto.ResourceCompact{
|
||||
Type: proto.ResourceCompactType_peer,
|
||||
ResourceId: &proto.ResourceCompact_PeerIndex{PeerIndex: idx},
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
||||
publicID, ok := e.networkIdToPublicId[r.ID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &proto.ResourceCompact{
|
||||
Type: proto.ResourceCompactType(t),
|
||||
ResourceId: &proto.ResourceCompact_Id{Id: publicID},
|
||||
}
|
||||
}
|
||||
|
||||
// postureCheckSeqs translates a slice of posture-check xids to their
|
||||
@@ -387,7 +417,7 @@ func (e *componentEncoder) networkPublicId(xid string) (string, bool) {
|
||||
return id, true
|
||||
}
|
||||
|
||||
func (e *componentEncoder) encodeDNSSettings(s *types.DNSSettings) *proto.DNSSettingsCompact {
|
||||
func (e *componentEncoder) encodeDNSSettings(s *nmdata.DNSSettings) *proto.DNSSettingsCompact {
|
||||
if s == nil || len(s.DisabledManagementGroups) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -402,7 +432,7 @@ func (e *componentEncoder) encodeDNSSettings(s *types.DNSSettings) *proto.DNSSet
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *componentEncoder) encodeRoutes(routes []*nbroute.Route) []*proto.RouteRaw {
|
||||
func (e *componentEncoder) encodeRoutes(routes []*nmdata.Route) []*proto.RouteRaw {
|
||||
if len(routes) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -440,7 +470,7 @@ func (e *componentEncoder) encodeRoutes(routes []*nbroute.Route) []*proto.RouteR
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *componentEncoder) encodeNameServerGroups(nsgs []*nbdns.NameServerGroup) []*proto.NameServerGroupRaw {
|
||||
func (e *componentEncoder) encodeNameServerGroups(nsgs []*nmdata.NameServerGroup) []*proto.NameServerGroupRaw {
|
||||
if len(nsgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -463,7 +493,7 @@ func (e *componentEncoder) encodeNameServerGroups(nsgs []*nbdns.NameServerGroup)
|
||||
return out
|
||||
}
|
||||
|
||||
func encodeNameServers(servers []nbdns.NameServer) []*proto.NameServer {
|
||||
func encodeNameServers(servers []nmdata.NameServer) []*proto.NameServer {
|
||||
if len(servers) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -478,7 +508,7 @@ func encodeNameServers(servers []nbdns.NameServer) []*proto.NameServer {
|
||||
return out
|
||||
}
|
||||
|
||||
func encodeSimpleRecords(records []nbdns.SimpleRecord) []*proto.SimpleRecord {
|
||||
func encodeSimpleRecords(records []nmdata.SimpleRecord) []*proto.SimpleRecord {
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -495,7 +525,7 @@ func encodeSimpleRecords(records []nbdns.SimpleRecord) []*proto.SimpleRecord {
|
||||
return out
|
||||
}
|
||||
|
||||
func encodeCustomZones(zones []nbdns.CustomZone) []*proto.CustomZone {
|
||||
func encodeCustomZones(zones []nmdata.CustomZone) []*proto.CustomZone {
|
||||
if len(zones) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -511,7 +541,7 @@ func encodeCustomZones(zones []nbdns.CustomZone) []*proto.CustomZone {
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *componentEncoder) encodeNetworkResources(resources []*types.ComponentResource) []*proto.NetworkResourceRaw {
|
||||
func (e *componentEncoder) encodeNetworkResources(resources []*nmdata.NetworkResource) []*proto.NetworkResourceRaw {
|
||||
if len(resources) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -540,7 +570,7 @@ func (e *componentEncoder) encodeNetworkResources(resources []*types.ComponentRe
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*types.ComponentRouter) map[string]*proto.NetworkRouterList {
|
||||
func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*nmdata.NetworkRouter) map[string]*proto.NetworkRouterList {
|
||||
if len(routersMap) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -576,7 +606,7 @@ func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*ty
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *componentEncoder) encodeResourcePoliciesMap(rpm map[string][]*types.Policy) map[string]*proto.PolicyIds {
|
||||
func (e *componentEncoder) encodeResourcePoliciesMap(rpm map[string][]*nmdata.Policy) map[string]*proto.PolicyIds {
|
||||
if len(rpm) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -663,7 +693,7 @@ func (e *componentEncoder) encodePostureFailedPeers(m map[string]map[string]stru
|
||||
// (which shouldn't happen in production but the encoder is exported)
|
||||
// degrades to login_expiration_enabled = false, which makes
|
||||
// LoginExpired() return false for every peer.
|
||||
func toAccountSettingsCompact(s *types.AccountSettingsInfo) *proto.AccountSettingsCompact {
|
||||
func toAccountSettingsCompact(s *nmdata.AccountSettingsInfo) *proto.AccountSettingsCompact {
|
||||
if s == nil {
|
||||
return &proto.AccountSettingsCompact{}
|
||||
}
|
||||
@@ -673,7 +703,7 @@ func toAccountSettingsCompact(s *types.AccountSettingsInfo) *proto.AccountSettin
|
||||
}
|
||||
}
|
||||
|
||||
func toAccountNetwork(n *types.Network) *proto.AccountNetwork {
|
||||
func toAccountNetwork(n *nmdata.Network) *proto.AccountNetwork {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -689,20 +719,20 @@ func toAccountNetwork(n *types.Network) *proto.AccountNetwork {
|
||||
return out
|
||||
}
|
||||
|
||||
func toPeerCompact(p *types.ComponentPeer) *proto.PeerCompact {
|
||||
func toPeerCompact(p *nmdata.Peer) *proto.PeerCompact {
|
||||
pc := &proto.PeerCompact{
|
||||
WgPubKey: decodeWgKey(p.Key),
|
||||
SshPubKey: []byte(p.SSHKey),
|
||||
DnsLabel: p.DNSLabel,
|
||||
AgentVersion: p.AgentVersion,
|
||||
AddedWithSsoLogin: p.AddedWithSSOLogin,
|
||||
AgentVersion: p.Meta.WtVersion,
|
||||
AddedWithSsoLogin: p.UserID != "",
|
||||
LoginExpirationEnabled: p.LoginExpirationEnabled,
|
||||
SshEnabled: p.SSHEnabled,
|
||||
SupportsIpv6: p.SupportsIPv6,
|
||||
SupportsSourcePrefixes: p.SupportsSourcePrefixes,
|
||||
ServerSshAllowed: p.ServerSSHAllowed,
|
||||
SupportsIpv6: p.SupportsIPv6(),
|
||||
SupportsSourcePrefixes: p.SupportsSourcePrefixes(),
|
||||
ServerSshAllowed: p.Meta.Flags.ServerSSHAllowed,
|
||||
}
|
||||
if !p.LastLogin.IsZero() {
|
||||
if p.LastLogin != nil {
|
||||
pc.LastLoginUnixNano = p.LastLogin.UnixNano()
|
||||
}
|
||||
switch {
|
||||
@@ -751,7 +781,7 @@ func portsToUint32(ports []string) []uint32 {
|
||||
return out
|
||||
}
|
||||
|
||||
func portRangesToProto(ranges []types.RulePortRange) []*proto.PortInfo_Range {
|
||||
func portRangesToProto(ranges []nmdata.RulePortRange) []*proto.PortInfo_Range {
|
||||
if len(ranges) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
nbroute "github.com/netbirdio/netbird/route"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
@@ -152,66 +152,66 @@ func envelopesEquivalent(a, b *proto.NetworkMapEnvelope) bool {
|
||||
}
|
||||
|
||||
func newTestComponents() *types.NetworkMapComponents {
|
||||
peerA := &types.ComponentPeer{
|
||||
ID: "peer-a",
|
||||
Key: testWgKeyA,
|
||||
IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}),
|
||||
DNSLabel: "peera",
|
||||
SSHKey: "ssh-a",
|
||||
AgentVersion: "0.40.0",
|
||||
peerA := &nmdata.Peer{
|
||||
ID: "peer-a",
|
||||
Key: testWgKeyA,
|
||||
IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}),
|
||||
DNSLabel: "peera",
|
||||
SSHKey: "ssh-a",
|
||||
Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
|
||||
}
|
||||
peerB := &types.ComponentPeer{
|
||||
ID: "peer-b",
|
||||
Key: testWgKeyB,
|
||||
IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}),
|
||||
IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}),
|
||||
DNSLabel: "peerb",
|
||||
AgentVersion: "0.25.0",
|
||||
peerB := &nmdata.Peer{
|
||||
ID: "peer-b",
|
||||
Key: testWgKeyB,
|
||||
IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}),
|
||||
IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}),
|
||||
DNSLabel: "peerb",
|
||||
Meta: nmdata.PeerSystemMeta{WtVersion: "0.25.0"},
|
||||
}
|
||||
peerC := &types.ComponentPeer{
|
||||
ID: "peer-c",
|
||||
Key: testWgKeyC,
|
||||
IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}),
|
||||
DNSLabel: "peerc",
|
||||
AgentVersion: "0.40.0",
|
||||
peerC := &nmdata.Peer{
|
||||
ID: "peer-c",
|
||||
Key: testWgKeyC,
|
||||
IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}),
|
||||
DNSLabel: "peerc",
|
||||
Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
|
||||
}
|
||||
|
||||
return &types.NetworkMapComponents{
|
||||
PeerID: "peer-a",
|
||||
Network: &types.Network{
|
||||
Network: &nmdata.Network{
|
||||
Identifier: "net-test",
|
||||
Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
|
||||
Serial: 7,
|
||||
},
|
||||
AccountSettings: &types.AccountSettingsInfo{
|
||||
AccountSettings: &nmdata.AccountSettingsInfo{
|
||||
PeerLoginExpirationEnabled: true,
|
||||
PeerLoginExpiration: 2 * time.Hour,
|
||||
},
|
||||
Peers: map[string]*types.ComponentPeer{
|
||||
Peers: map[string]*nmdata.Peer{
|
||||
"peer-a": peerA,
|
||||
"peer-b": peerB,
|
||||
"peer-c": peerC,
|
||||
},
|
||||
Groups: map[string]*types.ComponentGroup{
|
||||
"group-src": {ID: "group-src", PublicID: "1", Name: "Src", Peers: []string{"peer-a"}},
|
||||
"group-dst": {ID: "group-dst", PublicID: "2", Name: "Dst", Peers: []string{"peer-b", "peer-c"}},
|
||||
Groups: map[string]*nmdata.Group{
|
||||
"group-src": {PublicID: "1", Name: "Src", Peers: []string{"peer-a"}},
|
||||
"group-dst": {PublicID: "2", Name: "Dst", Peers: []string{"peer-b", "peer-c"}},
|
||||
},
|
||||
Policies: []*types.Policy{
|
||||
Policies: []*nmdata.Policy{
|
||||
{
|
||||
ID: "pol-1",
|
||||
PublicID: "10",
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{{
|
||||
ID: "rule-1", Enabled: true, Action: types.PolicyTrafficActionAccept,
|
||||
Protocol: types.PolicyRuleProtocolTCP, Bidirectional: true,
|
||||
Rules: []*nmdata.PolicyRule{{
|
||||
ID: "rule-1", Enabled: true, Action: string(types.PolicyTrafficActionAccept),
|
||||
Protocol: string(types.PolicyRuleProtocolTCP), Bidirectional: true,
|
||||
Ports: []string{"22", "80"},
|
||||
PortRanges: []types.RulePortRange{{Start: 8000, End: 8100}},
|
||||
PortRanges: []nmdata.RulePortRange{{Start: 8000, End: 8100}},
|
||||
Sources: []string{"group-src"},
|
||||
Destinations: []string{"group-dst"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
RouterPeers: map[string]*types.ComponentPeer{"peer-c": peerC},
|
||||
RouterPeers: map[string]*nmdata.Peer{"peer-c": peerC},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +304,31 @@ func TestEncodeNetworkMapEnvelope_GroupsByAccountPublicId(t *testing.T) {
|
||||
assert.Len(t, groupByID["2"].PeerIndexes, 2)
|
||||
}
|
||||
|
||||
func TestEncodePolicy(t *testing.T) {
|
||||
encoder := componentEncoder{peerOrder: map[string]uint32{"peerId": uint32(1234)}, networkIdToPublicId: map[string]string{"domain": "publicDomain", "host": "publicHost", "subnet": "publicSubnet"}}
|
||||
assert.Equal(t,
|
||||
encoder.resourceToProto(nmdata.Resource{Type: "peer", ID: "peerId"}),
|
||||
&proto.ResourceCompact{Type: proto.ResourceCompactType_peer, ResourceId: &proto.ResourceCompact_PeerIndex{PeerIndex: uint32(1234)}})
|
||||
// verify invalid peer id results in nil
|
||||
assert.Nil(t,
|
||||
encoder.resourceToProto(nmdata.Resource{Type: "peer", ID: "boom"}))
|
||||
assert.Equal(t,
|
||||
encoder.resourceToProto(nmdata.Resource{Type: "domain", ID: "domain"}),
|
||||
&proto.ResourceCompact{Type: proto.ResourceCompactType_domain, ResourceId: &proto.ResourceCompact_Id{Id: "publicDomain"}})
|
||||
assert.Equal(t,
|
||||
encoder.resourceToProto(nmdata.Resource{Type: "host", ID: "host"}),
|
||||
&proto.ResourceCompact{Type: proto.ResourceCompactType_host, ResourceId: &proto.ResourceCompact_Id{Id: "publicHost"}})
|
||||
assert.Equal(t,
|
||||
encoder.resourceToProto(nmdata.Resource{Type: "subnet", ID: "subnet"}),
|
||||
&proto.ResourceCompact{Type: proto.ResourceCompactType_subnet, ResourceId: &proto.ResourceCompact_Id{Id: "publicSubnet"}})
|
||||
// verify invalid resource type results in nil
|
||||
assert.Nil(t,
|
||||
encoder.resourceToProto(nmdata.Resource{Type: "boom", ID: "boom"}))
|
||||
// verify invalid networkresource id results in nil
|
||||
assert.Nil(t,
|
||||
encoder.resourceToProto(nmdata.Resource{Type: "host", ID: "boom"}))
|
||||
}
|
||||
|
||||
func TestEncodeNetworkMapEnvelope_PolicyExpansion(t *testing.T) {
|
||||
c := newTestComponents()
|
||||
|
||||
@@ -377,12 +402,12 @@ func TestEncodeNetworkMapEnvelope_MalformedWgKey(t *testing.T) {
|
||||
|
||||
func TestEncodeNetworkMapEnvelope_IPv6OnlyPeer(t *testing.T) {
|
||||
c := newTestComponents()
|
||||
v6Only := &types.ComponentPeer{
|
||||
ID: "peer-v6",
|
||||
Key: testWgKeyA,
|
||||
IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9}),
|
||||
DNSLabel: "peerv6",
|
||||
AgentVersion: "0.40.0",
|
||||
v6Only := &nmdata.Peer{
|
||||
ID: "peer-v6",
|
||||
Key: testWgKeyA,
|
||||
IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9}),
|
||||
DNSLabel: "peerv6",
|
||||
Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
|
||||
}
|
||||
c.Peers["peer-v6"] = v6Only
|
||||
|
||||
@@ -401,11 +426,11 @@ func TestEncodeNetworkMapEnvelope_IPv6OnlyPeer(t *testing.T) {
|
||||
|
||||
func TestEncodeNetworkMapEnvelope_PeerWithoutIP(t *testing.T) {
|
||||
c := newTestComponents()
|
||||
c.Peers["peer-noip"] = &types.ComponentPeer{
|
||||
ID: "peer-noip",
|
||||
Key: testWgKeyA,
|
||||
DNSLabel: "peernoip",
|
||||
AgentVersion: "0.40.0",
|
||||
c.Peers["peer-noip"] = &nmdata.Peer{
|
||||
ID: "peer-noip",
|
||||
Key: testWgKeyA,
|
||||
DNSLabel: "peernoip",
|
||||
Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
|
||||
}
|
||||
|
||||
full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull()
|
||||
@@ -423,7 +448,7 @@ func TestEncodeNetworkMapEnvelope_PeerWithoutIP(t *testing.T) {
|
||||
|
||||
func TestEncodeNetworkMapEnvelope_EmptyInput(t *testing.T) {
|
||||
c := &types.NetworkMapComponents{
|
||||
Network: &types.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}},
|
||||
Network: &nmdata.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}},
|
||||
}
|
||||
|
||||
env := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c})
|
||||
@@ -440,9 +465,9 @@ func TestEncodeNetworkMapEnvelope_EmptyInput(t *testing.T) {
|
||||
func TestEncodeNetworkMapEnvelope_PeerLoginExpirationFields(t *testing.T) {
|
||||
c := newTestComponents()
|
||||
now := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
c.Peers["peer-a"].AddedWithSSOLogin = true
|
||||
c.Peers["peer-a"].UserID = "user-1"
|
||||
c.Peers["peer-a"].LoginExpirationEnabled = true
|
||||
c.Peers["peer-a"].LastLogin = now
|
||||
c.Peers["peer-a"].LastLogin = &now
|
||||
|
||||
full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull()
|
||||
|
||||
@@ -472,7 +497,7 @@ func TestEncodeNetworkMapEnvelope_PeerLoginExpirationFields(t *testing.T) {
|
||||
|
||||
func TestEncodeNetworkMapEnvelope_RoutesRoundTrip(t *testing.T) {
|
||||
c := newTestComponents()
|
||||
c.Routes = []*nbroute.Route{
|
||||
c.Routes = []*nmdata.Route{
|
||||
{
|
||||
ID: "route-peer",
|
||||
PublicID: "100",
|
||||
@@ -519,7 +544,7 @@ func TestEncodeNetworkMapEnvelope_RoutesRoundTrip(t *testing.T) {
|
||||
|
||||
func TestEncodeNetworkMapEnvelope_RouteWithMissingPeerLeavesIndexUnset(t *testing.T) {
|
||||
c := newTestComponents()
|
||||
c.Routes = []*nbroute.Route{{
|
||||
c.Routes = []*nmdata.Route{{
|
||||
ID: "route-x",
|
||||
PublicID: "100",
|
||||
Peer: "peer-not-in-components",
|
||||
@@ -539,21 +564,21 @@ func TestEncodeNetworkMapEnvelope_ResourceOnlyPolicyShippedAndIndexed(t *testing
|
||||
// Policy that exists ONLY in ResourcePoliciesMap, not in c.Policies. This
|
||||
// is the I1 case — without unionPolicies the encoder would silently
|
||||
// drop it from the wire.
|
||||
resourceOnlyPolicy := &types.Policy{
|
||||
resourceOnlyPolicy := &nmdata.Policy{
|
||||
ID: "pol-resource", PublicID: "99", Enabled: true,
|
||||
Rules: []*types.PolicyRule{{
|
||||
ID: "rule-r", Enabled: true, Action: types.PolicyTrafficActionAccept,
|
||||
Protocol: types.PolicyRuleProtocolTCP,
|
||||
Rules: []*nmdata.PolicyRule{{
|
||||
ID: "rule-r", Enabled: true, Action: string(types.PolicyTrafficActionAccept),
|
||||
Protocol: string(types.PolicyRuleProtocolTCP),
|
||||
Sources: []string{"group-src"},
|
||||
Destinations: []string{"group-dst"},
|
||||
}},
|
||||
}
|
||||
c.ResourcePoliciesMap = map[string][]*types.Policy{
|
||||
c.ResourcePoliciesMap = map[string][]*nmdata.Policy{
|
||||
"resource-x": {c.Policies[0], resourceOnlyPolicy}, // shared + resource-only
|
||||
}
|
||||
// Resource must appear in components.NetworkResources with a seq id —
|
||||
// encoder uses that to translate the xid map key to uint32.
|
||||
c.NetworkResources = []*types.ComponentResource{
|
||||
c.NetworkResources = []*nmdata.NetworkResource{
|
||||
{ID: "resource-x", PublicID: "77", Name: "res-x", Enabled: true},
|
||||
}
|
||||
|
||||
@@ -562,27 +587,16 @@ func TestEncodeNetworkMapEnvelope_ResourceOnlyPolicyShippedAndIndexed(t *testing
|
||||
require.Len(t, full.Policies, 2, "encoded policies must include both peer-traffic and resource-only")
|
||||
|
||||
policyByID := map[string]*proto.PolicyCompact{}
|
||||
policyIds := make([]string, 0)
|
||||
for _, p := range full.Policies {
|
||||
policyByID[p.Id] = p
|
||||
policyIds = append(policyIds, p.Id)
|
||||
}
|
||||
require.Contains(t, policyByID, "10", "original peer-traffic policy id 10")
|
||||
require.Contains(t, policyByID, "99", "resource-only policy id 99")
|
||||
|
||||
require.Contains(t, full.ResourcePoliciesMap, "77")
|
||||
ids := full.ResourcePoliciesMap["77"].Ids
|
||||
require.Len(t, ids, 2)
|
||||
assert.ElementsMatch(t, policyIds, ids,
|
||||
"resource policies map must reference both wire policy indexes")
|
||||
}
|
||||
|
||||
func TestEncodeNetworkMapEnvelope_NameServerGroups(t *testing.T) {
|
||||
c := newTestComponents()
|
||||
c.NameServerGroups = []*nbdns.NameServerGroup{{
|
||||
c.NameServerGroups = []*nmdata.NameServerGroup{{
|
||||
ID: "nsg-1", PublicID: "50", Name: "Main", Description: "primary",
|
||||
NameServers: []nbdns.NameServer{{
|
||||
IP: netip.MustParseAddr("8.8.8.8"), NSType: nbdns.UDPNameServerType, Port: 53,
|
||||
NameServers: []nmdata.NameServer{{
|
||||
IP: netip.MustParseAddr("8.8.8.8"), NSType: int(nbdns.UDPNameServerType), Port: 53,
|
||||
}},
|
||||
Groups: []string{"group-src", "group-not-persisted"},
|
||||
Primary: true, Enabled: true,
|
||||
@@ -621,11 +635,11 @@ func TestEncodeNetworkMapEnvelope_PostureFailedPeers(t *testing.T) {
|
||||
func TestEncodeNetworkMapEnvelope_RoutersMap(t *testing.T) {
|
||||
c := newTestComponents()
|
||||
c.NetworkXIDToPublicID = map[string]string{"net-1": "5"}
|
||||
c.RoutersMap = map[string]map[string]*types.ComponentRouter{
|
||||
c.RoutersMap = map[string]map[string]*nmdata.NetworkRouter{
|
||||
"net-1": {
|
||||
"peer-c": {
|
||||
PublicID: "200",
|
||||
Peer: "peer-c", Masquerade: true, Metric: 10, Enabled: true,
|
||||
PublicID: "200",
|
||||
Masquerade: true, Metric: 10, Enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -651,14 +665,14 @@ func TestEncodeNetworkMapEnvelope_RouterPeerNotInComponentsPeers(t *testing.T) {
|
||||
// peer_index reference must still resolve.
|
||||
c := newTestComponents()
|
||||
delete(c.Peers, "peer-c")
|
||||
routerPeer := &types.ComponentPeer{
|
||||
routerPeer := &nmdata.Peer{
|
||||
ID: "peer-c", Key: testWgKeyC, IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}),
|
||||
DNSLabel: "peerc", AgentVersion: "0.40.0",
|
||||
DNSLabel: "peerc", Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
|
||||
}
|
||||
c.RouterPeers = map[string]*types.ComponentPeer{"peer-c": routerPeer}
|
||||
c.RouterPeers = map[string]*nmdata.Peer{"peer-c": routerPeer}
|
||||
c.NetworkXIDToPublicID = map[string]string{"net-1": "5"}
|
||||
c.RoutersMap = map[string]map[string]*types.ComponentRouter{
|
||||
"net-1": {"peer-c": {PublicID: "1", Peer: "peer-c", Enabled: true}},
|
||||
c.RoutersMap = map[string]map[string]*nmdata.NetworkRouter{
|
||||
"net-1": {"peer-c": {PublicID: "1", Enabled: true}},
|
||||
}
|
||||
|
||||
full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull()
|
||||
@@ -691,9 +705,9 @@ func TestToProxyPatch_EmptyInputReturnsNil(t *testing.T) {
|
||||
|
||||
func TestToProxyPatch_PopulatesAllFields(t *testing.T) {
|
||||
nm := &types.NetworkMap{
|
||||
Peers: []*types.ComponentPeer{{
|
||||
Peers: []*nmdata.Peer{{
|
||||
ID: "ext-peer", Key: testWgKeyA, IP: netip.AddrFrom4([4]byte{100, 64, 0, 9}),
|
||||
DNSLabel: "extpeer", AgentVersion: "0.40.0",
|
||||
DNSLabel: "extpeer", Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
|
||||
}},
|
||||
FirewallRules: []*types.FirewallRule{{
|
||||
PeerIP: "100.64.0.9", Action: "accept", Direction: 0, Protocol: "tcp",
|
||||
@@ -762,7 +776,7 @@ func TestEncodeNetworkMapEnvelope_NilComponentsGracefulDegrade(t *testing.T) {
|
||||
|
||||
func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) {
|
||||
c := &types.NetworkMapComponents{
|
||||
Network: &types.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}},
|
||||
Network: &nmdata.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}},
|
||||
// AccountSettings deliberately nil
|
||||
}
|
||||
|
||||
@@ -776,6 +790,6 @@ func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) {
|
||||
func emptyNetworkMapComponents() *types.NetworkMapComponents {
|
||||
return types.EmptyNetworkMapComponents(
|
||||
&types.NetworkMapComponents{
|
||||
PeerID: "peer-id", Peers: map[string]*types.ComponentPeer{"peer-id": {}}},
|
||||
PeerID: "peer-id", Peers: map[string]*nmdata.Peer{"peer-id": {}}},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap"
|
||||
nmdata "github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
@@ -50,7 +51,7 @@ func ToComponentSyncResponse(
|
||||
// TODO (dmitri) consider using invariants?
|
||||
//
|
||||
enableSSH := computeSSHEnabledForPeer(components, peer)
|
||||
peerConfig := toPeerConfig(peer, components.Network, dnsName, settings, httpConfig, deviceFlowConfig, enableSSH)
|
||||
peerConfig := toPeerConfig(peer, components.Network, dnsName, settings, httpConfig, deviceFlowConfig, enableSSH, components.ForceRoutingPeerDNSResolution)
|
||||
|
||||
includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid()
|
||||
useSourcePrefixes := peer.SupportsSourcePrefixes()
|
||||
@@ -170,25 +171,25 @@ func computeSSHEnabledForPeer(c *types.NetworkMapComponents, peer *nbpeer.Peer)
|
||||
// ruleEnablesSSHForPeer returns true when rule is active, targets peer, and
|
||||
// either explicitly authorises SSH or covers the legacy TCP/22 path while the
|
||||
// peer itself has SSH enabled locally.
|
||||
func ruleEnablesSSHForPeer(c *types.NetworkMapComponents, rule *types.PolicyRule, peer *nbpeer.Peer) bool {
|
||||
func ruleEnablesSSHForPeer(c *types.NetworkMapComponents, rule *nmdata.PolicyRule, peer *nbpeer.Peer) bool {
|
||||
if rule == nil || !rule.Enabled {
|
||||
return false
|
||||
}
|
||||
if !peerInDestinations(c, rule, peer.ID) {
|
||||
return false
|
||||
}
|
||||
if rule.Protocol == types.PolicyRuleProtocolNetbirdSSH {
|
||||
if rule.Protocol == string(types.PolicyRuleProtocolNetbirdSSH) {
|
||||
return true
|
||||
}
|
||||
return peer.SSHEnabled && types.PolicyRuleImpliesLegacySSH(rule)
|
||||
return peer.SSHEnabled && nmdata.PolicyRuleImpliesLegacySSH(rule)
|
||||
}
|
||||
|
||||
// peerInDestinations reports whether peerID is in any of rule.Destinations'
|
||||
// groups (or matches DestinationResource if it's a peer-typed resource —
|
||||
// for non-peer types Calculate falls through to group lookup, so we mirror
|
||||
// that exactly to avoid silent divergence).
|
||||
func peerInDestinations(c *types.NetworkMapComponents, rule *types.PolicyRule, peerID string) bool {
|
||||
if rule.DestinationResource.Type == types.ResourceTypePeer && rule.DestinationResource.ID != "" {
|
||||
func peerInDestinations(c *types.NetworkMapComponents, rule *nmdata.PolicyRule, peerID string) bool {
|
||||
if rule.DestinationResource.Type == string(types.ResourceTypePeer) && rule.DestinationResource.ID != "" {
|
||||
return rule.DestinationResource.ID == peerID
|
||||
}
|
||||
for _, groupID := range rule.Destinations {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
// TestComputeSSHEnabledForPeer covers both Calculate-mirroring branches:
|
||||
@@ -17,16 +18,15 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
|
||||
const targetPeerID = "target"
|
||||
const targetGroupID = "g_dst"
|
||||
|
||||
mkComponents := func(rule *types.PolicyRule, sshEnabled bool) (*types.NetworkMapComponents, *nbpeer.Peer) {
|
||||
mkComponents := func(rule *nmdata.PolicyRule, sshEnabled bool) (*types.NetworkMapComponents, *nbpeer.Peer) {
|
||||
peer := &nbpeer.Peer{ID: targetPeerID, SSHEnabled: sshEnabled}
|
||||
group := &types.ComponentGroup{ID: targetGroupID, Name: "dst", Peers: []string{targetPeerID}}
|
||||
return &types.NetworkMapComponents{
|
||||
Peers: map[string]*types.ComponentPeer{targetPeerID: peer.ToComponent()},
|
||||
Groups: map[string]*types.ComponentGroup{targetGroupID: group},
|
||||
Policies: []*types.Policy{{
|
||||
Peers: map[string]*nmdata.Peer{targetPeerID: types.TwinPeer(peer)},
|
||||
Groups: map[string]*nmdata.Group{targetGroupID: {Name: "dst", Peers: []string{targetPeerID}}},
|
||||
Policies: []*nmdata.Policy{{
|
||||
ID: "p",
|
||||
Enabled: true,
|
||||
Rules: []*types.PolicyRule{rule},
|
||||
Rules: []*nmdata.PolicyRule{rule},
|
||||
}},
|
||||
}, peer
|
||||
}
|
||||
@@ -34,14 +34,14 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
peerSSH bool
|
||||
rule types.PolicyRule
|
||||
rule nmdata.PolicyRule
|
||||
wantEnabled bool
|
||||
}{
|
||||
{
|
||||
name: "explicit-netbird-ssh-activates-regardless-of-peer-ssh",
|
||||
peerSSH: false,
|
||||
rule: types.PolicyRule{
|
||||
Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH,
|
||||
rule: nmdata.PolicyRule{
|
||||
Enabled: true, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
|
||||
Destinations: []string{targetGroupID},
|
||||
},
|
||||
wantEnabled: true,
|
||||
@@ -49,8 +49,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
|
||||
{
|
||||
name: "implicit-tcp-22-with-peer-ssh",
|
||||
peerSSH: true,
|
||||
rule: types.PolicyRule{
|
||||
Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22"},
|
||||
rule: nmdata.PolicyRule{
|
||||
Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"22"},
|
||||
Destinations: []string{targetGroupID},
|
||||
},
|
||||
wantEnabled: true,
|
||||
@@ -58,8 +58,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
|
||||
{
|
||||
name: "implicit-tcp-22-without-peer-ssh-disabled",
|
||||
peerSSH: false,
|
||||
rule: types.PolicyRule{
|
||||
Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22"},
|
||||
rule: nmdata.PolicyRule{
|
||||
Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"22"},
|
||||
Destinations: []string{targetGroupID},
|
||||
},
|
||||
wantEnabled: false,
|
||||
@@ -67,8 +67,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
|
||||
{
|
||||
name: "implicit-tcp-22022-with-peer-ssh",
|
||||
peerSSH: true,
|
||||
rule: types.PolicyRule{
|
||||
Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22022"},
|
||||
rule: nmdata.PolicyRule{
|
||||
Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"22022"},
|
||||
Destinations: []string{targetGroupID},
|
||||
},
|
||||
wantEnabled: true,
|
||||
@@ -76,8 +76,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
|
||||
{
|
||||
name: "implicit-all-protocol-with-peer-ssh",
|
||||
peerSSH: true,
|
||||
rule: types.PolicyRule{
|
||||
Enabled: true, Protocol: types.PolicyRuleProtocolALL,
|
||||
rule: nmdata.PolicyRule{
|
||||
Enabled: true, Protocol: string(types.PolicyRuleProtocolALL),
|
||||
Destinations: []string{targetGroupID},
|
||||
},
|
||||
wantEnabled: true,
|
||||
@@ -85,10 +85,10 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
|
||||
{
|
||||
name: "implicit-port-range-covers-22",
|
||||
peerSSH: true,
|
||||
rule: types.PolicyRule{
|
||||
rule: nmdata.PolicyRule{
|
||||
Enabled: true,
|
||||
Protocol: types.PolicyRuleProtocolTCP,
|
||||
PortRanges: []types.RulePortRange{{Start: 20, End: 30}},
|
||||
Protocol: string(types.PolicyRuleProtocolTCP),
|
||||
PortRanges: []nmdata.RulePortRange{{Start: 20, End: 30}},
|
||||
Destinations: []string{targetGroupID},
|
||||
},
|
||||
wantEnabled: true,
|
||||
@@ -96,8 +96,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
|
||||
{
|
||||
name: "tcp-80-no-ssh",
|
||||
peerSSH: true,
|
||||
rule: types.PolicyRule{
|
||||
Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"80"},
|
||||
rule: nmdata.PolicyRule{
|
||||
Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"80"},
|
||||
Destinations: []string{targetGroupID},
|
||||
},
|
||||
wantEnabled: false,
|
||||
@@ -105,8 +105,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
|
||||
{
|
||||
name: "disabled-rule-skipped",
|
||||
peerSSH: true,
|
||||
rule: types.PolicyRule{
|
||||
Enabled: false, Protocol: types.PolicyRuleProtocolNetbirdSSH,
|
||||
rule: nmdata.PolicyRule{
|
||||
Enabled: false, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
|
||||
Destinations: []string{targetGroupID},
|
||||
},
|
||||
wantEnabled: false,
|
||||
@@ -114,8 +114,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
|
||||
{
|
||||
name: "peer-not-in-destinations",
|
||||
peerSSH: true,
|
||||
rule: types.PolicyRule{
|
||||
Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH,
|
||||
rule: nmdata.PolicyRule{
|
||||
Enabled: true, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
|
||||
Destinations: []string{"g_other"}, // target not in this group
|
||||
},
|
||||
wantEnabled: false,
|
||||
@@ -123,21 +123,21 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
|
||||
{
|
||||
name: "peer-typed-destination-resource-matches",
|
||||
peerSSH: false,
|
||||
rule: types.PolicyRule{
|
||||
rule: nmdata.PolicyRule{
|
||||
Enabled: true,
|
||||
Protocol: types.PolicyRuleProtocolNetbirdSSH,
|
||||
DestinationResource: types.Resource{ID: targetPeerID, Type: types.ResourceTypePeer},
|
||||
Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
|
||||
DestinationResource: nmdata.Resource{ID: targetPeerID, Type: string(types.ResourceTypePeer)},
|
||||
},
|
||||
wantEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "non-peer-destination-resource-falls-through-to-groups",
|
||||
peerSSH: false,
|
||||
rule: types.PolicyRule{
|
||||
rule: nmdata.PolicyRule{
|
||||
Enabled: true,
|
||||
Protocol: types.PolicyRuleProtocolNetbirdSSH,
|
||||
DestinationResource: types.Resource{ID: targetPeerID, Type: "host"}, // wrong type
|
||||
Destinations: []string{targetGroupID}, // saved by group fallback
|
||||
Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
|
||||
DestinationResource: nmdata.Resource{ID: targetPeerID, Type: "host"}, // wrong type
|
||||
Destinations: []string{targetGroupID}, // saved by group fallback
|
||||
},
|
||||
wantEnabled: true,
|
||||
},
|
||||
@@ -158,14 +158,14 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
|
||||
func TestComputeSSHEnabledForPeer_TargetMissingFromComponents(t *testing.T) {
|
||||
peer := &nbpeer.Peer{ID: "missing", SSHEnabled: true}
|
||||
c := &types.NetworkMapComponents{
|
||||
Peers: map[string]*types.ComponentPeer{}, // target peer NOT present
|
||||
Groups: map[string]*types.ComponentGroup{
|
||||
"g": {ID: "g", Peers: []string{"missing"}},
|
||||
Peers: map[string]*nmdata.Peer{}, // target peer NOT present
|
||||
Groups: map[string]*nmdata.Group{
|
||||
"g": {Peers: []string{"missing"}},
|
||||
},
|
||||
Policies: []*types.Policy{{
|
||||
Policies: []*nmdata.Policy{{
|
||||
ID: "p", Enabled: true,
|
||||
Rules: []*types.PolicyRule{{
|
||||
Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH,
|
||||
Rules: []*nmdata.PolicyRule{{
|
||||
Enabled: true, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
|
||||
Destinations: []string{"g"},
|
||||
}},
|
||||
}},
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/posture"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/shared/netiputil"
|
||||
)
|
||||
@@ -119,7 +120,7 @@ func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken
|
||||
return nbConfig
|
||||
}
|
||||
|
||||
func toPeerConfig(peer *nbpeer.Peer, network *types.Network, dnsName string, settings *types.Settings, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, enableSSH bool) *proto.PeerConfig {
|
||||
func toPeerConfig(peer *nbpeer.Peer, network *nmdata.Network, dnsName string, settings *types.Settings, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, enableSSH bool, forceRoutingPeerDNS bool) *proto.PeerConfig {
|
||||
netmask, _ := network.Net.Mask.Size()
|
||||
fqdn := peer.FQDN(dnsName)
|
||||
|
||||
@@ -135,7 +136,7 @@ func toPeerConfig(peer *nbpeer.Peer, network *types.Network, dnsName string, set
|
||||
Address: fmt.Sprintf("%s/%d", peer.IP.String(), netmask),
|
||||
SshConfig: sshConfig,
|
||||
Fqdn: fqdn,
|
||||
RoutingPeerDnsResolutionEnabled: settings.RoutingPeerDNSResolutionEnabled,
|
||||
RoutingPeerDnsResolutionEnabled: settings.RoutingPeerDNSResolutionEnabled || peer.ProxyMeta.Embedded || forceRoutingPeerDNS,
|
||||
LazyConnectionEnabled: settings.LazyConnectionEnabled,
|
||||
AutoUpdate: &proto.AutoUpdateSettings{
|
||||
Version: settings.AutoUpdateVersion,
|
||||
@@ -162,12 +163,12 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb
|
||||
useSourcePrefixes := peer.SupportsSourcePrefixes()
|
||||
|
||||
response := &proto.SyncResponse{
|
||||
PeerConfig: toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH),
|
||||
PeerConfig: toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH, networkMap.ForceRoutingPeerDNSResolution),
|
||||
NetworkMap: &proto.NetworkMap{
|
||||
Serial: networkMap.Network.CurrentSerial(),
|
||||
Routes: networkmap.ToProtocolRoutes(networkMap.Routes),
|
||||
DNSConfig: networkmap.ToProtocolDNSConfig(networkMap.DNSConfig, dnsCache, dnsFwdPort),
|
||||
PeerConfig: toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH),
|
||||
PeerConfig: toPeerConfig(peer, networkMap.Network, dnsName, settings, httpConfig, deviceFlowConfig, networkMap.EnableSSH, networkMap.ForceRoutingPeerDNSResolution),
|
||||
},
|
||||
Checks: toProtocolChecks(ctx, checks),
|
||||
}
|
||||
|
||||
@@ -921,7 +921,7 @@ func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, ne
|
||||
// if peer has reached this point then it has logged in
|
||||
loginResp := &proto.LoginResponse{
|
||||
NetbirdConfig: toNetbirdConfig(s.config, nil, relayToken, nil, settings),
|
||||
PeerConfig: toPeerConfig(peer, network, s.networkMapController.GetDNSDomain(settings), settings, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, enableSSH),
|
||||
PeerConfig: toPeerConfig(peer, types.TwinNetwork(network), s.networkMapController.GetDNSDomain(settings), settings, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, enableSSH, false),
|
||||
Checks: toProtocolChecks(ctx, postureChecks),
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
@@ -31,10 +30,6 @@ type managerImpl struct {
|
||||
accountManager account.Manager
|
||||
}
|
||||
|
||||
func eventMetaResource(group *types.Group, resource *resourceTypes.NetworkResource) map[string]any {
|
||||
return map[string]any{"name": group.Name, "id": group.ID, "resource_name": resource.Name, "resource_id": resource.ID, "resource_type": resource.Type}
|
||||
}
|
||||
|
||||
type mockManager struct {
|
||||
}
|
||||
|
||||
@@ -114,7 +109,7 @@ func (m *managerImpl) AddResourceToGroupInTransaction(ctx context.Context, trans
|
||||
}
|
||||
|
||||
event := func() {
|
||||
m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceAddedToGroup, eventMetaResource(group, networkResource))
|
||||
m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceAddedToGroup, group.EventMetaResource(networkResource))
|
||||
}
|
||||
|
||||
return event, nil
|
||||
@@ -138,7 +133,7 @@ func (m *managerImpl) RemoveResourceFromGroupInTransaction(ctx context.Context,
|
||||
}
|
||||
|
||||
event := func() {
|
||||
m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceRemovedFromGroup, eventMetaResource(group, networkResource))
|
||||
m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceRemovedFromGroup, group.EventMetaResource(networkResource))
|
||||
}
|
||||
|
||||
return event, nil
|
||||
|
||||
@@ -446,7 +446,7 @@ func (h *Handler) GetAccessiblePeers(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
netMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, dns.CustomZone{}, nil, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers())
|
||||
|
||||
util.WriteJSONObject(ctx, w, toAccessiblePeers(netMap, account.Peers, dnsDomain))
|
||||
util.WriteJSONObject(ctx, w, toAccessiblePeers(account.Peers, netMap, dnsDomain))
|
||||
}
|
||||
|
||||
func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -534,20 +534,22 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request)
|
||||
util.WriteJSONObject(r.Context(), w, resp)
|
||||
}
|
||||
|
||||
// toAccessiblePeers rehydrates the calculated map's component peers into the
|
||||
// account's full peer objects, which carry the location/status/meta fields
|
||||
// the API response needs.
|
||||
func toAccessiblePeers(netMap *types.NetworkMap, accountPeers map[string]*nbpeer.Peer, dnsDomain string) []api.AccessiblePeer {
|
||||
// toAccessiblePeers resolves the twin peers in netMap back to the full account
|
||||
// peers (by ID) so the API response keeps Status/Name/OS/GeoNameID, which the
|
||||
// slim netmap twins intentionally don't carry.
|
||||
func toAccessiblePeers(accountPeers map[string]*nbpeer.Peer, netMap *types.NetworkMap, dnsDomain string) []api.AccessiblePeer {
|
||||
accessiblePeers := make([]api.AccessiblePeer, 0, len(netMap.Peers)+len(netMap.OfflinePeers))
|
||||
add := func(peers []*types.ComponentPeer) {
|
||||
for _, p := range peers {
|
||||
if peer := accountPeers[p.ID]; peer != nil {
|
||||
accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(peer, dnsDomain))
|
||||
}
|
||||
appendByID := func(id string) {
|
||||
if p, ok := accountPeers[id]; ok && p != nil {
|
||||
accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(p, dnsDomain))
|
||||
}
|
||||
}
|
||||
add(netMap.Peers)
|
||||
add(netMap.OfflinePeers)
|
||||
for _, p := range netMap.Peers {
|
||||
appendByID(p.ID)
|
||||
}
|
||||
for _, p := range netMap.OfflinePeers {
|
||||
appendByID(p.ID)
|
||||
}
|
||||
|
||||
return accessiblePeers
|
||||
}
|
||||
|
||||
@@ -683,81 +683,3 @@ func BackfillPublicIDs[T any](ctx context.Context, db *gorm.DB) error {
|
||||
log.WithContext(ctx).Infof("Backfill of empty public_id in table %s completed", tableName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// FoldCostAggregatesIntoBuckets migrates a per-request cost table from the old
|
||||
// "stored aggregate" shape (cost_usd + cache_cost_usd columns) to the per-bucket
|
||||
// breakdown, where the total and cache portion are derived on read instead.
|
||||
//
|
||||
// The fold preserves both aggregates exactly for historical rows: the cache
|
||||
// total moves into cached_input_cost_usd and the remainder into
|
||||
// input_cost_usd, so a row's derived total and cache cost still match what it
|
||||
// reported before the upgrade. The finer split is genuinely unknown for those
|
||||
// rows — the old schema never recorded a read/write or input/output division —
|
||||
// so it is lumped rather than guessed; only rows written after the upgrade
|
||||
// carry a true four-way split.
|
||||
//
|
||||
// Dropping the columns before folding would zero every historical row's cost,
|
||||
// so the update runs first and the drop only happens once it succeeds. A table
|
||||
// with no cost_usd column has already been migrated (or was created fresh) and
|
||||
// is skipped.
|
||||
func FoldCostAggregatesIntoBuckets[T any](ctx context.Context, db *gorm.DB) error {
|
||||
var model T
|
||||
|
||||
if !db.Migrator().HasTable(&model) {
|
||||
log.WithContext(ctx).Debugf("table for %T does not exist, no cost-bucket migration needed", model)
|
||||
return nil
|
||||
}
|
||||
if !db.Migrator().HasColumn(&model, "cost_usd") {
|
||||
log.WithContext(ctx).Debugf("table for %T has no cost_usd column, cost buckets already migrated", model)
|
||||
return nil
|
||||
}
|
||||
|
||||
stmt := &gorm.Statement{DB: db}
|
||||
if err := stmt.Parse(&model); err != nil {
|
||||
return fmt.Errorf("parse model schema: %w", err)
|
||||
}
|
||||
tableName := stmt.Schema.Table
|
||||
|
||||
// COALESCE guards rows whose new columns were added as NULL by an earlier
|
||||
// AutoMigrate run that predates the NOT NULL default.
|
||||
hasCacheColumn := db.Migrator().HasColumn(&model, "cache_cost_usd")
|
||||
cacheExpr := "0"
|
||||
if hasCacheColumn {
|
||||
cacheExpr = "COALESCE(cache_cost_usd, 0)"
|
||||
}
|
||||
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
// Only touch rows that carry a legacy total and no breakdown yet, so
|
||||
// the migration is idempotent and never overwrites a true split.
|
||||
update := fmt.Sprintf(`UPDATE %s
|
||||
SET input_cost_usd = COALESCE(cost_usd, 0) - %s,
|
||||
cached_input_cost_usd = %s,
|
||||
cache_creation_cost_usd = 0,
|
||||
output_cost_usd = 0
|
||||
WHERE COALESCE(cost_usd, 0) <> 0
|
||||
AND COALESCE(input_cost_usd, 0) = 0
|
||||
AND COALESCE(cached_input_cost_usd, 0) = 0
|
||||
AND COALESCE(cache_creation_cost_usd, 0) = 0
|
||||
AND COALESCE(output_cost_usd, 0) = 0`, tableName, cacheExpr, cacheExpr)
|
||||
res := tx.Exec(update)
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("fold legacy cost aggregates in %s: %w", tableName, res.Error)
|
||||
}
|
||||
log.WithContext(ctx).Infof("folded legacy cost aggregates into per-bucket columns for %d rows in table %s", res.RowsAffected, tableName)
|
||||
|
||||
if err := tx.Migrator().DropColumn(&model, "cost_usd"); err != nil {
|
||||
return fmt.Errorf("drop cost_usd from %s: %w", tableName, err)
|
||||
}
|
||||
if hasCacheColumn {
|
||||
if err := tx.Migrator().DropColumn(&model, "cache_cost_usd"); err != nil {
|
||||
return fmt.Errorf("drop cache_cost_usd from %s: %w", tableName, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Infof("migration of stored cost aggregates to per-bucket columns in table %s completed", tableName)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/migration"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/testutil"
|
||||
@@ -640,99 +639,3 @@ func TestCleanupOrphanedResources_SkipsWhenForeignKeyExists(t *testing.T) {
|
||||
db.Model(&testChildWithFK{}).Count(&count)
|
||||
assert.Equal(t, int64(2), count, "Both rows should survive — migration must skip when FK constraint exists")
|
||||
}
|
||||
|
||||
// legacyCostRow is the pre-breakdown shape of the usage table: cost was stored
|
||||
// as a total plus a cache portion, with no per-bucket columns. Used to build a
|
||||
// realistic pre-upgrade table for the fold migration to run against.
|
||||
type legacyCostRow struct {
|
||||
ID string `gorm:"primaryKey"`
|
||||
AccountID string
|
||||
Model string
|
||||
CostUSD float64
|
||||
CacheCostUSD float64
|
||||
}
|
||||
|
||||
func (legacyCostRow) TableName() string { return "agent_network_request_usage" }
|
||||
|
||||
// TestFoldCostAggregatesIntoBuckets_PreservesHistoricalCost covers the upgrade
|
||||
// path: a table written under the old schema must come out with its per-row
|
||||
// total and cache cost unchanged, because dropping cost_usd without folding it
|
||||
// forward would silently zero every historical row's spend.
|
||||
func TestFoldCostAggregatesIntoBuckets_PreservesHistoricalCost(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := setupDatabase(t)
|
||||
// setupDatabase hands back a process-shared database, so start from a clean
|
||||
// table rather than inheriting rows from another test.
|
||||
require.NoError(t, db.Migrator().DropTable(&agentNetworkTypes.AgentNetworkUsage{}))
|
||||
|
||||
require.NoError(t, db.AutoMigrate(&legacyCostRow{}), "legacy table must be created")
|
||||
require.NoError(t, db.Create(&legacyCostRow{
|
||||
ID: "u1", AccountID: "acct-1", Model: "claude-sonnet-4-6", CostUSD: 0.0123, CacheCostUSD: 0.0029,
|
||||
}).Error)
|
||||
require.NoError(t, db.Create(&legacyCostRow{
|
||||
ID: "u2", AccountID: "acct-1", Model: "gpt-4o", CostUSD: 0.5, CacheCostUSD: 0,
|
||||
}).Error)
|
||||
// A zero-cost row (denied / unpriced request) must stay zero, not be touched.
|
||||
require.NoError(t, db.Create(&legacyCostRow{ID: "u3", AccountID: "acct-1", Model: "gw/unpriced"}).Error)
|
||||
|
||||
// AutoMigrate adds the per-bucket columns alongside the legacy ones, exactly
|
||||
// as a real upgrade does before the post-auto migrations run.
|
||||
require.NoError(t, db.AutoMigrate(&agentNetworkTypes.AgentNetworkUsage{}), "new columns must be added")
|
||||
|
||||
require.NoError(t, migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkUsage](ctx, db))
|
||||
|
||||
assert.False(t, db.Migrator().HasColumn(&agentNetworkTypes.AgentNetworkUsage{}, "cost_usd"),
|
||||
"legacy cost_usd column must be dropped once folded")
|
||||
assert.False(t, db.Migrator().HasColumn(&agentNetworkTypes.AgentNetworkUsage{}, "cache_cost_usd"),
|
||||
"legacy cache_cost_usd column must be dropped once folded")
|
||||
|
||||
var rows []*agentNetworkTypes.AgentNetworkUsage
|
||||
require.NoError(t, db.Order("id").Find(&rows).Error)
|
||||
require.Len(t, rows, 3)
|
||||
|
||||
// u1: total and cache portion both preserved; the read/write and
|
||||
// input/output splits are unknowable for a legacy row, so the cache total
|
||||
// lands on cached_input and the remainder on input.
|
||||
assert.InDelta(t, 0.0123, rows[0].TotalCostUSD(), 1e-9, "historical total must survive the fold")
|
||||
assert.InDelta(t, 0.0029, rows[0].CacheCostUSD(), 1e-9, "historical cache cost must survive the fold")
|
||||
assert.InDelta(t, 0.0094, rows[0].InputCostUSD, 1e-9, "non-cache remainder lands on input")
|
||||
assert.InDelta(t, 0.0029, rows[0].CachedInputCostUSD, 1e-9, "legacy cache total lands on cached input")
|
||||
assert.Zero(t, rows[0].CacheCreationCostUSD, "legacy rows carry no read/write split to recover")
|
||||
assert.Zero(t, rows[0].OutputCostUSD, "legacy rows carry no input/output split to recover")
|
||||
|
||||
// u2: no cache spend — the whole total is the non-cache remainder.
|
||||
assert.InDelta(t, 0.5, rows[1].TotalCostUSD(), 1e-9, "cache-free historical total must survive")
|
||||
assert.Zero(t, rows[1].CacheCostUSD(), "a cache-free row must stay cache-free")
|
||||
|
||||
// u3: zero stays zero rather than being rewritten.
|
||||
assert.Zero(t, rows[2].TotalCostUSD(), "an unpriced row must remain unpriced")
|
||||
}
|
||||
|
||||
// TestFoldCostAggregatesIntoBuckets_SkipsAlreadyMigrated proves the migration is
|
||||
// safe to re-run: with no legacy column present it is a no-op that leaves a
|
||||
// true four-way split untouched.
|
||||
func TestFoldCostAggregatesIntoBuckets_SkipsAlreadyMigrated(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := setupDatabase(t)
|
||||
require.NoError(t, db.Migrator().DropTable(&agentNetworkTypes.AgentNetworkUsage{}))
|
||||
|
||||
require.NoError(t, db.AutoMigrate(&agentNetworkTypes.AgentNetworkUsage{}))
|
||||
// Timestamp must be set explicitly: a zero time.Time serialises as
|
||||
// '0000-00-00 00:00:00', which MySQL rejects under strict mode.
|
||||
require.NoError(t, db.Create(&agentNetworkTypes.AgentNetworkUsage{
|
||||
ID: "u1", AccountID: "acct-1", Model: "claude-sonnet-4-6",
|
||||
Timestamp: time.Date(2026, 5, 5, 9, 0, 0, 0, time.UTC),
|
||||
InputCostUSD: 0.001, CachedInputCostUSD: 0.002, CacheCreationCostUSD: 0.003, OutputCostUSD: 0.004,
|
||||
}).Error)
|
||||
|
||||
require.NoError(t, migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkUsage](ctx, db),
|
||||
"running against an already-migrated table must be a no-op, not an error")
|
||||
|
||||
var row agentNetworkTypes.AgentNetworkUsage
|
||||
require.NoError(t, db.First(&row, "id = ?", "u1").Error)
|
||||
assert.InDelta(t, 0.001, row.InputCostUSD, 1e-9, "a true split must not be rewritten")
|
||||
assert.InDelta(t, 0.002, row.CachedInputCostUSD, 1e-9)
|
||||
assert.InDelta(t, 0.003, row.CacheCreationCostUSD, 1e-9)
|
||||
assert.InDelta(t, 0.004, row.OutputCostUSD, 1e-9)
|
||||
assert.InDelta(t, 0.01, row.TotalCostUSD(), 1e-9, "derived total sums the four buckets")
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
nbDomain "github.com/netbirdio/netbird/shared/management/domain"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
sharedTypes "github.com/netbirdio/netbird/shared/management/types"
|
||||
)
|
||||
|
||||
type NetworkResourceType string
|
||||
@@ -65,27 +64,6 @@ func NewNetworkResource(accountID, networkID, name, description, address string,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ToComponent converts the resource to its self-contained components
|
||||
// representation. Returns nil for a nil resource.
|
||||
func (n *NetworkResource) ToComponent() *sharedTypes.ComponentResource {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
return &sharedTypes.ComponentResource{
|
||||
ID: n.ID,
|
||||
PublicID: n.PublicID,
|
||||
NetworkID: n.NetworkID,
|
||||
AccountID: n.AccountID,
|
||||
Name: n.Name,
|
||||
Description: n.Description,
|
||||
Type: sharedTypes.ComponentResourceType(n.Type),
|
||||
Address: n.Address,
|
||||
Domain: n.Domain,
|
||||
Prefix: n.Prefix,
|
||||
Enabled: n.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
func (n *NetworkResource) ToAPIResponse(groups []api.GroupMinimum) *api.NetworkResource {
|
||||
addr := n.Prefix.String()
|
||||
if n.Type == Domain {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/networks/types"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
sharedTypes "github.com/netbirdio/netbird/shared/management/types"
|
||||
)
|
||||
|
||||
type NetworkRouter struct {
|
||||
@@ -22,36 +21,6 @@ type NetworkRouter struct {
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// ToComponent converts the router to its self-contained components
|
||||
// representation. Returns nil for a nil router.
|
||||
func (n *NetworkRouter) ToComponent() *sharedTypes.ComponentRouter {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
return &sharedTypes.ComponentRouter{
|
||||
NetworkID: n.NetworkID,
|
||||
PublicID: n.PublicID,
|
||||
Peer: n.Peer,
|
||||
PeerGroups: n.PeerGroups,
|
||||
Masquerade: n.Masquerade,
|
||||
Metric: n.Metric,
|
||||
Enabled: n.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
// ToComponentMap converts a peer-keyed router map to its components
|
||||
// representation.
|
||||
func ToComponentMap(routers map[string]*NetworkRouter) map[string]*sharedTypes.ComponentRouter {
|
||||
if routers == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]*sharedTypes.ComponentRouter, len(routers))
|
||||
for id, r := range routers {
|
||||
out[id] = r.ToComponent()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func NewNetworkRouter(accountID string, networkID string, peer string, peerGroups []string, masquerade bool, metric int, enabled bool) (*NetworkRouter, error) {
|
||||
r := &NetworkRouter{
|
||||
ID: xid.New().String(),
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/posture"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
@@ -405,7 +406,7 @@ func (am *DefaultAccountManager) CreatePeerJob(ctx context.Context, accountID, p
|
||||
return status.NewPeerNotPartOfAccountError()
|
||||
}
|
||||
|
||||
meetMinVer, err := version.MeetsMinVersion(remoteJobsMinVer, p.Meta.WtVersion)
|
||||
meetMinVer, err := posture.MeetsMinVersion(remoteJobsMinVer, p.Meta.WtVersion)
|
||||
if !version.IsDevelopmentVersion(p.Meta.WtVersion) && (!meetMinVer || err != nil) {
|
||||
return status.Errorf(status.PreconditionFailed, "peer version %s does not meet the minimum required version %s for remote jobs", p.Meta.WtVersion, remoteJobsMinVer)
|
||||
}
|
||||
@@ -1588,7 +1589,7 @@ func affectedPeerIDsFromNetworkMap(nmap *types.NetworkMap, selfPeerID string) []
|
||||
}
|
||||
seen := make(map[string]struct{}, len(nmap.Peers)+len(nmap.OfflinePeers))
|
||||
ids := make([]string, 0, len(nmap.Peers)+len(nmap.OfflinePeers))
|
||||
add := func(peers []*types.ComponentPeer) {
|
||||
add := func(peers []*nmdata.Peer) {
|
||||
for _, p := range peers {
|
||||
if p == nil || p.ID == "" || p.ID == selfPeerID {
|
||||
continue
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/util"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
sharedTypes "github.com/netbirdio/netbird/shared/management/types"
|
||||
)
|
||||
|
||||
// Peer capability constants mirror the proto enum values.
|
||||
@@ -206,35 +205,6 @@ func (p *Peer) AddedWithSSOLogin() bool {
|
||||
return p.UserID != ""
|
||||
}
|
||||
|
||||
// ToComponent converts the peer to its self-contained components
|
||||
// representation, carrying exactly the subset of peer data that crosses the
|
||||
// components wire format. Returns nil for a nil peer so callers can convert
|
||||
// possibly-missing peers without guarding.
|
||||
func (p *Peer) ToComponent() *sharedTypes.ComponentPeer {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
cp := &sharedTypes.ComponentPeer{
|
||||
ID: p.ID,
|
||||
Key: p.Key,
|
||||
IP: p.IP,
|
||||
IPv6: p.IPv6,
|
||||
DNSLabel: p.DNSLabel,
|
||||
SSHKey: p.SSHKey,
|
||||
SSHEnabled: p.SSHEnabled,
|
||||
ServerSSHAllowed: p.Meta.Flags.ServerSSHAllowed,
|
||||
AgentVersion: p.Meta.WtVersion,
|
||||
SupportsSourcePrefixes: p.SupportsSourcePrefixes(),
|
||||
SupportsIPv6: p.SupportsIPv6(),
|
||||
LoginExpirationEnabled: p.LoginExpirationEnabled,
|
||||
AddedWithSSOLogin: p.AddedWithSSOLogin(),
|
||||
}
|
||||
if p.LastLogin != nil {
|
||||
cp.LastLogin = *p.LastLogin
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
// HasCapability reports whether the peer has the given capability.
|
||||
func (p *Peer) HasCapability(capability int32) bool {
|
||||
return slices.Contains(p.Meta.Capabilities, capability)
|
||||
|
||||
@@ -57,6 +57,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
nbroute "github.com/netbirdio/netbird/route"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
@@ -1091,22 +1092,22 @@ func TestToSyncResponse(t *testing.T) {
|
||||
Signature: "turn-pass",
|
||||
}
|
||||
networkMap := &types.NetworkMap{
|
||||
Network: &types.Network{Net: *ipnet, Serial: 1000},
|
||||
Peers: []*types.ComponentPeer{{
|
||||
Network: &nmdata.Network{Net: *ipnet, Serial: 1000},
|
||||
Peers: []*nmdata.Peer{{
|
||||
IP: netip.MustParseAddr("192.168.1.2"),
|
||||
IPv6: netip.MustParseAddr("fd00::2"),
|
||||
Key: "peer2-key",
|
||||
DNSLabel: "peer2",
|
||||
SSHEnabled: true,
|
||||
SSHKey: "peer2-ssh-key"}},
|
||||
OfflinePeers: []*types.ComponentPeer{{
|
||||
OfflinePeers: []*nmdata.Peer{{
|
||||
IP: netip.MustParseAddr("192.168.1.3"),
|
||||
IPv6: netip.MustParseAddr("fd00::3"),
|
||||
Key: "peer3-key",
|
||||
DNSLabel: "peer3",
|
||||
SSHEnabled: true,
|
||||
SSHKey: "peer3-ssh-key"}},
|
||||
Routes: []*nbroute.Route{
|
||||
Routes: []*nmdata.Route{
|
||||
{
|
||||
ID: "route1",
|
||||
Network: netip.MustParsePrefix("10.0.0.0/24"),
|
||||
|
||||
@@ -3,9 +3,11 @@ package posture
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/go-version"
|
||||
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
nbversion "github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
type NBVersionCheck struct {
|
||||
@@ -14,8 +16,14 @@ type NBVersionCheck struct {
|
||||
|
||||
var _ Check = (*NBVersionCheck)(nil)
|
||||
|
||||
// sanitizeVersion removes anything after the pre-release tag (e.g., "-dev", "-alpha", etc.)
|
||||
func sanitizeVersion(version string) string {
|
||||
parts := strings.Split(version, "-")
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
func (n *NBVersionCheck) Check(ctx context.Context, peer nbpeer.Peer) (bool, error) {
|
||||
meetsMin, err := nbversion.MeetsMinVersion(n.MinVersion, peer.Meta.WtVersion)
|
||||
meetsMin, err := MeetsMinVersion(n.MinVersion, peer.Meta.WtVersion)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -40,3 +48,21 @@ func (n *NBVersionCheck) Validate() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MeetsMinVersion checks if the peer's version meets or exceeds the minimum required version
|
||||
func MeetsMinVersion(minVer, peerVer string) (bool, error) {
|
||||
peerVer = sanitizeVersion(peerVer)
|
||||
minVer = sanitizeVersion(minVer)
|
||||
|
||||
peerNBVer, err := version.NewVersion(peerVer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
constraints, err := version.NewConstraint(">= " + minVer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return constraints.Check(peerNBVer), nil
|
||||
}
|
||||
|
||||
@@ -139,3 +139,68 @@ func TestNBVersionCheck_Validate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeetsMinVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
minVer string
|
||||
peerVer string
|
||||
want bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Peer version greater than min version",
|
||||
minVer: "0.26.0",
|
||||
peerVer: "0.60.1",
|
||||
want: true,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Peer version equals min version",
|
||||
minVer: "1.0.0",
|
||||
peerVer: "1.0.0",
|
||||
want: true,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Peer version less than min version",
|
||||
minVer: "1.0.0",
|
||||
peerVer: "0.9.9",
|
||||
want: false,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Peer version with pre-release tag greater than min version",
|
||||
minVer: "1.0.0",
|
||||
peerVer: "1.0.1-alpha",
|
||||
want: true,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid peer version format",
|
||||
minVer: "1.0.0",
|
||||
peerVer: "dev",
|
||||
want: false,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid min version format",
|
||||
minVer: "invalid.version",
|
||||
peerVer: "1.0.0",
|
||||
want: false,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := MeetsMinVersion(tt.minVer, tt.peerVer)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1201,7 +1201,7 @@ func TestGetNetworkMap_RouteSync(t *testing.T) {
|
||||
peer1Routes, err := am.GetNetworkMap(context.Background(), peer1ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, peer1Routes.Routes, 1, "we should receive one route for peer1")
|
||||
require.True(t, expectedRoute.Equal(peer1Routes.Routes[0]), "received route should be equal")
|
||||
require.True(t, types.TwinRoute(expectedRoute).Equal(peer1Routes.Routes[0]), "received route should be equal")
|
||||
|
||||
peer2Routes, err := am.GetNetworkMap(context.Background(), peer2ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -71,7 +71,7 @@ func (s *SqlStore) GetAgentNetworkMetrics(ctx context.Context) (AgentNetworkMetr
|
||||
usageRow := db.Model(&agentNetworkTypes.AgentNetworkUsage{}).
|
||||
Select("COALESCE(SUM(input_tokens), 0) AS input_tokens, " +
|
||||
"COALESCE(SUM(output_tokens), 0) AS output_tokens, " +
|
||||
"COALESCE(SUM" + agentNetworkTypes.CostUSDSQLExpr + ", 0) AS cost_usd").Row()
|
||||
"COALESCE(SUM(cost_usd), 0) AS cost_usd").Row()
|
||||
if err := usageRow.Scan(&m.InputTokens, &m.OutputTokens, &m.CostUSD); err != nil {
|
||||
return AgentNetworkMetrics{}, fmt.Errorf("scan agent network usage metrics: %w", err)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestAgentNetworkUsage_RealStore_RoundTrip(t *testing.T) {
|
||||
InputTokens: 1200,
|
||||
OutputTokens: 640,
|
||||
TotalTokens: 1840,
|
||||
InputCostUSD: 0.0231,
|
||||
CostUSD: 0.0231,
|
||||
}
|
||||
usageGroups := []agentNetworkTypes.AgentNetworkUsageGroup{
|
||||
{UsageID: usage.ID, GroupID: "grp-eng", AccountID: accountID},
|
||||
@@ -71,7 +71,7 @@ func TestAgentNetworkUsage_RealStore_RoundTrip(t *testing.T) {
|
||||
InputTokens: 1200,
|
||||
OutputTokens: 640,
|
||||
TotalTokens: 1840,
|
||||
InputCostUSD: 0.0231,
|
||||
CostUSD: 0.0231,
|
||||
}
|
||||
entryGroups := []agentNetworkTypes.AgentNetworkAccessLogGroup{
|
||||
{LogID: entry.ID, GroupID: "grp-eng", AccountID: accountID},
|
||||
@@ -127,7 +127,7 @@ func TestAgentNetworkUsageOverview_DailyAggregation(t *testing.T) {
|
||||
mk := func(id string, ts time.Time, model string, in, out int64, cost float64) *agentNetworkTypes.AgentNetworkUsage {
|
||||
return &agentNetworkTypes.AgentNetworkUsage{
|
||||
ID: id, AccountID: accountID, Timestamp: ts, Model: model,
|
||||
InputTokens: in, OutputTokens: out, TotalTokens: in + out, InputCostUSD: cost,
|
||||
InputTokens: in, OutputTokens: out, TotalTokens: in + out, CostUSD: cost,
|
||||
}
|
||||
}
|
||||
require.NoError(t, s.CreateAgentNetworkUsage(ctx, mk("u1", day1, "gpt-4o", 100, 50, 0.10), nil))
|
||||
@@ -143,7 +143,7 @@ func TestAgentNetworkUsageOverview_DailyAggregation(t *testing.T) {
|
||||
assert.Equal(t, "2026-05-05", buckets[0].PeriodStart, "oldest-first ordering")
|
||||
assert.Equal(t, int64(300), buckets[0].InputTokens, "same-day input tokens summed")
|
||||
assert.Equal(t, int64(130), buckets[0].OutputTokens)
|
||||
assert.InDelta(t, 0.30, buckets[0].TotalCostUSD(), 1e-9, "same-day cost summed")
|
||||
assert.InDelta(t, 0.30, buckets[0].CostUSD, 1e-9, "same-day cost summed")
|
||||
assert.Equal(t, "2026-05-06", buckets[1].PeriodStart)
|
||||
assert.Equal(t, int64(15), buckets[1].TotalTokens)
|
||||
|
||||
@@ -174,7 +174,7 @@ func TestAgentNetworkAccessLogSessions_RealStore(t *testing.T) {
|
||||
ID: id, AccountID: accountID, ServiceID: "svc", Timestamp: ts,
|
||||
UserID: user, StatusCode: 200, Provider: provider, Model: model,
|
||||
SessionID: session, Decision: decision,
|
||||
InputTokens: 100, OutputTokens: 50, TotalTokens: 150, InputCostUSD: cost,
|
||||
InputTokens: 100, OutputTokens: 50, TotalTokens: 150, CostUSD: cost,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ func TestAgentNetworkAccessLogSessions_RealStore(t *testing.T) {
|
||||
s1 := sessions[2]
|
||||
assert.Equal(t, 2, s1.RequestCount, "s1 has two requests")
|
||||
assert.Equal(t, int64(300), s1.TotalTokens, "tokens summed across the session")
|
||||
assert.InDelta(t, 0.30, s1.TotalCostUSD(), 1e-9, "cost summed across the session")
|
||||
assert.InDelta(t, 0.30, s1.CostUSD, 1e-9, "cost summed across the session")
|
||||
assert.Equal(t, "alice", s1.UserID)
|
||||
assert.Equal(t, "allow", s1.Decision)
|
||||
// SQLite hands times back in time.Local; normalise to UTC so the instant is
|
||||
|
||||
@@ -650,14 +650,6 @@ func getMigrationsPostAuto(ctx context.Context) []migrationFunc {
|
||||
func(db *gorm.DB) error {
|
||||
return migration.DropIndex[proxy.Proxy](ctx, db, "idx_proxy_account_id_unique")
|
||||
},
|
||||
// Post-auto so the per-bucket cost columns already exist when the legacy
|
||||
// aggregates are folded into them and dropped.
|
||||
func(db *gorm.DB) error {
|
||||
return migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkAccessLog](ctx, db)
|
||||
},
|
||||
func(db *gorm.DB) error {
|
||||
return migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkUsage](ctx, db)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,6 @@ import (
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
proxydomain "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones/records"
|
||||
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
|
||||
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
|
||||
networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
|
||||
@@ -1062,6 +1060,54 @@ func (a *Account) GetPeerConnectionResources(ctx context.Context, peer *nbpeer.P
|
||||
return peers, fwRules, authorizedUsers, sshEnabled
|
||||
}
|
||||
|
||||
// forcesRoutingPeerDNSResolution reports whether the given peer must run
|
||||
// routing-peer DNS resolution regardless of the account-global
|
||||
// RoutingPeerDNSResolutionEnabled setting. It returns true when the peer is a
|
||||
// router for a domain network resource that is targeted by an enabled
|
||||
// reverse-proxy service, so the peer's DNS forwarder starts and can resolve
|
||||
// the target for the embedded proxy peers. Embedded proxy peers themselves are
|
||||
// handled at PeerConfig build time.
|
||||
func (a *Account) forcesRoutingPeerDNSResolution(peerID string, routers map[string]map[string]*routerTypes.NetworkRouter) bool {
|
||||
targeted := a.proxyTargetedDomainResourceIDs()
|
||||
if len(targeted) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, resource := range a.NetworkResources {
|
||||
if resource == nil || !resource.Enabled || resource.Type != resourceTypes.Domain {
|
||||
continue
|
||||
}
|
||||
if _, ok := targeted[resource.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, isRouter := routers[resource.NetworkID][peerID]; isRouter {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// proxyTargetedDomainResourceIDs returns the set of domain network resource IDs
|
||||
// targeted by an enabled, non-terminated reverse-proxy service.
|
||||
func (a *Account) proxyTargetedDomainResourceIDs() map[string]struct{} {
|
||||
ids := make(map[string]struct{})
|
||||
for _, svc := range a.Services {
|
||||
if svc == nil || !svc.Enabled || svc.Terminated {
|
||||
continue
|
||||
}
|
||||
for _, target := range svc.Targets {
|
||||
if target == nil || !target.Enabled {
|
||||
continue
|
||||
}
|
||||
if target.TargetType == service.TargetTypeDomain {
|
||||
ids[target.TargetId] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (a *Account) getAllowedUserIDs() map[string]struct{} {
|
||||
users := make(map[string]struct{})
|
||||
for _, nbUser := range a.Users {
|
||||
@@ -1082,7 +1128,6 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer
|
||||
peersExists := make(map[string]struct{})
|
||||
rules := make([]*FirewallRule, 0)
|
||||
peers := make([]*nbpeer.Peer, 0)
|
||||
targetComponent := targetPeer.ToComponent()
|
||||
|
||||
return func(rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int) {
|
||||
for _, peer := range groupPeers {
|
||||
@@ -1118,10 +1163,10 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer
|
||||
if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 {
|
||||
rules = append(rules, &fr)
|
||||
} else {
|
||||
rules = append(rules, ExpandPortsAndRanges(fr, rule, targetComponent)...)
|
||||
rules = append(rules, ExpandPortsAndRanges(fr, rule, targetPeer)...)
|
||||
}
|
||||
|
||||
rules = AppendIPv6FirewallRule(rules, rulesExists, peer.ToComponent(), targetComponent, rule, FirewallRuleContext{
|
||||
rules = AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, FirewallRuleContext{
|
||||
Direction: direction,
|
||||
DirStr: strconv.Itoa(direction),
|
||||
ProtocolStr: string(protocol),
|
||||
@@ -1281,7 +1326,7 @@ func (a *Account) getRouteFirewallRules(ctx context.Context, peerID string, poli
|
||||
return fwRules
|
||||
}
|
||||
|
||||
func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}, validatedPeersMap map[string]struct{}) []*ComponentPeer {
|
||||
func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}, validatedPeersMap map[string]struct{}) []*nbpeer.Peer {
|
||||
distPeersWithPolicy := make(map[string]struct{})
|
||||
for _, id := range rule.Sources {
|
||||
group := a.Groups[id]
|
||||
@@ -1308,13 +1353,13 @@ func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID
|
||||
}
|
||||
}
|
||||
|
||||
distributionGroupPeers := make([]*ComponentPeer, 0, len(distPeersWithPolicy))
|
||||
distributionGroupPeers := make([]*nbpeer.Peer, 0, len(distPeersWithPolicy))
|
||||
for pID := range distPeersWithPolicy {
|
||||
peer := a.Peers[pID]
|
||||
if peer == nil {
|
||||
continue
|
||||
}
|
||||
distributionGroupPeers = append(distributionGroupPeers, peer.ToComponent())
|
||||
distributionGroupPeers = append(distributionGroupPeers, peer)
|
||||
}
|
||||
return distributionGroupPeers
|
||||
}
|
||||
@@ -1799,66 +1844,3 @@ func filterZoneRecordsForPeers(peer *nbpeer.Peer, customZone nbdns.CustomZone, p
|
||||
|
||||
return filteredRecords
|
||||
}
|
||||
|
||||
// filterPeerAppliedZones filters account zones based on the peer's group membership
|
||||
func filterPeerAppliedZones(ctx context.Context, accountZones []*zones.Zone, peerGroups LookupMap) []nbdns.CustomZone {
|
||||
var customZones []nbdns.CustomZone
|
||||
|
||||
if len(peerGroups) == 0 {
|
||||
return customZones
|
||||
}
|
||||
|
||||
for _, zone := range accountZones {
|
||||
if !zone.Enabled || len(zone.Records) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
hasAccess := false
|
||||
for _, distGroupID := range zone.DistributionGroups {
|
||||
if _, found := peerGroups[distGroupID]; found {
|
||||
hasAccess = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasAccess {
|
||||
continue
|
||||
}
|
||||
|
||||
simpleRecords := make([]nbdns.SimpleRecord, 0, len(zone.Records))
|
||||
for _, record := range zone.Records {
|
||||
var recordType int
|
||||
rData := record.Content
|
||||
|
||||
switch record.Type {
|
||||
case records.RecordTypeA:
|
||||
recordType = int(dns.TypeA)
|
||||
case records.RecordTypeAAAA:
|
||||
recordType = int(dns.TypeAAAA)
|
||||
case records.RecordTypeCNAME:
|
||||
recordType = int(dns.TypeCNAME)
|
||||
rData = dns.Fqdn(record.Content)
|
||||
default:
|
||||
log.WithContext(ctx).Warnf("unknown DNS record type %s for record %s", record.Type, record.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
simpleRecords = append(simpleRecords, nbdns.SimpleRecord{
|
||||
Name: dns.Fqdn(record.Name),
|
||||
Type: recordType,
|
||||
Class: nbdns.DefaultClass,
|
||||
TTL: record.TTL,
|
||||
RData: rData,
|
||||
})
|
||||
}
|
||||
|
||||
customZones = append(customZones, nbdns.CustomZone{
|
||||
Domain: dns.Fqdn(zone.Domain),
|
||||
Records: simpleRecords,
|
||||
SearchDomainDisabled: !zone.EnableSearchDomain,
|
||||
NonAuthoritative: true,
|
||||
})
|
||||
}
|
||||
|
||||
return customZones
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -11,7 +10,6 @@ import (
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones"
|
||||
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
|
||||
"github.com/netbirdio/netbird/management/server/telemetry"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
)
|
||||
|
||||
// GetPeerNetworkMapResult dispatches to either the legacy-NetworkMap path or
|
||||
@@ -92,6 +90,9 @@ func (a *Account) GetPeerNetworkMapFromComponents(
|
||||
return nm
|
||||
}
|
||||
|
||||
// GetPeerNetworkMapComponents builds the account's slim twin store and computes
|
||||
// the peer's components on it. The calculation itself lives on
|
||||
// networkmap.NetworkMapData and never touches the Account.
|
||||
func (a *Account) GetPeerNetworkMapComponents(
|
||||
ctx context.Context,
|
||||
peerID string,
|
||||
@@ -102,623 +103,10 @@ func (a *Account) GetPeerNetworkMapComponents(
|
||||
routers map[string]map[string]*routerTypes.NetworkRouter,
|
||||
groupIDToUserIDs map[string][]string,
|
||||
) *NetworkMapComponents {
|
||||
peer := a.Peers[peerID]
|
||||
// this can never happen, things are very wrong if it did
|
||||
// TODO (dmitri) maybe consider using invariants?
|
||||
if peer == nil {
|
||||
log.WithField("peer id", peerID).Error("NetworkMapComponents are computed for a peer missing from the account")
|
||||
return EmptyNetworkMapComponents(&NetworkMapComponents{
|
||||
PeerID: peerID,
|
||||
Network: a.Network.Copy(),
|
||||
// must include the target peer as it's required on the client
|
||||
Peers: map[string]*ComponentPeer{peerID: peer.ToComponent()},
|
||||
})
|
||||
nmd := a.toNetworkMapData(accountZones, validatedPeersMap, resourcePolicies, routers, groupIDToUserIDs)
|
||||
components := nmd.GetPeerNetworkMapComponents(peerID, toTwinCustomZone(peersCustomZone))
|
||||
if components != nil {
|
||||
components.ForceRoutingPeerDNSResolution = a.forcesRoutingPeerDNSResolution(peerID, routers)
|
||||
}
|
||||
|
||||
if _, ok := validatedPeersMap[peerID]; !ok {
|
||||
// Mirror legacy graceful-degrade: GetPeerNetworkMapFromComponents
|
||||
// returns &NetworkMap{Network: a.Network.Copy()} when components is
|
||||
// nil. Match that floor so the receiving client always sees the
|
||||
// account Network identifier, not a fully-empty envelope.
|
||||
return EmptyNetworkMapComponents(&NetworkMapComponents{
|
||||
PeerID: peerID,
|
||||
Network: a.Network.Copy(),
|
||||
// must include the target peer as it's required on the client
|
||||
Peers: map[string]*ComponentPeer{peerID: peer.ToComponent()},
|
||||
})
|
||||
}
|
||||
|
||||
components := &NetworkMapComponents{
|
||||
PeerID: peerID,
|
||||
Network: a.Network.Copy(),
|
||||
NameServerGroups: make([]*nbdns.NameServerGroup, 0),
|
||||
CustomZoneDomain: peersCustomZone.Domain,
|
||||
ResourcePoliciesMap: make(map[string][]*Policy),
|
||||
RoutersMap: make(map[string]map[string]*ComponentRouter),
|
||||
NetworkResources: make([]*ComponentResource, 0),
|
||||
PostureFailedPeers: make(map[string]map[string]struct{}, len(a.PostureChecks)),
|
||||
RouterPeers: make(map[string]*ComponentPeer),
|
||||
NetworkXIDToPublicID: make(map[string]string, len(a.Networks)),
|
||||
PostureCheckXIDToPublicID: make(map[string]string, len(a.PostureChecks)),
|
||||
}
|
||||
for _, n := range a.Networks {
|
||||
if n != nil {
|
||||
components.NetworkXIDToPublicID[n.ID] = n.PublicID
|
||||
}
|
||||
}
|
||||
for _, pc := range a.PostureChecks {
|
||||
if pc != nil {
|
||||
components.PostureCheckXIDToPublicID[pc.ID] = pc.PublicID
|
||||
}
|
||||
}
|
||||
|
||||
components.AccountSettings = &AccountSettingsInfo{
|
||||
PeerLoginExpirationEnabled: a.Settings.PeerLoginExpirationEnabled,
|
||||
PeerLoginExpiration: a.Settings.PeerLoginExpiration,
|
||||
PeerInactivityExpirationEnabled: a.Settings.PeerInactivityExpirationEnabled,
|
||||
PeerInactivityExpiration: a.Settings.PeerInactivityExpiration,
|
||||
}
|
||||
|
||||
components.DNSSettings = &a.DNSSettings
|
||||
|
||||
// relevantPeers always contains the target peer (peerID)
|
||||
relevantPeers, relevantGroups, relevantPolicies, relevantRoutes, sshReqs := a.getPeersGroupsPoliciesRoutes(ctx, peerID, peer.SSHEnabled, validatedPeersMap, &components.PostureFailedPeers)
|
||||
|
||||
if len(sshReqs.neededGroupIDs) > 0 {
|
||||
components.GroupIDToUserIDs = filterGroupIDToUserIDs(groupIDToUserIDs, sshReqs.neededGroupIDs)
|
||||
}
|
||||
if sshReqs.needAllowedUserIDs {
|
||||
components.AllowedUserIDs = a.getAllowedUserIDs()
|
||||
}
|
||||
|
||||
components.Peers = relevantPeers
|
||||
components.Groups = GroupsToComponent(relevantGroups)
|
||||
components.Policies = relevantPolicies
|
||||
components.Routes = relevantRoutes
|
||||
components.AllDNSRecords = filterDNSRecordsByPeers(peersCustomZone.Records, relevantPeers, peer.SupportsIPv6() && peer.IPv6.IsValid())
|
||||
|
||||
peerGroups := a.GetPeerGroups(peerID)
|
||||
components.AccountZones = filterPeerAppliedZones(ctx, accountZones, peerGroups)
|
||||
components.AccountZones = append(components.AccountZones, a.SynthesizePrivateServiceZones(peerID)...)
|
||||
|
||||
for _, nsGroup := range a.NameServerGroups {
|
||||
if nsGroup.Enabled {
|
||||
for _, gID := range nsGroup.Groups {
|
||||
if _, found := relevantGroups[gID]; found {
|
||||
components.NameServerGroups = append(components.NameServerGroups, nsGroup)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, resource := range a.NetworkResources {
|
||||
if !resource.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
policies, exists := resourcePolicies[resource.ID]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
addSourcePeers := false
|
||||
|
||||
networkRoutingPeers, routerExists := routers[resource.NetworkID]
|
||||
if routerExists {
|
||||
if _, ok := networkRoutingPeers[peerID]; ok {
|
||||
addSourcePeers = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, policy := range policies {
|
||||
if addSourcePeers {
|
||||
var peers []string
|
||||
if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" {
|
||||
peers = []string{policy.Rules[0].SourceResource.ID}
|
||||
} else {
|
||||
peers = a.getUniquePeerIDsFromGroupsIDs(ctx, policy.SourceGroups())
|
||||
}
|
||||
for _, pID := range a.getPostureValidPeersSaveFailed(peers, policy.SourcePostureChecks, validatedPeersMap, &components.PostureFailedPeers) {
|
||||
if _, exists := components.Peers[pID]; !exists {
|
||||
components.Peers[pID] = a.GetPeer(pID).ToComponent()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
peerInSources := false
|
||||
if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" {
|
||||
peerInSources = policy.Rules[0].SourceResource.ID == peerID
|
||||
} else {
|
||||
for _, groupID := range policy.SourceGroups() {
|
||||
if group := a.GetGroup(groupID); group != nil && slices.Contains(group.Peers, peerID) {
|
||||
peerInSources = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !peerInSources {
|
||||
continue
|
||||
}
|
||||
isValid, pname := a.validatePostureChecksOnPeerGetFailed(ctx, policy.SourcePostureChecks, peerID)
|
||||
if !isValid && len(pname) > 0 {
|
||||
if _, ok := components.PostureFailedPeers[pname]; !ok {
|
||||
components.PostureFailedPeers[pname] = make(map[string]struct{})
|
||||
}
|
||||
components.PostureFailedPeers[pname][peer.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
addSourcePeers = true
|
||||
}
|
||||
|
||||
for _, rule := range policy.Rules {
|
||||
for _, srcGroupID := range rule.Sources {
|
||||
if g := a.Groups[srcGroupID]; g != nil {
|
||||
if _, exists := components.Groups[srcGroupID]; !exists {
|
||||
components.Groups[srcGroupID] = g.ToComponent()
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, dstGroupID := range rule.Destinations {
|
||||
if g := a.Groups[dstGroupID]; g != nil {
|
||||
if _, exists := components.Groups[dstGroupID]; !exists {
|
||||
components.Groups[dstGroupID] = g.ToComponent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
components.ResourcePoliciesMap[resource.ID] = policies
|
||||
}
|
||||
|
||||
// Only expose router peers and the per-network routers_map when this
|
||||
// target peer actually has access to the resource (either as a router
|
||||
// itself or via a policy that includes it as a source). Without this
|
||||
// gate, every peer's envelope was leaking router peers of every
|
||||
// network in the account — accounts with many tenants/networks
|
||||
// shipped tens of unrelated peers in `peers[]` and `routers_map`.
|
||||
if addSourcePeers {
|
||||
components.RoutersMap[resource.NetworkID] = routerTypes.ToComponentMap(networkRoutingPeers)
|
||||
for peerIDKey := range networkRoutingPeers {
|
||||
if p := a.Peers[peerIDKey]; p != nil {
|
||||
cp := components.RouterPeers[peerIDKey]
|
||||
if cp == nil {
|
||||
cp = p.ToComponent()
|
||||
components.RouterPeers[peerIDKey] = cp
|
||||
}
|
||||
if _, exists := components.Peers[peerIDKey]; !exists {
|
||||
if _, validated := validatedPeersMap[peerIDKey]; validated {
|
||||
components.Peers[peerIDKey] = cp
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
components.NetworkResources = append(components.NetworkResources, resource.ToComponent())
|
||||
}
|
||||
}
|
||||
|
||||
filterGroupPeers(&components.Groups, components.Peers)
|
||||
filterPostureFailedPeers(&components.PostureFailedPeers, components.Policies, components.ResourcePoliciesMap, components.Peers)
|
||||
|
||||
return components
|
||||
}
|
||||
|
||||
type sshRequirements struct {
|
||||
neededGroupIDs map[string]struct{}
|
||||
needAllowedUserIDs bool
|
||||
}
|
||||
|
||||
func (a *Account) getPeersGroupsPoliciesRoutes(
|
||||
ctx context.Context,
|
||||
peerID string,
|
||||
peerSSHEnabled bool,
|
||||
validatedPeersMap map[string]struct{},
|
||||
postureFailedPeers *map[string]map[string]struct{},
|
||||
) (map[string]*ComponentPeer, map[string]*Group, []*Policy, []*route.Route, sshRequirements) {
|
||||
relevantPeerIDs := make(map[string]*ComponentPeer, len(a.Peers)/4)
|
||||
relevantGroupIDs := make(map[string]*Group, len(a.Groups)/4)
|
||||
relevantPolicies := make([]*Policy, 0, len(a.Policies))
|
||||
relevantRoutes := make([]*route.Route, 0, len(a.Routes))
|
||||
sshReqs := sshRequirements{neededGroupIDs: make(map[string]struct{})}
|
||||
|
||||
relevantPeerIDs[peerID] = a.GetPeer(peerID).ToComponent()
|
||||
|
||||
peerGroupSet := make(map[string]struct{}, 8)
|
||||
for groupID, group := range a.Groups {
|
||||
if slices.Contains(group.Peers, peerID) {
|
||||
relevantGroupIDs[groupID] = a.GetGroup(groupID)
|
||||
peerGroupSet[groupID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
routeAccessControlGroups := make(map[string]struct{})
|
||||
for _, r := range a.Routes {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
relevant := r.Peer == peerID
|
||||
if !relevant {
|
||||
for _, groupID := range r.PeerGroups {
|
||||
if _, ok := peerGroupSet[groupID]; ok {
|
||||
relevant = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !relevant && r.Enabled {
|
||||
for _, groupID := range r.Groups {
|
||||
if _, ok := peerGroupSet[groupID]; ok {
|
||||
relevant = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !relevant {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, groupID := range r.PeerGroups {
|
||||
relevantGroupIDs[groupID] = a.GetGroup(groupID)
|
||||
}
|
||||
for _, groupID := range r.Groups {
|
||||
relevantGroupIDs[groupID] = a.GetGroup(groupID)
|
||||
}
|
||||
if r.Enabled {
|
||||
for _, groupID := range r.AccessControlGroups {
|
||||
relevantGroupIDs[groupID] = a.GetGroup(groupID)
|
||||
routeAccessControlGroups[groupID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Include route advertisers in relevantPeerIDs. The envelope
|
||||
// encoder writes route.peer_index by looking up r.Peer in the
|
||||
// shipped peers list; if the advertiser is policy-isolated from
|
||||
// the target peer (no rule edge between them), it would otherwise
|
||||
// be omitted and the decoder would fail to resolve r.Peer, leaving
|
||||
// the client without a WG tunnel target for this route. Legacy
|
||||
// NetworkMap.Routes shipped the WG public key inline, so the
|
||||
// equivalence path doesn't surface this — but the dependency is
|
||||
// real once a client actually tries to use the route.
|
||||
// Gate by validatedPeersMap so non-validated advertisers stay out
|
||||
// (matches the network-resource router behaviour at the bottom of
|
||||
// this loop, and the legacy invariant that only validated peers
|
||||
// reach a client's view).
|
||||
if r.Peer != "" {
|
||||
if _, ok := validatedPeersMap[r.Peer]; ok {
|
||||
if p := a.GetPeer(r.Peer); p != nil {
|
||||
relevantPeerIDs[r.Peer] = p.ToComponent()
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, groupID := range r.PeerGroups {
|
||||
g := a.GetGroup(groupID)
|
||||
if g == nil {
|
||||
continue
|
||||
}
|
||||
for _, pid := range g.Peers {
|
||||
if _, exists := relevantPeerIDs[pid]; exists {
|
||||
continue
|
||||
}
|
||||
if _, ok := validatedPeersMap[pid]; !ok {
|
||||
continue
|
||||
}
|
||||
if p := a.GetPeer(pid); p != nil {
|
||||
relevantPeerIDs[pid] = p.ToComponent()
|
||||
}
|
||||
}
|
||||
}
|
||||
relevantRoutes = append(relevantRoutes, r)
|
||||
}
|
||||
|
||||
for _, policy := range a.Policies {
|
||||
if !policy.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
policyRelevant := false
|
||||
for _, rule := range policy.Rules {
|
||||
if !rule.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(routeAccessControlGroups) > 0 {
|
||||
for _, destGroupID := range rule.Destinations {
|
||||
if _, needed := routeAccessControlGroups[destGroupID]; needed {
|
||||
policyRelevant = true
|
||||
for _, srcGroupID := range rule.Sources {
|
||||
relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID)
|
||||
}
|
||||
for _, dstGroupID := range rule.Destinations {
|
||||
relevantGroupIDs[dstGroupID] = a.GetGroup(dstGroupID)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sourcePeers, destinationPeers []string
|
||||
var peerInSources, peerInDestinations bool
|
||||
|
||||
if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" {
|
||||
sourcePeers = []string{rule.SourceResource.ID}
|
||||
if rule.SourceResource.ID == peerID {
|
||||
peerInSources = true
|
||||
}
|
||||
} else {
|
||||
sourcePeers, peerInSources = a.getPeersFromGroups(ctx, rule.Sources, peerID, policy.SourcePostureChecks, validatedPeersMap, postureFailedPeers)
|
||||
}
|
||||
|
||||
if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" {
|
||||
destinationPeers = []string{rule.DestinationResource.ID}
|
||||
if rule.DestinationResource.ID == peerID {
|
||||
peerInDestinations = true
|
||||
}
|
||||
} else {
|
||||
destinationPeers, peerInDestinations = a.getPeersFromGroups(ctx, rule.Destinations, peerID, nil, validatedPeersMap, postureFailedPeers)
|
||||
}
|
||||
|
||||
if peerInSources {
|
||||
policyRelevant = true
|
||||
for _, pid := range destinationPeers {
|
||||
if _, exists := relevantPeerIDs[pid]; !exists {
|
||||
relevantPeerIDs[pid] = a.GetPeer(pid).ToComponent()
|
||||
}
|
||||
}
|
||||
for _, dstGroupID := range rule.Destinations {
|
||||
relevantGroupIDs[dstGroupID] = a.GetGroup(dstGroupID)
|
||||
}
|
||||
}
|
||||
|
||||
if peerInDestinations {
|
||||
policyRelevant = true
|
||||
for _, pid := range sourcePeers {
|
||||
if _, exists := relevantPeerIDs[pid]; !exists {
|
||||
relevantPeerIDs[pid] = a.GetPeer(pid).ToComponent()
|
||||
}
|
||||
}
|
||||
for _, srcGroupID := range rule.Sources {
|
||||
relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID)
|
||||
}
|
||||
|
||||
if rule.Protocol == PolicyRuleProtocolNetbirdSSH {
|
||||
switch {
|
||||
case len(rule.AuthorizedGroups) > 0:
|
||||
for groupID := range rule.AuthorizedGroups {
|
||||
sshReqs.neededGroupIDs[groupID] = struct{}{}
|
||||
}
|
||||
case rule.AuthorizedUser != "":
|
||||
default:
|
||||
sshReqs.needAllowedUserIDs = true
|
||||
}
|
||||
} else if PolicyRuleImpliesLegacySSH(rule) && peerSSHEnabled {
|
||||
sshReqs.needAllowedUserIDs = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if policyRelevant {
|
||||
relevantPolicies = append(relevantPolicies, policy)
|
||||
}
|
||||
}
|
||||
|
||||
return relevantPeerIDs, relevantGroupIDs, relevantPolicies, relevantRoutes, sshReqs
|
||||
}
|
||||
|
||||
func (a *Account) getPeersFromGroups(ctx context.Context, groups []string, peerID string, sourcePostureChecksIDs []string,
|
||||
validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}) ([]string, bool) {
|
||||
peerInGroups := false
|
||||
filteredPeerIDs := make([]string, 0, len(groups))
|
||||
seenPeerIds := make(map[string]struct{}, len(groups))
|
||||
|
||||
for _, gid := range groups {
|
||||
group := a.GetGroup(gid)
|
||||
if group == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if group.IsGroupAll() || len(groups) == 1 {
|
||||
filteredPeerIDs = make([]string, 0, len(group.Peers))
|
||||
peerInGroups = false
|
||||
for _, pid := range group.Peers {
|
||||
peer, ok := a.Peers[pid]
|
||||
if !ok || peer == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := validatedPeersMap[peer.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
isValid, pname := a.validatePostureChecksOnPeerGetFailed(ctx, sourcePostureChecksIDs, peer.ID)
|
||||
if !isValid && len(pname) > 0 {
|
||||
if _, ok := (*postureFailedPeers)[pname]; !ok {
|
||||
(*postureFailedPeers)[pname] = make(map[string]struct{})
|
||||
}
|
||||
(*postureFailedPeers)[pname][peer.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
|
||||
if peer.ID == peerID {
|
||||
peerInGroups = true
|
||||
continue
|
||||
}
|
||||
|
||||
filteredPeerIDs = append(filteredPeerIDs, peer.ID)
|
||||
}
|
||||
return filteredPeerIDs, peerInGroups
|
||||
}
|
||||
|
||||
for _, pid := range group.Peers {
|
||||
if _, seen := seenPeerIds[pid]; seen {
|
||||
continue
|
||||
}
|
||||
seenPeerIds[pid] = struct{}{}
|
||||
peer, ok := a.Peers[pid]
|
||||
if !ok || peer == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := validatedPeersMap[peer.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
isValid, pname := a.validatePostureChecksOnPeerGetFailed(ctx, sourcePostureChecksIDs, peer.ID)
|
||||
if !isValid && len(pname) > 0 {
|
||||
if _, ok := (*postureFailedPeers)[pname]; !ok {
|
||||
(*postureFailedPeers)[pname] = make(map[string]struct{})
|
||||
}
|
||||
(*postureFailedPeers)[pname][peer.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
|
||||
if peer.ID == peerID {
|
||||
peerInGroups = true
|
||||
continue
|
||||
}
|
||||
|
||||
filteredPeerIDs = append(filteredPeerIDs, peer.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredPeerIDs, peerInGroups
|
||||
}
|
||||
|
||||
func (a *Account) validatePostureChecksOnPeerGetFailed(ctx context.Context, sourcePostureChecksID []string, peerID string) (bool, string) {
|
||||
peer, ok := a.Peers[peerID]
|
||||
if !ok || peer == nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
for _, postureChecksID := range sourcePostureChecksID {
|
||||
postureChecks := a.GetPostureChecks(postureChecksID)
|
||||
if postureChecks == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, check := range postureChecks.GetChecks() {
|
||||
isValid, _ := check.Check(ctx, *peer)
|
||||
if !isValid {
|
||||
return false, postureChecksID
|
||||
}
|
||||
}
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func (a *Account) getPostureValidPeersSaveFailed(inputPeers []string, postureChecksIDs []string, validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}) []string {
|
||||
var dest []string
|
||||
for _, peerID := range inputPeers {
|
||||
if _, validated := validatedPeersMap[peerID]; !validated {
|
||||
continue
|
||||
}
|
||||
valid, pname := a.validatePostureChecksOnPeerGetFailed(context.Background(), postureChecksIDs, peerID)
|
||||
if valid {
|
||||
dest = append(dest, peerID)
|
||||
continue
|
||||
}
|
||||
if _, ok := (*postureFailedPeers)[pname]; !ok {
|
||||
(*postureFailedPeers)[pname] = make(map[string]struct{})
|
||||
}
|
||||
(*postureFailedPeers)[pname][peerID] = struct{}{}
|
||||
}
|
||||
return dest
|
||||
}
|
||||
|
||||
// filterGroupPeers trims each group's Peers slice to only those peers that
|
||||
// also appear in `peers`. Groups whose filtered list is empty are NOT
|
||||
// deleted from the map — they're kept so the components wire encoder can
|
||||
// still resolve seq references from routes/policies/access-control groups
|
||||
// that name them. Calculate() tolerates groups with empty Peers (the inner
|
||||
// loops simply iterate zero times), so retaining them is behaviourally a
|
||||
// no-op for the legacy path that consumes the same NetworkMapComponents.
|
||||
func filterGroupPeers(groups *map[string]*ComponentGroup, peers map[string]*ComponentPeer) {
|
||||
for groupID, groupInfo := range *groups {
|
||||
filteredPeers := make([]string, 0, len(groupInfo.Peers))
|
||||
for _, pid := range groupInfo.Peers {
|
||||
if _, exists := peers[pid]; exists {
|
||||
filteredPeers = append(filteredPeers, pid)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filteredPeers) != len(groupInfo.Peers) {
|
||||
ng := *groupInfo
|
||||
ng.Peers = filteredPeers
|
||||
(*groups)[groupID] = &ng
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{}, policies []*Policy, resourcePoliciesMap map[string][]*Policy, peers map[string]*ComponentPeer) {
|
||||
if len(*postureFailedPeers) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
referencedPostureChecks := make(map[string]struct{})
|
||||
for _, policy := range policies {
|
||||
for _, checkID := range policy.SourcePostureChecks {
|
||||
referencedPostureChecks[checkID] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, resPolicies := range resourcePoliciesMap {
|
||||
for _, policy := range resPolicies {
|
||||
for _, checkID := range policy.SourcePostureChecks {
|
||||
referencedPostureChecks[checkID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for checkID, failedPeers := range *postureFailedPeers {
|
||||
if _, referenced := referencedPostureChecks[checkID]; !referenced {
|
||||
delete(*postureFailedPeers, checkID)
|
||||
continue
|
||||
}
|
||||
for peerID := range failedPeers {
|
||||
if _, exists := peers[peerID]; !exists {
|
||||
delete(failedPeers, peerID)
|
||||
}
|
||||
}
|
||||
if len(failedPeers) == 0 {
|
||||
delete(*postureFailedPeers, checkID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func filterDNSRecordsByPeers(records []nbdns.SimpleRecord, peers map[string]*ComponentPeer, includeIPv6 bool) []nbdns.SimpleRecord {
|
||||
if len(records) == 0 || len(peers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Include both v4 and v6 addresses so AAAA records (whose RData is an IPv6
|
||||
// address) are not filtered out when peers have IPv6 assigned. When the
|
||||
// requesting peer doesn't have IPv6, omit v6 IPs so AAAA records get dropped.
|
||||
peerIPs := make(map[string]struct{}, len(peers)*2)
|
||||
for _, peer := range peers {
|
||||
if peer == nil {
|
||||
continue
|
||||
}
|
||||
peerIPs[peer.IP.String()] = struct{}{}
|
||||
if includeIPv6 && peer.IPv6.IsValid() {
|
||||
peerIPs[peer.IPv6.String()] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
filteredRecords := make([]nbdns.SimpleRecord, 0, len(records))
|
||||
for _, record := range records {
|
||||
if _, exists := peerIPs[record.RData]; exists {
|
||||
filteredRecords = append(filteredRecords, record)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredRecords
|
||||
}
|
||||
|
||||
func filterGroupIDToUserIDs(fullMap map[string][]string, neededGroupIDs map[string]struct{}) map[string][]string {
|
||||
if len(neededGroupIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
filtered := make(map[string][]string, len(neededGroupIDs))
|
||||
for groupID := range neededGroupIDs {
|
||||
if users, ok := fullMap[groupID]; ok {
|
||||
filtered[groupID] = users
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
515
management/server/types/account_networkmapdata.go
Normal file
515
management/server/types/account_networkmapdata.go
Normal file
@@ -0,0 +1,515 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"github.com/miekg/dns"
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones/records"
|
||||
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
|
||||
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/posture"
|
||||
nbroute "github.com/netbirdio/netbird/route"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
// toNetworkMapData builds the slim twin store from the account once per
|
||||
// account. The per-peer components calculation then runs on the twin.
|
||||
func (a *Account) toNetworkMapData(
|
||||
accountZones []*zones.Zone,
|
||||
validatedPeersMap map[string]struct{},
|
||||
resourcePolicies map[string][]*Policy,
|
||||
routers map[string]map[string]*routerTypes.NetworkRouter,
|
||||
groupIDToUserIDs map[string][]string,
|
||||
) *networkmap.NetworkMapData {
|
||||
nmd := &networkmap.NetworkMapData{
|
||||
Peers: make(map[string]*nmdata.Peer, len(a.Peers)),
|
||||
Groups: make(map[string]*nmdata.Group, len(a.Groups)),
|
||||
Policies: make([]*nmdata.Policy, 0, len(a.Policies)),
|
||||
Routes: make([]*nmdata.Route, 0, len(a.Routes)),
|
||||
NameServerGroups: make([]*nmdata.NameServerGroup, 0, len(a.NameServerGroups)),
|
||||
NetworkResources: make([]*nmdata.NetworkResource, 0, len(a.NetworkResources)),
|
||||
PostureChecks: make(map[string]*nmdata.PostureChecks, len(a.PostureChecks)),
|
||||
ResourcePolicies: make(map[string][]*nmdata.Policy, len(resourcePolicies)),
|
||||
Routers: make(map[string]map[string]*nmdata.NetworkRouter, len(routers)),
|
||||
ValidatedPeers: validatedPeersMap,
|
||||
GroupIDToUserIDs: groupIDToUserIDs,
|
||||
AllowedUserIDs: a.getAllowedUserIDs(),
|
||||
NetworkXIDToPublicID: make(map[string]string, len(a.Networks)),
|
||||
PostureCheckXIDToPublicID: make(map[string]string, len(a.PostureChecks)),
|
||||
}
|
||||
|
||||
if a.Network != nil {
|
||||
nmd.Network = TwinNetwork(a.Network)
|
||||
}
|
||||
nmd.DNSSettings = &nmdata.DNSSettings{DisabledManagementGroups: a.DNSSettings.DisabledManagementGroups}
|
||||
if a.Settings != nil {
|
||||
nmd.AccountSettings = &nmdata.AccountSettingsInfo{
|
||||
PeerLoginExpirationEnabled: a.Settings.PeerLoginExpirationEnabled,
|
||||
PeerLoginExpiration: a.Settings.PeerLoginExpiration,
|
||||
PeerInactivityExpirationEnabled: a.Settings.PeerInactivityExpirationEnabled,
|
||||
PeerInactivityExpiration: a.Settings.PeerInactivityExpiration,
|
||||
}
|
||||
}
|
||||
|
||||
for id, p := range a.Peers {
|
||||
nmd.Peers[id] = twinPeer(p)
|
||||
}
|
||||
for id, g := range a.Groups {
|
||||
nmd.Groups[id] = twinGroup(g)
|
||||
}
|
||||
|
||||
policyCache := make(map[string]*nmdata.Policy, len(a.Policies))
|
||||
twinPol := func(p *Policy) *nmdata.Policy {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
if tp, ok := policyCache[p.ID]; ok {
|
||||
return tp
|
||||
}
|
||||
tp := twinPolicy(p)
|
||||
policyCache[p.ID] = tp
|
||||
return tp
|
||||
}
|
||||
for _, p := range a.Policies {
|
||||
nmd.Policies = append(nmd.Policies, twinPol(p))
|
||||
}
|
||||
for resID, pols := range resourcePolicies {
|
||||
twinPols := make([]*nmdata.Policy, 0, len(pols))
|
||||
for _, p := range pols {
|
||||
twinPols = append(twinPols, twinPol(p))
|
||||
}
|
||||
nmd.ResourcePolicies[resID] = twinPols
|
||||
}
|
||||
|
||||
for _, r := range a.Routes {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
nmd.Routes = append(nmd.Routes, twinRoute(r))
|
||||
}
|
||||
for _, nsg := range a.NameServerGroups {
|
||||
nmd.NameServerGroups = append(nmd.NameServerGroups, twinNSG(nsg))
|
||||
}
|
||||
for _, res := range a.NetworkResources {
|
||||
nmd.NetworkResources = append(nmd.NetworkResources, twinNetworkResource(res))
|
||||
}
|
||||
for _, pc := range a.PostureChecks {
|
||||
if pc != nil {
|
||||
nmd.PostureChecks[pc.ID] = twinPostureChecks(pc)
|
||||
nmd.PostureCheckXIDToPublicID[pc.ID] = pc.PublicID
|
||||
}
|
||||
}
|
||||
for _, n := range a.Networks {
|
||||
if n != nil {
|
||||
nmd.NetworkXIDToPublicID[n.ID] = n.PublicID
|
||||
}
|
||||
}
|
||||
for networkID, inner := range routers {
|
||||
twinInner := make(map[string]*nmdata.NetworkRouter, len(inner))
|
||||
for peerID, router := range inner {
|
||||
twinInner[peerID] = twinRouter(router)
|
||||
}
|
||||
nmd.Routers[networkID] = twinInner
|
||||
}
|
||||
|
||||
nmd.AppliedZoneCandidates = buildAppliedZoneCandidates(accountZones)
|
||||
nmd.PrivateServiceCandidates = a.buildPrivateServiceCandidates()
|
||||
|
||||
return nmd
|
||||
}
|
||||
|
||||
func twinPeer(p *nbpeer.Peer) *nmdata.Peer {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
networkAddresses := make([]nmdata.NetworkAddress, 0, len(p.Meta.NetworkAddresses))
|
||||
for _, na := range p.Meta.NetworkAddresses {
|
||||
networkAddresses = append(networkAddresses, nmdata.NetworkAddress{NetIP: na.NetIP})
|
||||
}
|
||||
files := make([]nmdata.File, 0, len(p.Meta.Files))
|
||||
for _, f := range p.Meta.Files {
|
||||
files = append(files, nmdata.File{Path: f.Path, ProcessIsRunning: f.ProcessIsRunning})
|
||||
}
|
||||
return &nmdata.Peer{
|
||||
ID: p.ID,
|
||||
Key: p.Key,
|
||||
SSHKey: p.SSHKey,
|
||||
DNSLabel: p.DNSLabel,
|
||||
UserID: p.UserID,
|
||||
SSHEnabled: p.SSHEnabled,
|
||||
LoginExpirationEnabled: p.LoginExpirationEnabled,
|
||||
LastLogin: p.LastLogin,
|
||||
IP: p.IP,
|
||||
IPv6: p.IPv6,
|
||||
Meta: nmdata.PeerSystemMeta{
|
||||
WtVersion: p.Meta.WtVersion,
|
||||
GoOS: p.Meta.GoOS,
|
||||
OSVersion: p.Meta.OSVersion,
|
||||
KernelVersion: p.Meta.KernelVersion,
|
||||
NetworkAddresses: networkAddresses,
|
||||
Files: files,
|
||||
Capabilities: p.Meta.Capabilities,
|
||||
Flags: nmdata.Flags{
|
||||
ServerSSHAllowed: p.Meta.Flags.ServerSSHAllowed,
|
||||
DisableIPv6: p.Meta.Flags.DisableIPv6,
|
||||
},
|
||||
},
|
||||
Location: nmdata.PeerLocation{
|
||||
CountryCode: p.Location.CountryCode,
|
||||
CityName: p.Location.CityName,
|
||||
ConnectionIP: p.Location.ConnectionIP,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TwinPeer converts a real peer to its slim nmdata twin. Exported for the
|
||||
// port-forwarding integration, which builds proxy NetworkMaps holding twins.
|
||||
func TwinPeer(p *nbpeer.Peer) *nmdata.Peer {
|
||||
return twinPeer(p)
|
||||
}
|
||||
|
||||
func twinPeers(peers []*nbpeer.Peer) []*nmdata.Peer {
|
||||
out := make([]*nmdata.Peer, len(peers))
|
||||
for i, p := range peers {
|
||||
out[i] = twinPeer(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func twinGroup(g *Group) *nmdata.Group {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
return &nmdata.Group{
|
||||
Name: g.Name,
|
||||
PublicID: g.PublicID,
|
||||
Peers: g.Peers,
|
||||
}
|
||||
}
|
||||
|
||||
func twinPolicy(p *Policy) *nmdata.Policy {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
rules := make([]*nmdata.PolicyRule, 0, len(p.Rules))
|
||||
for _, r := range p.Rules {
|
||||
rules = append(rules, twinRule(r))
|
||||
}
|
||||
return &nmdata.Policy{
|
||||
ID: p.ID,
|
||||
PublicID: p.PublicID,
|
||||
Enabled: p.Enabled,
|
||||
SourcePostureChecks: p.SourcePostureChecks,
|
||||
Rules: rules,
|
||||
}
|
||||
}
|
||||
|
||||
func twinRule(r *PolicyRule) *nmdata.PolicyRule {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
var portRanges []nmdata.RulePortRange
|
||||
if r.PortRanges != nil {
|
||||
portRanges = make([]nmdata.RulePortRange, len(r.PortRanges))
|
||||
for i, pr := range r.PortRanges {
|
||||
portRanges[i] = nmdata.RulePortRange{Start: pr.Start, End: pr.End}
|
||||
}
|
||||
}
|
||||
return &nmdata.PolicyRule{
|
||||
ID: r.ID,
|
||||
PolicyID: r.PolicyID,
|
||||
Enabled: r.Enabled,
|
||||
Action: string(r.Action),
|
||||
Protocol: string(r.Protocol),
|
||||
Bidirectional: r.Bidirectional,
|
||||
Sources: r.Sources,
|
||||
Destinations: r.Destinations,
|
||||
SourceResource: nmdata.Resource{ID: r.SourceResource.ID, Type: string(r.SourceResource.Type)},
|
||||
DestinationResource: nmdata.Resource{ID: r.DestinationResource.ID, Type: string(r.DestinationResource.Type)},
|
||||
Ports: r.Ports,
|
||||
PortRanges: portRanges,
|
||||
AuthorizedGroups: r.AuthorizedGroups,
|
||||
AuthorizedUser: r.AuthorizedUser,
|
||||
}
|
||||
}
|
||||
|
||||
func twinRoute(r *nbroute.Route) *nmdata.Route {
|
||||
return &nmdata.Route{
|
||||
ID: string(r.ID),
|
||||
AccountID: r.AccountID,
|
||||
PublicID: r.PublicID,
|
||||
Network: r.Network,
|
||||
Domains: r.Domains,
|
||||
KeepRoute: r.KeepRoute,
|
||||
NetID: string(r.NetID),
|
||||
Description: r.Description,
|
||||
Peer: r.Peer,
|
||||
PeerID: r.PeerID,
|
||||
PeerGroups: r.PeerGroups,
|
||||
NetworkType: int(r.NetworkType),
|
||||
Masquerade: r.Masquerade,
|
||||
Metric: r.Metric,
|
||||
Enabled: r.Enabled,
|
||||
Groups: r.Groups,
|
||||
AccessControlGroups: r.AccessControlGroups,
|
||||
SkipAutoApply: r.SkipAutoApply,
|
||||
}
|
||||
}
|
||||
|
||||
// TwinRoute converts a real *route.Route to its slim nmdata twin. Exported for
|
||||
// tests that assert against twin routes returned in a NetworkMap.
|
||||
func TwinRoute(r *nbroute.Route) *nmdata.Route {
|
||||
return twinRoute(r)
|
||||
}
|
||||
|
||||
func twinNetworkResource(r *resourceTypes.NetworkResource) *nmdata.NetworkResource {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return &nmdata.NetworkResource{
|
||||
ID: r.ID,
|
||||
NetworkID: r.NetworkID,
|
||||
AccountID: r.AccountID,
|
||||
PublicID: r.PublicID,
|
||||
Name: r.Name,
|
||||
Description: r.Description,
|
||||
Type: string(r.Type),
|
||||
Address: r.Address,
|
||||
Domain: r.Domain,
|
||||
Prefix: r.Prefix,
|
||||
Enabled: r.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
func twinRouter(r *routerTypes.NetworkRouter) *nmdata.NetworkRouter {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return &nmdata.NetworkRouter{
|
||||
PublicID: r.PublicID,
|
||||
PeerGroups: r.PeerGroups,
|
||||
Masquerade: r.Masquerade,
|
||||
Metric: r.Metric,
|
||||
Enabled: r.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
func twinNSG(n *nbdns.NameServerGroup) *nmdata.NameServerGroup {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
nameServers := make([]nmdata.NameServer, 0, len(n.NameServers))
|
||||
for _, ns := range n.NameServers {
|
||||
nameServers = append(nameServers, nmdata.NameServer{
|
||||
IP: ns.IP,
|
||||
NSType: int(ns.NSType),
|
||||
Port: ns.Port,
|
||||
})
|
||||
}
|
||||
return &nmdata.NameServerGroup{
|
||||
ID: n.ID,
|
||||
PublicID: n.PublicID,
|
||||
Name: n.Name,
|
||||
Description: n.Description,
|
||||
NameServers: nameServers,
|
||||
Groups: n.Groups,
|
||||
Primary: n.Primary,
|
||||
Domains: n.Domains,
|
||||
Enabled: n.Enabled,
|
||||
SearchDomainsEnabled: n.SearchDomainsEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
// TwinNetwork converts a real *Network to its slim twin. Exported for the
|
||||
// graceful-degrade path that builds a minimal NetworkMapComponents directly.
|
||||
func TwinNetwork(n *Network) *nmdata.Network {
|
||||
nc := n.Copy()
|
||||
return &nmdata.Network{
|
||||
Identifier: nc.Identifier,
|
||||
Net: nc.Net,
|
||||
NetV6: nc.NetV6,
|
||||
Dns: nc.Dns,
|
||||
Serial: int64(nc.Serial),
|
||||
}
|
||||
}
|
||||
|
||||
func twinPostureChecks(pc *posture.Checks) *nmdata.PostureChecks {
|
||||
if pc == nil {
|
||||
return nil
|
||||
}
|
||||
out := &nmdata.PostureChecks{ID: pc.ID}
|
||||
def := pc.Checks
|
||||
if def.NBVersionCheck != nil {
|
||||
out.Checks.NBVersionCheck = &nmdata.NBVersionCheck{MinVersion: def.NBVersionCheck.MinVersion}
|
||||
}
|
||||
if def.OSVersionCheck != nil {
|
||||
oc := &nmdata.OSVersionCheck{}
|
||||
if def.OSVersionCheck.Android != nil {
|
||||
oc.Android = &nmdata.MinVersionCheck{MinVersion: def.OSVersionCheck.Android.MinVersion}
|
||||
}
|
||||
if def.OSVersionCheck.Darwin != nil {
|
||||
oc.Darwin = &nmdata.MinVersionCheck{MinVersion: def.OSVersionCheck.Darwin.MinVersion}
|
||||
}
|
||||
if def.OSVersionCheck.Ios != nil {
|
||||
oc.Ios = &nmdata.MinVersionCheck{MinVersion: def.OSVersionCheck.Ios.MinVersion}
|
||||
}
|
||||
if def.OSVersionCheck.Linux != nil {
|
||||
oc.Linux = &nmdata.MinKernelVersionCheck{MinKernelVersion: def.OSVersionCheck.Linux.MinKernelVersion}
|
||||
}
|
||||
if def.OSVersionCheck.Windows != nil {
|
||||
oc.Windows = &nmdata.MinKernelVersionCheck{MinKernelVersion: def.OSVersionCheck.Windows.MinKernelVersion}
|
||||
}
|
||||
out.Checks.OSVersionCheck = oc
|
||||
}
|
||||
if def.GeoLocationCheck != nil {
|
||||
gc := &nmdata.GeoLocationCheck{Action: def.GeoLocationCheck.Action}
|
||||
for _, loc := range def.GeoLocationCheck.Locations {
|
||||
gc.Locations = append(gc.Locations, nmdata.GeoLocation{CountryCode: loc.CountryCode, CityName: loc.CityName})
|
||||
}
|
||||
out.Checks.GeoLocationCheck = gc
|
||||
}
|
||||
if def.PeerNetworkRangeCheck != nil {
|
||||
out.Checks.PeerNetworkRangeCheck = &nmdata.PeerNetworkRangeCheck{
|
||||
Action: def.PeerNetworkRangeCheck.Action,
|
||||
Ranges: def.PeerNetworkRangeCheck.Ranges,
|
||||
}
|
||||
}
|
||||
if def.ProcessCheck != nil {
|
||||
procs := make([]nmdata.Process, 0, len(def.ProcessCheck.Processes))
|
||||
for _, p := range def.ProcessCheck.Processes {
|
||||
procs = append(procs, nmdata.Process{LinuxPath: p.LinuxPath, MacPath: p.MacPath, WindowsPath: p.WindowsPath})
|
||||
}
|
||||
out.Checks.ProcessCheck = &nmdata.ProcessCheck{Processes: procs}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildAppliedZoneCandidates precomputes the account-level custom DNS zones
|
||||
// (record conversion) once; the per-peer distribution-group gate runs in the
|
||||
// components calc. Mirrors the account-level half of filterPeerAppliedZones.
|
||||
func buildAppliedZoneCandidates(accountZones []*zones.Zone) []networkmap.AppliedZoneCandidate {
|
||||
var out []networkmap.AppliedZoneCandidate
|
||||
for _, zone := range accountZones {
|
||||
if !zone.Enabled || len(zone.Records) == 0 {
|
||||
continue
|
||||
}
|
||||
simpleRecords := make([]nmdata.SimpleRecord, 0, len(zone.Records))
|
||||
for _, record := range zone.Records {
|
||||
var recordType int
|
||||
rData := record.Content
|
||||
switch record.Type {
|
||||
case records.RecordTypeA:
|
||||
recordType = int(dns.TypeA)
|
||||
case records.RecordTypeAAAA:
|
||||
recordType = int(dns.TypeAAAA)
|
||||
case records.RecordTypeCNAME:
|
||||
recordType = int(dns.TypeCNAME)
|
||||
rData = dns.Fqdn(record.Content)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
simpleRecords = append(simpleRecords, nmdata.SimpleRecord{
|
||||
Name: dns.Fqdn(record.Name),
|
||||
Type: recordType,
|
||||
Class: nbdns.DefaultClass,
|
||||
TTL: record.TTL,
|
||||
RData: rData,
|
||||
})
|
||||
}
|
||||
out = append(out, networkmap.AppliedZoneCandidate{
|
||||
DistributionGroups: zone.DistributionGroups,
|
||||
Zone: nmdata.CustomZone{
|
||||
Domain: dns.Fqdn(zone.Domain),
|
||||
Records: simpleRecords,
|
||||
SearchDomainDisabled: !zone.EnableSearchDomain,
|
||||
NonAuthoritative: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildPrivateServiceCandidates precomputes the connected-proxy A records per
|
||||
// private service (account-level); the per-peer access-group gate + apex merge
|
||||
// run in the components calc. Mirrors the account-level half of
|
||||
// SynthesizePrivateServiceZones.
|
||||
func (a *Account) buildPrivateServiceCandidates() []networkmap.PrivateServiceCandidate {
|
||||
if len(a.Services) == 0 {
|
||||
return nil
|
||||
}
|
||||
proxyPeersByCluster := a.GetProxyPeers()
|
||||
if len(proxyPeersByCluster) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var out []networkmap.PrivateServiceCandidate
|
||||
for _, svc := range a.Services {
|
||||
if svc == nil || !svc.Enabled || !svc.Private {
|
||||
continue
|
||||
}
|
||||
if len(svc.AccessGroups) == 0 {
|
||||
continue
|
||||
}
|
||||
proxyPeers := proxyPeersByCluster[svc.ProxyCluster]
|
||||
if len(proxyPeers) == 0 {
|
||||
continue
|
||||
}
|
||||
apex := a.privateServiceDomainZone(svc)
|
||||
if apex == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var recs []nmdata.SimpleRecord
|
||||
for _, p := range proxyPeers {
|
||||
if p == nil || !p.IP.IsValid() {
|
||||
continue
|
||||
}
|
||||
if p.Status == nil || !p.Status.Connected {
|
||||
continue
|
||||
}
|
||||
recs = append(recs, nmdata.SimpleRecord{
|
||||
Name: dns.Fqdn(svc.Domain),
|
||||
Type: int(dns.TypeA),
|
||||
Class: nbdns.DefaultClass,
|
||||
TTL: privateServiceDNSRecordTTL,
|
||||
RData: p.IP.String(),
|
||||
})
|
||||
}
|
||||
if len(recs) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, networkmap.PrivateServiceCandidate{
|
||||
AccessGroups: svc.AccessGroups,
|
||||
Zone: nmdata.CustomZone{
|
||||
Domain: dns.Fqdn(apex),
|
||||
Records: recs,
|
||||
NonAuthoritative: true,
|
||||
SearchDomainDisabled: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toTwinCustomZone(z nbdns.CustomZone) nmdata.CustomZone {
|
||||
records := make([]nmdata.SimpleRecord, 0, len(z.Records))
|
||||
for _, r := range z.Records {
|
||||
records = append(records, nmdata.SimpleRecord{
|
||||
Name: r.Name,
|
||||
Type: r.Type,
|
||||
Class: r.Class,
|
||||
TTL: r.TTL,
|
||||
RData: r.RData,
|
||||
})
|
||||
}
|
||||
return nmdata.CustomZone{
|
||||
Domain: z.Domain,
|
||||
Records: records,
|
||||
SearchDomainDisabled: z.SearchDomainDisabled,
|
||||
NonAuthoritative: z.NonAuthoritative,
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) {
|
||||
@@ -48,7 +49,7 @@ func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func netmapPeerIDs(peers []*ComponentPeer) []string {
|
||||
func netmapPeerIDs(peers []*nmdata.Peer) []string {
|
||||
ids := make([]string, 0, len(peers))
|
||||
for _, p := range peers {
|
||||
if p == nil {
|
||||
|
||||
@@ -13,8 +13,6 @@ import (
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones/records"
|
||||
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
|
||||
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
|
||||
networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
|
||||
@@ -666,7 +664,7 @@ func Test_ExpandPortsAndRanges_SSHRuleExpansion(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ExpandPortsAndRanges(tt.base, tt.rule, tt.peer.ToComponent())
|
||||
result := ExpandPortsAndRanges(tt.base, tt.rule, tt.peer)
|
||||
|
||||
var ports []string
|
||||
for _, fr := range result {
|
||||
@@ -1040,518 +1038,6 @@ func Test_FilterZoneRecordsForPeers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_filterPeerAppliedZones(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
accountZones []*zones.Zone
|
||||
peerGroups LookupMap
|
||||
expected []nbdns.CustomZone
|
||||
}{
|
||||
{
|
||||
name: "empty peer groups returns empty custom zones",
|
||||
accountZones: []*zones.Zone{},
|
||||
peerGroups: LookupMap{},
|
||||
expected: []nbdns.CustomZone{},
|
||||
},
|
||||
{
|
||||
name: "peer has access to zone with A record",
|
||||
accountZones: []*zones.Zone{
|
||||
{
|
||||
ID: "zone1",
|
||||
Domain: "example.com",
|
||||
Enabled: true,
|
||||
EnableSearchDomain: false,
|
||||
DistributionGroups: []string{"group1"},
|
||||
Records: []*records.Record{
|
||||
{
|
||||
ID: "record1",
|
||||
Name: "www.example.com",
|
||||
Type: records.RecordTypeA,
|
||||
Content: "192.168.1.1",
|
||||
TTL: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
peerGroups: LookupMap{"group1": struct{}{}},
|
||||
expected: []nbdns.CustomZone{
|
||||
{
|
||||
Domain: "example.com.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{
|
||||
Name: "www.example.com.",
|
||||
Type: int(dns.TypeA),
|
||||
Class: nbdns.DefaultClass,
|
||||
TTL: 300,
|
||||
RData: "192.168.1.1",
|
||||
},
|
||||
},
|
||||
SearchDomainDisabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "peer has access to zone with search domain enabled",
|
||||
accountZones: []*zones.Zone{
|
||||
{
|
||||
ID: "zone1",
|
||||
Domain: "internal.local",
|
||||
Enabled: true,
|
||||
EnableSearchDomain: true,
|
||||
DistributionGroups: []string{"group1"},
|
||||
Records: []*records.Record{
|
||||
{
|
||||
ID: "record1",
|
||||
Name: "api.internal.local",
|
||||
Type: records.RecordTypeA,
|
||||
Content: "10.0.0.1",
|
||||
TTL: 600,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
peerGroups: LookupMap{"group1": struct{}{}},
|
||||
expected: []nbdns.CustomZone{
|
||||
{
|
||||
Domain: "internal.local.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{
|
||||
Name: "api.internal.local.",
|
||||
Type: int(dns.TypeA),
|
||||
Class: nbdns.DefaultClass,
|
||||
TTL: 600,
|
||||
RData: "10.0.0.1",
|
||||
},
|
||||
},
|
||||
SearchDomainDisabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "peer has no access to zone",
|
||||
accountZones: []*zones.Zone{
|
||||
{
|
||||
ID: "zone1",
|
||||
Domain: "private.com",
|
||||
Enabled: true,
|
||||
EnableSearchDomain: false,
|
||||
DistributionGroups: []string{"group2"},
|
||||
Records: []*records.Record{
|
||||
{
|
||||
ID: "record1",
|
||||
Name: "secret.private.com",
|
||||
Type: records.RecordTypeA,
|
||||
Content: "192.168.1.1",
|
||||
TTL: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
peerGroups: LookupMap{"group1": struct{}{}},
|
||||
expected: []nbdns.CustomZone{},
|
||||
},
|
||||
{
|
||||
name: "disabled zone is filtered out",
|
||||
accountZones: []*zones.Zone{
|
||||
{
|
||||
ID: "zone1",
|
||||
Domain: "disabled.com",
|
||||
Enabled: false,
|
||||
EnableSearchDomain: false,
|
||||
DistributionGroups: []string{"group1"},
|
||||
Records: []*records.Record{
|
||||
{
|
||||
ID: "record1",
|
||||
Name: "www.disabled.com",
|
||||
Type: records.RecordTypeA,
|
||||
Content: "192.168.1.1",
|
||||
TTL: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
peerGroups: LookupMap{"group1": struct{}{}},
|
||||
expected: []nbdns.CustomZone{},
|
||||
},
|
||||
{
|
||||
name: "zone with no records is filtered out",
|
||||
accountZones: []*zones.Zone{
|
||||
{
|
||||
ID: "zone1",
|
||||
Domain: "empty.com",
|
||||
Enabled: true,
|
||||
EnableSearchDomain: false,
|
||||
DistributionGroups: []string{"group1"},
|
||||
Records: []*records.Record{},
|
||||
},
|
||||
},
|
||||
peerGroups: LookupMap{"group1": struct{}{}},
|
||||
expected: []nbdns.CustomZone{},
|
||||
},
|
||||
{
|
||||
name: "peer has access via multiple groups",
|
||||
accountZones: []*zones.Zone{
|
||||
{
|
||||
ID: "zone1",
|
||||
Domain: "multi.com",
|
||||
Enabled: true,
|
||||
EnableSearchDomain: false,
|
||||
DistributionGroups: []string{"group1", "group2", "group3"},
|
||||
Records: []*records.Record{
|
||||
{
|
||||
ID: "record1",
|
||||
Name: "www.multi.com",
|
||||
Type: records.RecordTypeA,
|
||||
Content: "192.168.1.1",
|
||||
TTL: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
peerGroups: LookupMap{"group2": struct{}{}},
|
||||
expected: []nbdns.CustomZone{
|
||||
{
|
||||
Domain: "multi.com.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{
|
||||
Name: "www.multi.com.",
|
||||
Type: int(dns.TypeA),
|
||||
Class: nbdns.DefaultClass,
|
||||
TTL: 300,
|
||||
RData: "192.168.1.1",
|
||||
},
|
||||
},
|
||||
SearchDomainDisabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple zones with mixed access",
|
||||
accountZones: []*zones.Zone{
|
||||
{
|
||||
ID: "zone1",
|
||||
Domain: "allowed.com",
|
||||
Enabled: true,
|
||||
EnableSearchDomain: false,
|
||||
DistributionGroups: []string{"group1"},
|
||||
Records: []*records.Record{
|
||||
{
|
||||
ID: "record1",
|
||||
Name: "www.allowed.com",
|
||||
Type: records.RecordTypeA,
|
||||
Content: "192.168.1.1",
|
||||
TTL: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "zone2",
|
||||
Domain: "denied.com",
|
||||
Enabled: true,
|
||||
EnableSearchDomain: false,
|
||||
DistributionGroups: []string{"group2"},
|
||||
Records: []*records.Record{
|
||||
{
|
||||
ID: "record2",
|
||||
Name: "www.denied.com",
|
||||
Type: records.RecordTypeA,
|
||||
Content: "192.168.1.2",
|
||||
TTL: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
peerGroups: LookupMap{"group1": struct{}{}},
|
||||
expected: []nbdns.CustomZone{
|
||||
{
|
||||
Domain: "allowed.com.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{
|
||||
Name: "www.allowed.com.",
|
||||
Type: int(dns.TypeA),
|
||||
Class: nbdns.DefaultClass,
|
||||
TTL: 300,
|
||||
RData: "192.168.1.1",
|
||||
},
|
||||
},
|
||||
SearchDomainDisabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "zone with multiple record types",
|
||||
accountZones: []*zones.Zone{
|
||||
{
|
||||
ID: "zone1",
|
||||
Domain: "mixed.com",
|
||||
Enabled: true,
|
||||
EnableSearchDomain: false,
|
||||
DistributionGroups: []string{"group1"},
|
||||
Records: []*records.Record{
|
||||
{
|
||||
ID: "record1",
|
||||
Name: "www.mixed.com",
|
||||
Type: records.RecordTypeA,
|
||||
Content: "192.168.1.1",
|
||||
TTL: 300,
|
||||
},
|
||||
{
|
||||
ID: "record2",
|
||||
Name: "ipv6.mixed.com",
|
||||
Type: records.RecordTypeAAAA,
|
||||
Content: "2001:db8::1",
|
||||
TTL: 600,
|
||||
},
|
||||
{
|
||||
ID: "record3",
|
||||
Name: "alias.mixed.com",
|
||||
Type: records.RecordTypeCNAME,
|
||||
Content: "www.mixed.com",
|
||||
TTL: 900,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
peerGroups: LookupMap{"group1": struct{}{}},
|
||||
expected: []nbdns.CustomZone{
|
||||
{
|
||||
Domain: "mixed.com.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{
|
||||
Name: "www.mixed.com.",
|
||||
Type: int(dns.TypeA),
|
||||
Class: nbdns.DefaultClass,
|
||||
TTL: 300,
|
||||
RData: "192.168.1.1",
|
||||
},
|
||||
{
|
||||
Name: "ipv6.mixed.com.",
|
||||
Type: int(dns.TypeAAAA),
|
||||
Class: nbdns.DefaultClass,
|
||||
TTL: 600,
|
||||
RData: "2001:db8::1",
|
||||
},
|
||||
{
|
||||
Name: "alias.mixed.com.",
|
||||
Type: int(dns.TypeCNAME),
|
||||
Class: nbdns.DefaultClass,
|
||||
TTL: 900,
|
||||
RData: "www.mixed.com.",
|
||||
},
|
||||
},
|
||||
SearchDomainDisabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple zones both accessible",
|
||||
accountZones: []*zones.Zone{
|
||||
{
|
||||
ID: "zone1",
|
||||
Domain: "first.com",
|
||||
Enabled: true,
|
||||
EnableSearchDomain: true,
|
||||
DistributionGroups: []string{"group1"},
|
||||
Records: []*records.Record{
|
||||
{
|
||||
ID: "record1",
|
||||
Name: "www.first.com",
|
||||
Type: records.RecordTypeA,
|
||||
Content: "192.168.1.1",
|
||||
TTL: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "zone2",
|
||||
Domain: "second.com",
|
||||
Enabled: true,
|
||||
EnableSearchDomain: false,
|
||||
DistributionGroups: []string{"group1"},
|
||||
Records: []*records.Record{
|
||||
{
|
||||
ID: "record2",
|
||||
Name: "www.second.com",
|
||||
Type: records.RecordTypeA,
|
||||
Content: "192.168.1.2",
|
||||
TTL: 600,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
peerGroups: LookupMap{"group1": struct{}{}},
|
||||
expected: []nbdns.CustomZone{
|
||||
{
|
||||
Domain: "first.com.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{
|
||||
Name: "www.first.com.",
|
||||
Type: int(dns.TypeA),
|
||||
Class: nbdns.DefaultClass,
|
||||
TTL: 300,
|
||||
RData: "192.168.1.1",
|
||||
},
|
||||
},
|
||||
SearchDomainDisabled: false,
|
||||
},
|
||||
{
|
||||
Domain: "second.com.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{
|
||||
Name: "www.second.com.",
|
||||
Type: int(dns.TypeA),
|
||||
Class: nbdns.DefaultClass,
|
||||
TTL: 600,
|
||||
RData: "192.168.1.2",
|
||||
},
|
||||
},
|
||||
SearchDomainDisabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "zone with multiple records of same type",
|
||||
accountZones: []*zones.Zone{
|
||||
{
|
||||
ID: "zone1",
|
||||
Domain: "multi-a.com",
|
||||
Enabled: true,
|
||||
EnableSearchDomain: false,
|
||||
DistributionGroups: []string{"group1"},
|
||||
Records: []*records.Record{
|
||||
{
|
||||
ID: "record1",
|
||||
Name: "www.multi-a.com",
|
||||
Type: records.RecordTypeA,
|
||||
Content: "192.168.1.1",
|
||||
TTL: 300,
|
||||
},
|
||||
{
|
||||
ID: "record2",
|
||||
Name: "www.multi-a.com",
|
||||
Type: records.RecordTypeA,
|
||||
Content: "192.168.1.2",
|
||||
TTL: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
peerGroups: LookupMap{"group1": struct{}{}},
|
||||
expected: []nbdns.CustomZone{
|
||||
{
|
||||
Domain: "multi-a.com.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{
|
||||
Name: "www.multi-a.com.",
|
||||
Type: int(dns.TypeA),
|
||||
Class: nbdns.DefaultClass,
|
||||
TTL: 300,
|
||||
RData: "192.168.1.1",
|
||||
},
|
||||
{
|
||||
Name: "www.multi-a.com.",
|
||||
Type: int(dns.TypeA),
|
||||
Class: nbdns.DefaultClass,
|
||||
TTL: 300,
|
||||
RData: "192.168.1.2",
|
||||
},
|
||||
},
|
||||
SearchDomainDisabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "peer in multiple groups accessing different zones",
|
||||
accountZones: []*zones.Zone{
|
||||
{
|
||||
ID: "zone1",
|
||||
Domain: "zone1.com",
|
||||
Enabled: true,
|
||||
EnableSearchDomain: false,
|
||||
DistributionGroups: []string{"group1"},
|
||||
Records: []*records.Record{
|
||||
{
|
||||
ID: "record1",
|
||||
Name: "www.zone1.com",
|
||||
Type: records.RecordTypeA,
|
||||
Content: "192.168.1.1",
|
||||
TTL: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "zone2",
|
||||
Domain: "zone2.com",
|
||||
Enabled: true,
|
||||
EnableSearchDomain: false,
|
||||
DistributionGroups: []string{"group2"},
|
||||
Records: []*records.Record{
|
||||
{
|
||||
ID: "record2",
|
||||
Name: "www.zone2.com",
|
||||
Type: records.RecordTypeA,
|
||||
Content: "192.168.1.2",
|
||||
TTL: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
peerGroups: LookupMap{"group1": struct{}{}, "group2": struct{}{}},
|
||||
expected: []nbdns.CustomZone{
|
||||
{
|
||||
Domain: "zone1.com.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{
|
||||
Name: "www.zone1.com.",
|
||||
Type: int(dns.TypeA),
|
||||
Class: nbdns.DefaultClass,
|
||||
TTL: 300,
|
||||
RData: "192.168.1.1",
|
||||
},
|
||||
},
|
||||
SearchDomainDisabled: true,
|
||||
},
|
||||
{
|
||||
Domain: "zone2.com.",
|
||||
Records: []nbdns.SimpleRecord{
|
||||
{
|
||||
Name: "www.zone2.com.",
|
||||
Type: int(dns.TypeA),
|
||||
Class: nbdns.DefaultClass,
|
||||
TTL: 300,
|
||||
RData: "192.168.1.2",
|
||||
},
|
||||
},
|
||||
SearchDomainDisabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := filterPeerAppliedZones(ctx, tt.accountZones, tt.peerGroups)
|
||||
require.Equal(t, len(tt.expected), len(result), "number of custom zones should match")
|
||||
|
||||
for i, expectedZone := range tt.expected {
|
||||
assert.Equal(t, expectedZone.Domain, result[i].Domain, "domain should match")
|
||||
assert.Equal(t, expectedZone.SearchDomainDisabled, result[i].SearchDomainDisabled, "search domain disabled flag should match")
|
||||
assert.Equal(t, len(expectedZone.Records), len(result[i].Records), "number of records should match")
|
||||
|
||||
for j, expectedRecord := range expectedZone.Records {
|
||||
assert.Equal(t, expectedRecord.Name, result[i].Records[j].Name, "record name should match")
|
||||
assert.Equal(t, expectedRecord.Type, result[i].Records[j].Type, "record type should match")
|
||||
assert.Equal(t, expectedRecord.Class, result[i].Records[j].Class, "record class should match")
|
||||
assert.Equal(t, expectedRecord.TTL, result[i].Records[j].TTL, "record TTL should match")
|
||||
assert.Equal(t, expectedRecord.RData, result[i].Records[j].RData, "record RData should match")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectPrivateServicePolicies_ProxyPeerGetsInboundRule(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
nbroute "github.com/netbirdio/netbird/route"
|
||||
sharedtypes "github.com/netbirdio/netbird/shared/management/types"
|
||||
)
|
||||
@@ -17,6 +18,9 @@ type DNSSettings = sharedtypes.DNSSettings
|
||||
|
||||
type FirewallRule = sharedtypes.FirewallRule
|
||||
|
||||
type Group = sharedtypes.Group
|
||||
type GroupPeer = sharedtypes.GroupPeer
|
||||
|
||||
type Network = sharedtypes.Network
|
||||
type NetworkMap = sharedtypes.NetworkMap
|
||||
type ForwardingRule = sharedtypes.ForwardingRule
|
||||
@@ -38,18 +42,6 @@ type RouteFirewallRule = sharedtypes.RouteFirewallRule
|
||||
|
||||
type NetworkMapComponents = sharedtypes.NetworkMapComponents
|
||||
|
||||
type ComponentPeer = sharedtypes.ComponentPeer
|
||||
type ComponentGroup = sharedtypes.ComponentGroup
|
||||
type ComponentRouter = sharedtypes.ComponentRouter
|
||||
type ComponentResource = sharedtypes.ComponentResource
|
||||
type ComponentResourceType = sharedtypes.ComponentResourceType
|
||||
|
||||
const (
|
||||
ComponentResourceHost = sharedtypes.ComponentResourceHost
|
||||
ComponentResourceSubnet = sharedtypes.ComponentResourceSubnet
|
||||
ComponentResourceDomain = sharedtypes.ComponentResourceDomain
|
||||
)
|
||||
|
||||
var EmptyNetworkMapComponents = sharedtypes.EmptyNetworkMapComponents
|
||||
|
||||
type AccountSettingsInfo = sharedtypes.AccountSettingsInfo
|
||||
@@ -60,7 +52,12 @@ type NetworkMapComponentsCompact = sharedtypes.NetworkMapComponentsCompact
|
||||
type LookupMap = sharedtypes.LookupMap
|
||||
type FirewallRuleContext = sharedtypes.FirewallRuleContext
|
||||
|
||||
const GroupAllName = sharedtypes.GroupAllName
|
||||
const (
|
||||
GroupIssuedAPI = sharedtypes.GroupIssuedAPI
|
||||
GroupIssuedJWT = sharedtypes.GroupIssuedJWT
|
||||
GroupIssuedIntegration = sharedtypes.GroupIssuedIntegration
|
||||
GroupAllName = sharedtypes.GroupAllName
|
||||
)
|
||||
|
||||
// Function forwarders preserve types.X(...) call sites that previously
|
||||
// resolved to package-local funcs. Plain forwarders (not var aliases) keep
|
||||
@@ -70,20 +67,24 @@ func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool {
|
||||
return sharedtypes.PolicyRuleImpliesLegacySSH(rule)
|
||||
}
|
||||
|
||||
func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPeer) []*FirewallRule {
|
||||
return sharedtypes.ExpandPortsAndRanges(base, rule, peer)
|
||||
// ExpandPortsAndRanges / AppendIPv6FirewallRule / GenerateRouteFirewallRules
|
||||
// forward to the shared twin-typed helpers, converting the real types the
|
||||
// legacy Account calc still uses to nmdata twins at this boundary.
|
||||
|
||||
func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer) []*FirewallRule {
|
||||
return sharedtypes.ExpandPortsAndRanges(base, twinRule(rule), twinPeer(peer))
|
||||
}
|
||||
|
||||
func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *ComponentPeer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule {
|
||||
return sharedtypes.AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, rc)
|
||||
func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *nbpeer.Peer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule {
|
||||
return sharedtypes.AppendIPv6FirewallRule(rules, rulesExists, twinPeer(peer), twinPeer(targetPeer), twinRule(rule), rc)
|
||||
}
|
||||
|
||||
func CalculateNetworkMapFromComponents(ctx context.Context, components *NetworkMapComponents) *NetworkMap {
|
||||
return sharedtypes.CalculateNetworkMapFromComponents(ctx, components)
|
||||
}
|
||||
|
||||
func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*ComponentPeer, direction int, includeIPv6 bool) []*RouteFirewallRule {
|
||||
return sharedtypes.GenerateRouteFirewallRules(ctx, route, rule, groupPeers, direction, includeIPv6)
|
||||
func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int, includeIPv6 bool) []*RouteFirewallRule {
|
||||
return sharedtypes.GenerateRouteFirewallRules(ctx, twinRoute(route), twinRule(rule), twinPeers(groupPeers), direction, includeIPv6)
|
||||
}
|
||||
|
||||
func AllocateIPv6Subnet(r *rand.Rand) net.IPNet {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
func TestNetworkMapComponents_IPv6EndToEnd(t *testing.T) {
|
||||
@@ -105,7 +105,7 @@ func TestNetworkMapComponents_RemotePeerWithoutCapability(t *testing.T) {
|
||||
require.NotNil(t, nm)
|
||||
|
||||
t.Run("AllowedIPs include remote v6", func(t *testing.T) {
|
||||
var dst *types.ComponentPeer
|
||||
var dst *nmdata.Peer
|
||||
for _, p := range nm.Peers {
|
||||
if p.ID == "peer-dst-1" {
|
||||
dst = p
|
||||
|
||||
703
management/server/types/legacynmap/account_components.go
Normal file
703
management/server/types/legacynmap/account_components.go
Normal file
@@ -0,0 +1,703 @@
|
||||
//go:build nmapequiv
|
||||
|
||||
package legacynmap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones"
|
||||
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
|
||||
"github.com/netbirdio/netbird/management/server/telemetry"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
)
|
||||
|
||||
// GetPeerNetworkMapResult dispatches to either the legacy-NetworkMap path or
|
||||
// the components path based on the peer's capability and the kill switch.
|
||||
// Capable peers (PeerCapabilityComponentNetworkMap) get the raw components
|
||||
// shape — the server skips Calculate() entirely for them, saving CPU
|
||||
// proportional to the number of capable peers in the account. Legacy peers
|
||||
// (or any peer when componentsDisabled is true) get the fully-expanded
|
||||
// NetworkMap as before.
|
||||
|
||||
func GetPeerNetworkMapFromComponents(a *Account,
|
||||
ctx context.Context,
|
||||
peerID string,
|
||||
peersCustomZone nbdns.CustomZone,
|
||||
accountZones []*zones.Zone,
|
||||
validatedPeersMap map[string]struct{},
|
||||
resourcePolicies map[string][]*Policy,
|
||||
routers map[string]map[string]*routerTypes.NetworkRouter,
|
||||
metrics *telemetry.AccountManagerMetrics,
|
||||
groupIDToUserIDs map[string][]string,
|
||||
) *NetworkMap {
|
||||
start := time.Now()
|
||||
|
||||
components := GetPeerNetworkMapComponents(a,
|
||||
ctx,
|
||||
peerID,
|
||||
peersCustomZone,
|
||||
accountZones,
|
||||
validatedPeersMap,
|
||||
resourcePolicies,
|
||||
routers,
|
||||
groupIDToUserIDs,
|
||||
)
|
||||
|
||||
if components.IsEmpty() {
|
||||
return &NetworkMap{Network: components.Network}
|
||||
}
|
||||
|
||||
nm := CalculateNetworkMapFromComponents(ctx, components)
|
||||
|
||||
if metrics != nil {
|
||||
objectCount := int64(len(nm.Peers) + len(nm.OfflinePeers) + len(nm.Routes) + len(nm.FirewallRules) + len(nm.RoutesFirewallRules))
|
||||
metrics.CountNetworkMapObjects(objectCount)
|
||||
metrics.CountGetPeerNetworkMapDuration(time.Since(start))
|
||||
|
||||
if objectCount > 5000 {
|
||||
log.WithContext(ctx).Tracef("account: %s has a total resource count of %d objects from components, "+
|
||||
"peers: %d, offline peers: %d, routes: %d, firewall rules: %d, route firewall rules: %d",
|
||||
a.Id, objectCount, len(nm.Peers), len(nm.OfflinePeers), len(nm.Routes), len(nm.FirewallRules), len(nm.RoutesFirewallRules))
|
||||
}
|
||||
}
|
||||
|
||||
return nm
|
||||
}
|
||||
|
||||
func GetPeerNetworkMapComponents(a *Account,
|
||||
ctx context.Context,
|
||||
peerID string,
|
||||
peersCustomZone nbdns.CustomZone,
|
||||
accountZones []*zones.Zone,
|
||||
validatedPeersMap map[string]struct{},
|
||||
resourcePolicies map[string][]*Policy,
|
||||
routers map[string]map[string]*routerTypes.NetworkRouter,
|
||||
groupIDToUserIDs map[string][]string,
|
||||
) *NetworkMapComponents {
|
||||
peer := a.Peers[peerID]
|
||||
// this can never happen, things are very wrong if it did
|
||||
// TODO (dmitri) maybe consider using invariants?
|
||||
if peer == nil {
|
||||
log.WithField("peer id", peerID).Error("NetworkMapComponents are computed for a peer missing from the account")
|
||||
return EmptyNetworkMapComponents(&NetworkMapComponents{
|
||||
PeerID: peerID,
|
||||
Network: a.Network.Copy(),
|
||||
// must include the target peer as it's required on the client
|
||||
Peers: map[string]*ComponentPeer{peerID: peerToComponent(peer)},
|
||||
})
|
||||
}
|
||||
|
||||
if _, ok := validatedPeersMap[peerID]; !ok {
|
||||
// Mirror legacy graceful-degrade: GetPeerNetworkMapFromComponents
|
||||
// returns &NetworkMap{Network: a.Network.Copy()} when components is
|
||||
// nil. Match that floor so the receiving client always sees the
|
||||
// account Network identifier, not a fully-empty envelope.
|
||||
return EmptyNetworkMapComponents(&NetworkMapComponents{
|
||||
PeerID: peerID,
|
||||
Network: a.Network.Copy(),
|
||||
// must include the target peer as it's required on the client
|
||||
Peers: map[string]*ComponentPeer{peerID: peerToComponent(peer)},
|
||||
})
|
||||
}
|
||||
|
||||
components := &NetworkMapComponents{
|
||||
PeerID: peerID,
|
||||
Network: a.Network.Copy(),
|
||||
NameServerGroups: make([]*nbdns.NameServerGroup, 0),
|
||||
CustomZoneDomain: peersCustomZone.Domain,
|
||||
ResourcePoliciesMap: make(map[string][]*Policy),
|
||||
RoutersMap: make(map[string]map[string]*ComponentRouter),
|
||||
NetworkResources: make([]*ComponentResource, 0),
|
||||
PostureFailedPeers: make(map[string]map[string]struct{}, len(a.PostureChecks)),
|
||||
RouterPeers: make(map[string]*ComponentPeer),
|
||||
NetworkXIDToPublicID: make(map[string]string, len(a.Networks)),
|
||||
PostureCheckXIDToPublicID: make(map[string]string, len(a.PostureChecks)),
|
||||
|
||||
ForceRoutingPeerDNSResolution: forcesRoutingPeerDNSResolution(a, peerID, routers),
|
||||
}
|
||||
for _, n := range a.Networks {
|
||||
if n != nil {
|
||||
components.NetworkXIDToPublicID[n.ID] = n.PublicID
|
||||
}
|
||||
}
|
||||
for _, pc := range a.PostureChecks {
|
||||
if pc != nil {
|
||||
components.PostureCheckXIDToPublicID[pc.ID] = pc.PublicID
|
||||
}
|
||||
}
|
||||
|
||||
components.AccountSettings = &AccountSettingsInfo{
|
||||
PeerLoginExpirationEnabled: a.Settings.PeerLoginExpirationEnabled,
|
||||
PeerLoginExpiration: a.Settings.PeerLoginExpiration,
|
||||
PeerInactivityExpirationEnabled: a.Settings.PeerInactivityExpirationEnabled,
|
||||
PeerInactivityExpiration: a.Settings.PeerInactivityExpiration,
|
||||
}
|
||||
|
||||
components.DNSSettings = &a.DNSSettings
|
||||
|
||||
// relevantPeers always contains the target peer (peerID)
|
||||
relevantPeers, relevantGroups, relevantPolicies, relevantRoutes, sshReqs := getPeersGroupsPoliciesRoutes(a, ctx, peerID, peer.SSHEnabled, validatedPeersMap, &components.PostureFailedPeers)
|
||||
|
||||
if len(sshReqs.neededGroupIDs) > 0 {
|
||||
components.GroupIDToUserIDs = filterGroupIDToUserIDs(groupIDToUserIDs, sshReqs.neededGroupIDs)
|
||||
}
|
||||
if sshReqs.needAllowedUserIDs {
|
||||
components.AllowedUserIDs = getAllowedUserIDs(a)
|
||||
}
|
||||
|
||||
components.Peers = relevantPeers
|
||||
components.Groups = groupsToComponent(relevantGroups)
|
||||
components.Policies = relevantPolicies
|
||||
components.Routes = relevantRoutes
|
||||
components.AllDNSRecords = filterDNSRecordsByPeers(peersCustomZone.Records, relevantPeers, peer.SupportsIPv6() && peer.IPv6.IsValid())
|
||||
|
||||
peerGroups := a.GetPeerGroups(peerID)
|
||||
components.AccountZones = filterPeerAppliedZones(ctx, accountZones, LookupMap(peerGroups))
|
||||
components.AccountZones = append(components.AccountZones, a.SynthesizePrivateServiceZones(peerID)...)
|
||||
|
||||
for _, nsGroup := range a.NameServerGroups {
|
||||
if nsGroup.Enabled {
|
||||
for _, gID := range nsGroup.Groups {
|
||||
if _, found := relevantGroups[gID]; found {
|
||||
components.NameServerGroups = append(components.NameServerGroups, nsGroup)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, resource := range a.NetworkResources {
|
||||
if !resource.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
policies, exists := resourcePolicies[resource.ID]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
addSourcePeers := false
|
||||
|
||||
networkRoutingPeers, routerExists := routers[resource.NetworkID]
|
||||
if routerExists {
|
||||
if _, ok := networkRoutingPeers[peerID]; ok {
|
||||
addSourcePeers = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, policy := range policies {
|
||||
if addSourcePeers {
|
||||
var peers []string
|
||||
if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" {
|
||||
peers = []string{policy.Rules[0].SourceResource.ID}
|
||||
} else {
|
||||
peers = getUniquePeerIDsFromGroupsIDs(a, ctx, policy.SourceGroups())
|
||||
}
|
||||
for _, pID := range getPostureValidPeersSaveFailed(a, peers, policy.SourcePostureChecks, validatedPeersMap, &components.PostureFailedPeers) {
|
||||
if _, exists := components.Peers[pID]; !exists {
|
||||
components.Peers[pID] = peerToComponent(a.GetPeer(pID))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
peerInSources := false
|
||||
if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" {
|
||||
peerInSources = policy.Rules[0].SourceResource.ID == peerID
|
||||
} else {
|
||||
for _, groupID := range policy.SourceGroups() {
|
||||
if group := a.GetGroup(groupID); group != nil && slices.Contains(group.Peers, peerID) {
|
||||
peerInSources = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !peerInSources {
|
||||
continue
|
||||
}
|
||||
isValid, pname := validatePostureChecksOnPeerGetFailed(a, ctx, policy.SourcePostureChecks, peerID)
|
||||
if !isValid && len(pname) > 0 {
|
||||
if _, ok := components.PostureFailedPeers[pname]; !ok {
|
||||
components.PostureFailedPeers[pname] = make(map[string]struct{})
|
||||
}
|
||||
components.PostureFailedPeers[pname][peer.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
addSourcePeers = true
|
||||
}
|
||||
|
||||
for _, rule := range policy.Rules {
|
||||
for _, srcGroupID := range rule.Sources {
|
||||
if g := a.Groups[srcGroupID]; g != nil {
|
||||
if _, exists := components.Groups[srcGroupID]; !exists {
|
||||
components.Groups[srcGroupID] = groupToComponent(g)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, dstGroupID := range rule.Destinations {
|
||||
if g := a.Groups[dstGroupID]; g != nil {
|
||||
if _, exists := components.Groups[dstGroupID]; !exists {
|
||||
components.Groups[dstGroupID] = groupToComponent(g)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
components.ResourcePoliciesMap[resource.ID] = policies
|
||||
}
|
||||
|
||||
// Only expose router peers and the per-network routers_map when this
|
||||
// target peer actually has access to the resource (either as a router
|
||||
// itself or via a policy that includes it as a source). Without this
|
||||
// gate, every peer's envelope was leaking router peers of every
|
||||
// network in the account — accounts with many tenants/networks
|
||||
// shipped tens of unrelated peers in `peers[]` and `routers_map`.
|
||||
if addSourcePeers {
|
||||
components.RoutersMap[resource.NetworkID] = routersToComponentMap(networkRoutingPeers)
|
||||
for peerIDKey := range networkRoutingPeers {
|
||||
if p := a.Peers[peerIDKey]; p != nil {
|
||||
cp := components.RouterPeers[peerIDKey]
|
||||
if cp == nil {
|
||||
cp = peerToComponent(p)
|
||||
components.RouterPeers[peerIDKey] = cp
|
||||
}
|
||||
if _, exists := components.Peers[peerIDKey]; !exists {
|
||||
if _, validated := validatedPeersMap[peerIDKey]; validated {
|
||||
components.Peers[peerIDKey] = cp
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
components.NetworkResources = append(components.NetworkResources, resourceToComponent(resource))
|
||||
}
|
||||
}
|
||||
|
||||
filterGroupPeers(&components.Groups, components.Peers)
|
||||
filterPostureFailedPeers(&components.PostureFailedPeers, components.Policies, components.ResourcePoliciesMap, components.Peers)
|
||||
|
||||
return components
|
||||
}
|
||||
|
||||
type sshRequirements struct {
|
||||
neededGroupIDs map[string]struct{}
|
||||
needAllowedUserIDs bool
|
||||
}
|
||||
|
||||
func getPeersGroupsPoliciesRoutes(a *Account,
|
||||
ctx context.Context,
|
||||
peerID string,
|
||||
peerSSHEnabled bool,
|
||||
validatedPeersMap map[string]struct{},
|
||||
postureFailedPeers *map[string]map[string]struct{},
|
||||
) (map[string]*ComponentPeer, map[string]*Group, []*Policy, []*route.Route, sshRequirements) {
|
||||
relevantPeerIDs := make(map[string]*ComponentPeer, len(a.Peers)/4)
|
||||
relevantGroupIDs := make(map[string]*Group, len(a.Groups)/4)
|
||||
relevantPolicies := make([]*Policy, 0, len(a.Policies))
|
||||
relevantRoutes := make([]*route.Route, 0, len(a.Routes))
|
||||
sshReqs := sshRequirements{neededGroupIDs: make(map[string]struct{})}
|
||||
|
||||
relevantPeerIDs[peerID] = peerToComponent(a.GetPeer(peerID))
|
||||
|
||||
peerGroupSet := make(map[string]struct{}, 8)
|
||||
for groupID, group := range a.Groups {
|
||||
if slices.Contains(group.Peers, peerID) {
|
||||
relevantGroupIDs[groupID] = a.GetGroup(groupID)
|
||||
peerGroupSet[groupID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
routeAccessControlGroups := make(map[string]struct{})
|
||||
for _, r := range a.Routes {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
relevant := r.Peer == peerID
|
||||
if !relevant {
|
||||
for _, groupID := range r.PeerGroups {
|
||||
if _, ok := peerGroupSet[groupID]; ok {
|
||||
relevant = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !relevant && r.Enabled {
|
||||
for _, groupID := range r.Groups {
|
||||
if _, ok := peerGroupSet[groupID]; ok {
|
||||
relevant = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !relevant {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, groupID := range r.PeerGroups {
|
||||
relevantGroupIDs[groupID] = a.GetGroup(groupID)
|
||||
}
|
||||
for _, groupID := range r.Groups {
|
||||
relevantGroupIDs[groupID] = a.GetGroup(groupID)
|
||||
}
|
||||
if r.Enabled {
|
||||
for _, groupID := range r.AccessControlGroups {
|
||||
relevantGroupIDs[groupID] = a.GetGroup(groupID)
|
||||
routeAccessControlGroups[groupID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Include route advertisers in relevantPeerIDs. The envelope
|
||||
// encoder writes route.peer_index by looking up r.Peer in the
|
||||
// shipped peers list; if the advertiser is policy-isolated from
|
||||
// the target peer (no rule edge between them), it would otherwise
|
||||
// be omitted and the decoder would fail to resolve r.Peer, leaving
|
||||
// the client without a WG tunnel target for this route. Legacy
|
||||
// NetworkMap.Routes shipped the WG public key inline, so the
|
||||
// equivalence path doesn't surface this — but the dependency is
|
||||
// real once a client actually tries to use the route.
|
||||
// Gate by validatedPeersMap so non-validated advertisers stay out
|
||||
// (matches the network-resource router behaviour at the bottom of
|
||||
// this loop, and the legacy invariant that only validated peers
|
||||
// reach a client's view).
|
||||
if r.Peer != "" {
|
||||
if _, ok := validatedPeersMap[r.Peer]; ok {
|
||||
if p := a.GetPeer(r.Peer); p != nil {
|
||||
relevantPeerIDs[r.Peer] = peerToComponent(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, groupID := range r.PeerGroups {
|
||||
g := a.GetGroup(groupID)
|
||||
if g == nil {
|
||||
continue
|
||||
}
|
||||
for _, pid := range g.Peers {
|
||||
if _, exists := relevantPeerIDs[pid]; exists {
|
||||
continue
|
||||
}
|
||||
if _, ok := validatedPeersMap[pid]; !ok {
|
||||
continue
|
||||
}
|
||||
if p := a.GetPeer(pid); p != nil {
|
||||
relevantPeerIDs[pid] = peerToComponent(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
relevantRoutes = append(relevantRoutes, r)
|
||||
}
|
||||
|
||||
for _, policy := range a.Policies {
|
||||
if !policy.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
policyRelevant := false
|
||||
for _, rule := range policy.Rules {
|
||||
if !rule.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(routeAccessControlGroups) > 0 {
|
||||
for _, destGroupID := range rule.Destinations {
|
||||
if _, needed := routeAccessControlGroups[destGroupID]; needed {
|
||||
policyRelevant = true
|
||||
for _, srcGroupID := range rule.Sources {
|
||||
relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID)
|
||||
}
|
||||
for _, dstGroupID := range rule.Destinations {
|
||||
relevantGroupIDs[dstGroupID] = a.GetGroup(dstGroupID)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sourcePeers, destinationPeers []string
|
||||
var peerInSources, peerInDestinations bool
|
||||
|
||||
if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" {
|
||||
sourcePeers = []string{rule.SourceResource.ID}
|
||||
if rule.SourceResource.ID == peerID {
|
||||
peerInSources = true
|
||||
}
|
||||
} else {
|
||||
sourcePeers, peerInSources = getPeersFromGroups(a, ctx, rule.Sources, peerID, policy.SourcePostureChecks, validatedPeersMap, postureFailedPeers)
|
||||
}
|
||||
|
||||
if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" {
|
||||
destinationPeers = []string{rule.DestinationResource.ID}
|
||||
if rule.DestinationResource.ID == peerID {
|
||||
peerInDestinations = true
|
||||
}
|
||||
} else {
|
||||
destinationPeers, peerInDestinations = getPeersFromGroups(a, ctx, rule.Destinations, peerID, nil, validatedPeersMap, postureFailedPeers)
|
||||
}
|
||||
|
||||
if peerInSources {
|
||||
policyRelevant = true
|
||||
for _, pid := range destinationPeers {
|
||||
if _, exists := relevantPeerIDs[pid]; !exists {
|
||||
relevantPeerIDs[pid] = peerToComponent(a.GetPeer(pid))
|
||||
}
|
||||
}
|
||||
for _, dstGroupID := range rule.Destinations {
|
||||
relevantGroupIDs[dstGroupID] = a.GetGroup(dstGroupID)
|
||||
}
|
||||
}
|
||||
|
||||
if peerInDestinations {
|
||||
policyRelevant = true
|
||||
for _, pid := range sourcePeers {
|
||||
if _, exists := relevantPeerIDs[pid]; !exists {
|
||||
relevantPeerIDs[pid] = peerToComponent(a.GetPeer(pid))
|
||||
}
|
||||
}
|
||||
for _, srcGroupID := range rule.Sources {
|
||||
relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID)
|
||||
}
|
||||
|
||||
if rule.Protocol == PolicyRuleProtocolNetbirdSSH {
|
||||
switch {
|
||||
case len(rule.AuthorizedGroups) > 0:
|
||||
for groupID := range rule.AuthorizedGroups {
|
||||
sshReqs.neededGroupIDs[groupID] = struct{}{}
|
||||
}
|
||||
case rule.AuthorizedUser != "":
|
||||
default:
|
||||
sshReqs.needAllowedUserIDs = true
|
||||
}
|
||||
} else if PolicyRuleImpliesLegacySSH(rule) && peerSSHEnabled {
|
||||
sshReqs.needAllowedUserIDs = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if policyRelevant {
|
||||
relevantPolicies = append(relevantPolicies, policy)
|
||||
}
|
||||
}
|
||||
|
||||
return relevantPeerIDs, relevantGroupIDs, relevantPolicies, relevantRoutes, sshReqs
|
||||
}
|
||||
|
||||
func getPeersFromGroups(a *Account, ctx context.Context, groups []string, peerID string, sourcePostureChecksIDs []string,
|
||||
validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}) ([]string, bool) {
|
||||
peerInGroups := false
|
||||
filteredPeerIDs := make([]string, 0, len(groups))
|
||||
seenPeerIds := make(map[string]struct{}, len(groups))
|
||||
|
||||
for _, gid := range groups {
|
||||
group := a.GetGroup(gid)
|
||||
if group == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if group.IsGroupAll() || len(groups) == 1 {
|
||||
filteredPeerIDs = make([]string, 0, len(group.Peers))
|
||||
peerInGroups = false
|
||||
for _, pid := range group.Peers {
|
||||
peer, ok := a.Peers[pid]
|
||||
if !ok || peer == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := validatedPeersMap[peer.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
isValid, pname := validatePostureChecksOnPeerGetFailed(a, ctx, sourcePostureChecksIDs, peer.ID)
|
||||
if !isValid && len(pname) > 0 {
|
||||
if _, ok := (*postureFailedPeers)[pname]; !ok {
|
||||
(*postureFailedPeers)[pname] = make(map[string]struct{})
|
||||
}
|
||||
(*postureFailedPeers)[pname][peer.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
|
||||
if peer.ID == peerID {
|
||||
peerInGroups = true
|
||||
continue
|
||||
}
|
||||
|
||||
filteredPeerIDs = append(filteredPeerIDs, peer.ID)
|
||||
}
|
||||
return filteredPeerIDs, peerInGroups
|
||||
}
|
||||
|
||||
for _, pid := range group.Peers {
|
||||
if _, seen := seenPeerIds[pid]; seen {
|
||||
continue
|
||||
}
|
||||
seenPeerIds[pid] = struct{}{}
|
||||
peer, ok := a.Peers[pid]
|
||||
if !ok || peer == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := validatedPeersMap[peer.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
isValid, pname := validatePostureChecksOnPeerGetFailed(a, ctx, sourcePostureChecksIDs, peer.ID)
|
||||
if !isValid && len(pname) > 0 {
|
||||
if _, ok := (*postureFailedPeers)[pname]; !ok {
|
||||
(*postureFailedPeers)[pname] = make(map[string]struct{})
|
||||
}
|
||||
(*postureFailedPeers)[pname][peer.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
|
||||
if peer.ID == peerID {
|
||||
peerInGroups = true
|
||||
continue
|
||||
}
|
||||
|
||||
filteredPeerIDs = append(filteredPeerIDs, peer.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredPeerIDs, peerInGroups
|
||||
}
|
||||
|
||||
func validatePostureChecksOnPeerGetFailed(a *Account, ctx context.Context, sourcePostureChecksID []string, peerID string) (bool, string) {
|
||||
peer, ok := a.Peers[peerID]
|
||||
if !ok || peer == nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
for _, postureChecksID := range sourcePostureChecksID {
|
||||
postureChecks := a.GetPostureChecks(postureChecksID)
|
||||
if postureChecks == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, check := range postureChecks.GetChecks() {
|
||||
isValid, _ := check.Check(ctx, *peer)
|
||||
if !isValid {
|
||||
return false, postureChecksID
|
||||
}
|
||||
}
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func getPostureValidPeersSaveFailed(a *Account, inputPeers []string, postureChecksIDs []string, validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}) []string {
|
||||
var dest []string
|
||||
for _, peerID := range inputPeers {
|
||||
if _, validated := validatedPeersMap[peerID]; !validated {
|
||||
continue
|
||||
}
|
||||
valid, pname := validatePostureChecksOnPeerGetFailed(a, context.Background(), postureChecksIDs, peerID)
|
||||
if valid {
|
||||
dest = append(dest, peerID)
|
||||
continue
|
||||
}
|
||||
if _, ok := (*postureFailedPeers)[pname]; !ok {
|
||||
(*postureFailedPeers)[pname] = make(map[string]struct{})
|
||||
}
|
||||
(*postureFailedPeers)[pname][peerID] = struct{}{}
|
||||
}
|
||||
return dest
|
||||
}
|
||||
|
||||
// filterGroupPeers trims each group's Peers slice to only those peers that
|
||||
// also appear in `peers`. Groups whose filtered list is empty are NOT
|
||||
// deleted from the map — they're kept so the components wire encoder can
|
||||
// still resolve seq references from routes/policies/access-control groups
|
||||
// that name them. Calculate() tolerates groups with empty Peers (the inner
|
||||
// loops simply iterate zero times), so retaining them is behaviourally a
|
||||
// no-op for the legacy path that consumes the same NetworkMapComponents.
|
||||
func filterGroupPeers(groups *map[string]*ComponentGroup, peers map[string]*ComponentPeer) {
|
||||
for groupID, groupInfo := range *groups {
|
||||
filteredPeers := make([]string, 0, len(groupInfo.Peers))
|
||||
for _, pid := range groupInfo.Peers {
|
||||
if _, exists := peers[pid]; exists {
|
||||
filteredPeers = append(filteredPeers, pid)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filteredPeers) != len(groupInfo.Peers) {
|
||||
ng := *groupInfo
|
||||
ng.Peers = filteredPeers
|
||||
(*groups)[groupID] = &ng
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{}, policies []*Policy, resourcePoliciesMap map[string][]*Policy, peers map[string]*ComponentPeer) {
|
||||
if len(*postureFailedPeers) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
referencedPostureChecks := make(map[string]struct{})
|
||||
for _, policy := range policies {
|
||||
for _, checkID := range policy.SourcePostureChecks {
|
||||
referencedPostureChecks[checkID] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, resPolicies := range resourcePoliciesMap {
|
||||
for _, policy := range resPolicies {
|
||||
for _, checkID := range policy.SourcePostureChecks {
|
||||
referencedPostureChecks[checkID] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for checkID, failedPeers := range *postureFailedPeers {
|
||||
if _, referenced := referencedPostureChecks[checkID]; !referenced {
|
||||
delete(*postureFailedPeers, checkID)
|
||||
continue
|
||||
}
|
||||
for peerID := range failedPeers {
|
||||
if _, exists := peers[peerID]; !exists {
|
||||
delete(failedPeers, peerID)
|
||||
}
|
||||
}
|
||||
if len(failedPeers) == 0 {
|
||||
delete(*postureFailedPeers, checkID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func filterDNSRecordsByPeers(records []nbdns.SimpleRecord, peers map[string]*ComponentPeer, includeIPv6 bool) []nbdns.SimpleRecord {
|
||||
if len(records) == 0 || len(peers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Include both v4 and v6 addresses so AAAA records (whose RData is an IPv6
|
||||
// address) are not filtered out when peers have IPv6 assigned. When the
|
||||
// requesting peer doesn't have IPv6, omit v6 IPs so AAAA records get dropped.
|
||||
peerIPs := make(map[string]struct{}, len(peers)*2)
|
||||
for _, peer := range peers {
|
||||
if peer == nil {
|
||||
continue
|
||||
}
|
||||
peerIPs[peer.IP.String()] = struct{}{}
|
||||
if includeIPv6 && peer.IPv6.IsValid() {
|
||||
peerIPs[peer.IPv6.String()] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
filteredRecords := make([]nbdns.SimpleRecord, 0, len(records))
|
||||
for _, record := range records {
|
||||
if _, exists := peerIPs[record.RData]; exists {
|
||||
filteredRecords = append(filteredRecords, record)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredRecords
|
||||
}
|
||||
|
||||
func filterGroupIDToUserIDs(fullMap map[string][]string, neededGroupIDs map[string]struct{}) map[string][]string {
|
||||
if len(neededGroupIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
filtered := make(map[string][]string, len(neededGroupIDs))
|
||||
for groupID := range neededGroupIDs {
|
||||
if users, ok := fullMap[groupID]; ok {
|
||||
filtered[groupID] = users
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
48
management/server/types/legacynmap/aliases.go
Normal file
48
management/server/types/legacynmap/aliases.go
Normal file
@@ -0,0 +1,48 @@
|
||||
//go:build nmapequiv
|
||||
|
||||
// Package legacynmap is a frozen copy of main's Account → NetworkMapComponents →
|
||||
// NetworkMap → proto path, used only by the main-vs-branch equivalence test.
|
||||
// It is build-tagged so it never compiles into production binaries, and it lives
|
||||
// in its own package so it cannot reach this tree's unexported helpers — a
|
||||
// divergence can therefore never be hidden by the two sides sharing code.
|
||||
//
|
||||
// Delete this package once the nmdata refactor is validated.
|
||||
//
|
||||
// Types below are aliased rather than copied because they are byte-identical
|
||||
// between main and this branch. Anything that drifted is copied instead; see
|
||||
// converters.go and copied_funcs.go.
|
||||
package legacynmap
|
||||
|
||||
import (
|
||||
types "github.com/netbirdio/netbird/management/server/types"
|
||||
sharedtypes "github.com/netbirdio/netbird/shared/management/types"
|
||||
)
|
||||
|
||||
type (
|
||||
Account = types.Account
|
||||
|
||||
DNSSettings = sharedtypes.DNSSettings
|
||||
FirewallRule = sharedtypes.FirewallRule
|
||||
ForwardingRule = sharedtypes.ForwardingRule
|
||||
Group = sharedtypes.Group
|
||||
Network = sharedtypes.Network
|
||||
Policy = sharedtypes.Policy
|
||||
PolicyRule = sharedtypes.PolicyRule
|
||||
Resource = sharedtypes.Resource
|
||||
RulePortRange = sharedtypes.RulePortRange
|
||||
RouteFirewallRule = sharedtypes.RouteFirewallRule
|
||||
)
|
||||
|
||||
const (
|
||||
FirewallRuleDirectionIN = sharedtypes.FirewallRuleDirectionIN
|
||||
FirewallRuleDirectionOUT = sharedtypes.FirewallRuleDirectionOUT
|
||||
|
||||
PolicyRuleProtocolALL = sharedtypes.PolicyRuleProtocolALL
|
||||
PolicyRuleProtocolTCP = sharedtypes.PolicyRuleProtocolTCP
|
||||
PolicyRuleProtocolNetbirdSSH = sharedtypes.PolicyRuleProtocolNetbirdSSH
|
||||
PolicyTrafficActionAccept = sharedtypes.PolicyTrafficActionAccept
|
||||
ResourceTypePeer = sharedtypes.ResourceTypePeer
|
||||
|
||||
AllowedIPsFormat = sharedtypes.AllowedIPsFormat
|
||||
AllowedIPsV6Format = sharedtypes.AllowedIPsV6Format
|
||||
)
|
||||
@@ -1,4 +1,6 @@
|
||||
package types
|
||||
//go:build nmapequiv
|
||||
|
||||
package legacynmap
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
128
management/server/types/legacynmap/converters.go
Normal file
128
management/server/types/legacynmap/converters.go
Normal file
@@ -0,0 +1,128 @@
|
||||
//go:build nmapequiv
|
||||
|
||||
package legacynmap
|
||||
|
||||
import (
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
|
||||
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
)
|
||||
|
||||
// NetworkMap is main's shape. It is copied rather than aliased because this
|
||||
// branch's NetworkMap dropped ForceRoutingPeerDNSResolution, which main threads
|
||||
// into PeerConfig.RoutingPeerDnsResolutionEnabled.
|
||||
type NetworkMap struct {
|
||||
Peers []*ComponentPeer
|
||||
Network *Network
|
||||
Routes []*route.Route
|
||||
DNSConfig nbdns.Config
|
||||
OfflinePeers []*ComponentPeer
|
||||
FirewallRules []*FirewallRule
|
||||
RoutesFirewallRules []*RouteFirewallRule
|
||||
ForwardingRules []*ForwardingRule
|
||||
AuthorizedUsers map[string]map[string]struct{}
|
||||
EnableSSH bool
|
||||
// ForceRoutingPeerDNSResolution forces the peer to run/use routing-peer DNS
|
||||
// resolution regardless of the account-global setting, for reverse-proxy
|
||||
// domain targets.
|
||||
ForceRoutingPeerDNSResolution bool
|
||||
}
|
||||
|
||||
// The ToComponent converters below are main's methods, re-expressed as free
|
||||
// functions because their receivers live in packages this one cannot extend.
|
||||
// Bodies are otherwise unchanged.
|
||||
|
||||
func peerToComponent(p *nbpeer.Peer) *ComponentPeer {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
cp := &ComponentPeer{
|
||||
ID: p.ID,
|
||||
Key: p.Key,
|
||||
IP: p.IP,
|
||||
IPv6: p.IPv6,
|
||||
DNSLabel: p.DNSLabel,
|
||||
SSHKey: p.SSHKey,
|
||||
SSHEnabled: p.SSHEnabled,
|
||||
ServerSSHAllowed: p.Meta.Flags.ServerSSHAllowed,
|
||||
AgentVersion: p.Meta.WtVersion,
|
||||
SupportsSourcePrefixes: p.SupportsSourcePrefixes(),
|
||||
SupportsIPv6: p.SupportsIPv6(),
|
||||
LoginExpirationEnabled: p.LoginExpirationEnabled,
|
||||
AddedWithSSOLogin: p.AddedWithSSOLogin(),
|
||||
}
|
||||
if p.LastLogin != nil {
|
||||
cp.LastLogin = *p.LastLogin
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
func groupToComponent(g *Group) *ComponentGroup {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
return &ComponentGroup{
|
||||
ID: g.ID,
|
||||
PublicID: g.PublicID,
|
||||
Name: g.Name,
|
||||
Peers: g.Peers,
|
||||
}
|
||||
}
|
||||
|
||||
func groupsToComponent(groups map[string]*Group) map[string]*ComponentGroup {
|
||||
if groups == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]*ComponentGroup, len(groups))
|
||||
for id, g := range groups {
|
||||
out[id] = groupToComponent(g)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func routerToComponent(n *routerTypes.NetworkRouter) *ComponentRouter {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
return &ComponentRouter{
|
||||
NetworkID: n.NetworkID,
|
||||
PublicID: n.PublicID,
|
||||
Peer: n.Peer,
|
||||
PeerGroups: n.PeerGroups,
|
||||
Masquerade: n.Masquerade,
|
||||
Metric: n.Metric,
|
||||
Enabled: n.Enabled,
|
||||
}
|
||||
}
|
||||
|
||||
func routersToComponentMap(routers map[string]*routerTypes.NetworkRouter) map[string]*ComponentRouter {
|
||||
if routers == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]*ComponentRouter, len(routers))
|
||||
for id, r := range routers {
|
||||
out[id] = routerToComponent(r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resourceToComponent(n *resourceTypes.NetworkResource) *ComponentResource {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
return &ComponentResource{
|
||||
ID: n.ID,
|
||||
PublicID: n.PublicID,
|
||||
NetworkID: n.NetworkID,
|
||||
AccountID: n.AccountID,
|
||||
Name: n.Name,
|
||||
Description: n.Description,
|
||||
Type: ComponentResourceType(n.Type),
|
||||
Address: n.Address,
|
||||
Domain: n.Domain,
|
||||
Prefix: n.Prefix,
|
||||
Enabled: n.Enabled,
|
||||
}
|
||||
}
|
||||
284
management/server/types/legacynmap/copied_funcs.go
Normal file
284
management/server/types/legacynmap/copied_funcs.go
Normal file
@@ -0,0 +1,284 @@
|
||||
//go:build nmapequiv
|
||||
|
||||
package legacynmap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones/records"
|
||||
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
|
||||
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
|
||||
nbroute "github.com/netbirdio/netbird/route"
|
||||
)
|
||||
|
||||
func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*ComponentPeer, direction int, includeIPv6 bool) []*RouteFirewallRule {
|
||||
rulesExists := make(map[string]struct{})
|
||||
rules := make([]*RouteFirewallRule, 0)
|
||||
|
||||
v4Sources, v6Sources := splitPeerSourcesByFamily(groupPeers)
|
||||
|
||||
isV6Route := route.Network.Addr().Is6()
|
||||
|
||||
// Skip v6 destination routes entirely for peers without IPv6 support
|
||||
if isV6Route && !includeIPv6 {
|
||||
return rules
|
||||
}
|
||||
|
||||
// Pick sources matching the destination family
|
||||
sourceRanges := v4Sources
|
||||
if isV6Route {
|
||||
sourceRanges = v6Sources
|
||||
}
|
||||
|
||||
baseRule := RouteFirewallRule{
|
||||
PolicyID: rule.PolicyID,
|
||||
RouteID: route.ID,
|
||||
SourceRanges: sourceRanges,
|
||||
Action: string(rule.Action),
|
||||
Destination: route.Network.String(),
|
||||
Protocol: string(rule.Protocol),
|
||||
Domains: route.Domains,
|
||||
IsDynamic: route.IsDynamic(),
|
||||
}
|
||||
|
||||
if len(rule.Ports) == 0 {
|
||||
rules = append(rules, generateRulesWithPortRanges(baseRule, rule, rulesExists)...)
|
||||
} else {
|
||||
rules = append(rules, generateRulesWithPorts(ctx, baseRule, rule, rulesExists)...)
|
||||
}
|
||||
|
||||
// Generate v6 counterpart for dynamic routes and 0.0.0.0/0 exit node routes.
|
||||
isDefaultV4 := !isV6Route && route.Network.Bits() == 0
|
||||
if includeIPv6 && (route.IsDynamic() || isDefaultV4) && len(v6Sources) > 0 {
|
||||
v6Rule := baseRule
|
||||
v6Rule.SourceRanges = v6Sources
|
||||
if isDefaultV4 {
|
||||
v6Rule.Destination = "::/0"
|
||||
v6Rule.RouteID = route.ID + "-v6-default"
|
||||
}
|
||||
if len(rule.Ports) == 0 {
|
||||
rules = append(rules, generateRulesWithPortRanges(v6Rule, rule, rulesExists)...)
|
||||
} else {
|
||||
rules = append(rules, generateRulesWithPorts(ctx, v6Rule, rule, rulesExists)...)
|
||||
}
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
func filterPeerAppliedZones(ctx context.Context, accountZones []*zones.Zone, peerGroups LookupMap) []nbdns.CustomZone {
|
||||
var customZones []nbdns.CustomZone
|
||||
|
||||
if len(peerGroups) == 0 {
|
||||
return customZones
|
||||
}
|
||||
|
||||
for _, zone := range accountZones {
|
||||
if !zone.Enabled || len(zone.Records) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
hasAccess := false
|
||||
for _, distGroupID := range zone.DistributionGroups {
|
||||
if _, found := peerGroups[distGroupID]; found {
|
||||
hasAccess = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasAccess {
|
||||
continue
|
||||
}
|
||||
|
||||
simpleRecords := make([]nbdns.SimpleRecord, 0, len(zone.Records))
|
||||
for _, record := range zone.Records {
|
||||
var recordType int
|
||||
rData := record.Content
|
||||
|
||||
switch record.Type {
|
||||
case records.RecordTypeA:
|
||||
recordType = int(dns.TypeA)
|
||||
case records.RecordTypeAAAA:
|
||||
recordType = int(dns.TypeAAAA)
|
||||
case records.RecordTypeCNAME:
|
||||
recordType = int(dns.TypeCNAME)
|
||||
rData = dns.Fqdn(record.Content)
|
||||
default:
|
||||
log.WithContext(ctx).Warnf("unknown DNS record type %s for record %s", record.Type, record.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
simpleRecords = append(simpleRecords, nbdns.SimpleRecord{
|
||||
Name: dns.Fqdn(record.Name),
|
||||
Type: recordType,
|
||||
Class: nbdns.DefaultClass,
|
||||
TTL: record.TTL,
|
||||
RData: rData,
|
||||
})
|
||||
}
|
||||
|
||||
customZones = append(customZones, nbdns.CustomZone{
|
||||
Domain: dns.Fqdn(zone.Domain),
|
||||
Records: simpleRecords,
|
||||
SearchDomainDisabled: !zone.EnableSearchDomain,
|
||||
NonAuthoritative: true,
|
||||
})
|
||||
}
|
||||
|
||||
return customZones
|
||||
}
|
||||
|
||||
func getAllowedUserIDs(a *Account) map[string]struct{} {
|
||||
users := make(map[string]struct{})
|
||||
for _, nbUser := range a.Users {
|
||||
if !nbUser.IsBlocked() && !nbUser.IsServiceUser {
|
||||
users[nbUser.Id] = struct{}{}
|
||||
}
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
func getUniquePeerIDsFromGroupsIDs(a *Account, ctx context.Context, groups []string) []string {
|
||||
peerIDs := make(map[string]struct{}, len(groups)) // we expect at least one peer per group as initial capacity
|
||||
for _, groupID := range groups {
|
||||
group := a.GetGroup(groupID)
|
||||
if group == nil {
|
||||
log.WithContext(ctx).Warnf("group %s doesn't exist under account %s, will continue map generation without it", groupID, a.Id)
|
||||
continue
|
||||
}
|
||||
|
||||
if group.IsGroupAll() || len(groups) == 1 {
|
||||
return group.Peers
|
||||
}
|
||||
|
||||
for _, peerID := range group.Peers {
|
||||
peerIDs[peerID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(peerIDs))
|
||||
for peerID := range peerIDs {
|
||||
ids = append(ids, peerID)
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
func forcesRoutingPeerDNSResolution(a *Account, peerID string, routers map[string]map[string]*routerTypes.NetworkRouter) bool {
|
||||
targeted := proxyTargetedDomainResourceIDs(a)
|
||||
if len(targeted) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, resource := range a.NetworkResources {
|
||||
if resource == nil || !resource.Enabled || resource.Type != resourceTypes.Domain {
|
||||
continue
|
||||
}
|
||||
if _, ok := targeted[resource.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, isRouter := routers[resource.NetworkID][peerID]; isRouter {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func proxyTargetedDomainResourceIDs(a *Account) map[string]struct{} {
|
||||
ids := make(map[string]struct{})
|
||||
for _, svc := range a.Services {
|
||||
if svc == nil || !svc.Enabled || svc.Terminated {
|
||||
continue
|
||||
}
|
||||
for _, target := range svc.Targets {
|
||||
if target == nil || !target.Enabled {
|
||||
continue
|
||||
}
|
||||
if target.TargetType == service.TargetTypeDomain {
|
||||
ids[target.TargetId] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func splitPeerSourcesByFamily(groupPeers []*ComponentPeer) (v4, v6 []string) {
|
||||
v4 = make([]string, 0, len(groupPeers))
|
||||
v6 = make([]string, 0, len(groupPeers))
|
||||
for _, peer := range groupPeers {
|
||||
if peer == nil {
|
||||
continue
|
||||
}
|
||||
v4 = append(v4, fmt.Sprintf(AllowedIPsFormat, peer.IP))
|
||||
if peer.IPv6.IsValid() {
|
||||
v6 = append(v6, fmt.Sprintf(AllowedIPsV6Format, peer.IPv6))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func generateRulesWithPortRanges(baseRule RouteFirewallRule, rule *PolicyRule, rulesExists map[string]struct{}) []*RouteFirewallRule {
|
||||
rules := make([]*RouteFirewallRule, 0)
|
||||
|
||||
ruleIDBase := generateRuleIDBase(rule, baseRule)
|
||||
if len(rule.Ports) == 0 {
|
||||
if len(rule.PortRanges) == 0 {
|
||||
if _, ok := rulesExists[ruleIDBase]; !ok {
|
||||
rulesExists[ruleIDBase] = struct{}{}
|
||||
rules = append(rules, &baseRule)
|
||||
}
|
||||
} else {
|
||||
for _, portRange := range rule.PortRanges {
|
||||
ruleID := fmt.Sprintf("%s%d-%d", ruleIDBase, portRange.Start, portRange.End)
|
||||
if _, ok := rulesExists[ruleID]; !ok {
|
||||
rulesExists[ruleID] = struct{}{}
|
||||
pr := baseRule
|
||||
pr.PortRange = portRange
|
||||
rules = append(rules, &pr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
func generateRulesWithPorts(ctx context.Context, baseRule RouteFirewallRule, rule *PolicyRule, rulesExists map[string]struct{}) []*RouteFirewallRule {
|
||||
rules := make([]*RouteFirewallRule, 0)
|
||||
ruleIDBase := generateRuleIDBase(rule, baseRule)
|
||||
|
||||
for _, port := range rule.Ports {
|
||||
ruleID := ruleIDBase + port
|
||||
if _, ok := rulesExists[ruleID]; ok {
|
||||
continue
|
||||
}
|
||||
rulesExists[ruleID] = struct{}{}
|
||||
|
||||
pr := baseRule
|
||||
p, err := strconv.ParseUint(port, 10, 16)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to parse port %s for rule: %s", port, rule.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
pr.Port = uint16(p)
|
||||
rules = append(rules, &pr)
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
func generateRuleIDBase(rule *PolicyRule, baseRule RouteFirewallRule) string {
|
||||
return rule.ID + strings.Join(baseRule.SourceRanges, ",") + strconv.Itoa(FirewallRuleDirectionIN) + baseRule.Protocol + baseRule.Action
|
||||
}
|
||||
7
management/server/types/legacynmap/doc.go
Normal file
7
management/server/types/legacynmap/doc.go
Normal file
@@ -0,0 +1,7 @@
|
||||
// Package legacynmap holds a frozen copy of main's network-map computation,
|
||||
// used only by the main-vs-branch proto equivalence test. All real content is
|
||||
// behind the nmapequiv build tag; this file exists so the package is still valid
|
||||
// for untagged builds and `go test ./...`.
|
||||
//
|
||||
// Delete this package once the nmdata refactor is validated.
|
||||
package legacynmap
|
||||
570
management/server/types/legacynmap/equivalence_test.go
Normal file
570
management/server/types/legacynmap/equivalence_test.go
Normal file
@@ -0,0 +1,570 @@
|
||||
//go:build nmapequiv
|
||||
|
||||
// Main-vs-branch equivalence check. For every peer of every account in a real
|
||||
// Postgres copy it computes the client-facing proto.NetworkMap twice:
|
||||
//
|
||||
// - legacy path: main's Account → NetworkMapComponents → Calculate → proto
|
||||
// (the frozen copy in this package)
|
||||
// - new path: this branch's Account → NetworkMapData → components →
|
||||
// Calculate → ToSyncResponse → proto
|
||||
//
|
||||
// proto.NetworkMap is generated code identical in both trees, which is what
|
||||
// makes it the one usable comparison surface — the intermediate Go types differ
|
||||
// by design. proto.Equal would trip over repeated-field ordering, so both sides
|
||||
// are canonicalized first.
|
||||
//
|
||||
// NETBIRD_STORE_ENGINE_POSTGRES_DSN='...' go test -tags nmapequiv \
|
||||
// -run TestNetworkMapProtoEquivalence -count=1 -timeout 60m \
|
||||
// ./management/server/types/legacynmap/
|
||||
//
|
||||
// Accounts are loaded one at a time and released between iterations, so peak
|
||||
// memory tracks the largest single account rather than the whole database.
|
||||
//
|
||||
// Env knobs: NETMAP_ACCOUNTS (comma-separated ids, skips discovery),
|
||||
// NETMAP_MAX_ACCOUNTS (0 = all), NETMAP_MAX_PEERS (0 = all). Fails at the
|
||||
// first divergence.
|
||||
package legacynmap_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"os"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/encoding/prototext"
|
||||
goproto "google.golang.org/protobuf/proto"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller/cache"
|
||||
mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/management/server/types/legacynmap"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
equivDNSName = "netbird.cloud"
|
||||
progressEvery = 5000
|
||||
)
|
||||
|
||||
type equivStats struct {
|
||||
accounts int
|
||||
peersChecked int
|
||||
skippedNilNM int
|
||||
}
|
||||
|
||||
func TestNetworkMapProtoEquivalence(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("prod-db equivalence test, skipped in short mode")
|
||||
}
|
||||
dsn := equivDSN()
|
||||
if dsn == "" {
|
||||
t.Skip("NETBIRD_STORE_ENGINE_POSTGRES_DSN not set")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
// skipMigration=true: this reads a restored production copy and must not
|
||||
// alter its schema. Flip to false only if reads fail on an older dump.
|
||||
testStore, err := store.NewPostgresqlStore(ctx, dsn, nil, true)
|
||||
require.NoError(t, err, "connect to postgres")
|
||||
t.Cleanup(func() { testStore.Close(ctx) })
|
||||
|
||||
accountIDs := equivAccountIDs(t, dsn)
|
||||
require.NotEmpty(t, accountIDs, "no accounts selected")
|
||||
|
||||
stats := &equivStats{accounts: len(accountIDs)}
|
||||
maxPeers := envInt("NETMAP_MAX_PEERS", 0)
|
||||
|
||||
for i, accountID := range accountIDs {
|
||||
account, err := testStore.GetAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
t.Logf("account %s: load failed, skipping: %v", accountID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
checkAccount(ctx, t, account, maxPeers, stats)
|
||||
|
||||
account = nil
|
||||
debug.FreeOSMemory()
|
||||
|
||||
if i%progressEvery == 0 {
|
||||
var ms runtime.MemStats
|
||||
runtime.ReadMemStats(&ms)
|
||||
t.Logf("progress: accounts=%d/%d peers_checked=%d heap=%dMiB", i, len(accountIDs), stats.peersChecked, ms.HeapAlloc>>20)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("equivalence: accounts=%d peers_checked=%d skipped_nil_nm=%d — no divergence",
|
||||
stats.accounts, stats.peersChecked, stats.skippedNilNM)
|
||||
}
|
||||
|
||||
// checkAccount compares both paths for every peer of one account. Nothing is
|
||||
// retained across peers, so memory stays flat within an account.
|
||||
func checkAccount(ctx context.Context, t *testing.T, account *types.Account, maxPeers int, stats *equivStats) {
|
||||
t.Helper()
|
||||
|
||||
if len(account.Peers) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
validated := make(map[string]struct{}, len(account.Peers))
|
||||
peerIDs := make([]string, 0, len(account.Peers))
|
||||
for peerID := range account.Peers {
|
||||
validated[peerID] = struct{}{}
|
||||
peerIDs = append(peerIDs, peerID)
|
||||
}
|
||||
sort.Strings(peerIDs)
|
||||
if maxPeers > 0 && len(peerIDs) > maxPeers {
|
||||
peerIDs = peerIDs[:maxPeers]
|
||||
}
|
||||
|
||||
resourcePolicies := account.GetResourcePoliciesMap()
|
||||
routers := account.GetResourceRoutersMap()
|
||||
groupUsers := account.GetActiveGroupUsers()
|
||||
|
||||
settings := account.Settings
|
||||
if settings == nil {
|
||||
settings = &types.Settings{}
|
||||
}
|
||||
|
||||
for _, peerID := range peerIDs {
|
||||
peer := account.Peers[peerID]
|
||||
if peer == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// NEW PATH — this branch, through the production conversion.
|
||||
newNM := account.GetPeerNetworkMapFromComponents(
|
||||
ctx, peerID, nbdns.CustomZone{}, nil, validated, resourcePolicies, routers, nil, groupUsers,
|
||||
)
|
||||
if newNM == nil {
|
||||
stats.skippedNilNM++
|
||||
continue
|
||||
}
|
||||
newProto := mgmtgrpc.ToSyncResponse(
|
||||
ctx, nil, nil, nil, peer, nil, nil, newNM, equivDNSName, nil,
|
||||
&cache.DNSConfigCache{}, settings, settings.Extra, nil, 0,
|
||||
).NetworkMap
|
||||
|
||||
// LEGACY PATH — main's frozen copy.
|
||||
legacyNM := legacynmap.GetPeerNetworkMapFromComponents(
|
||||
account, ctx, peerID, nbdns.CustomZone{}, nil, validated, resourcePolicies, routers, nil, groupUsers,
|
||||
)
|
||||
if legacyNM == nil {
|
||||
t.Fatalf("after %d peers: account=%s peer=%s legacy NetworkMap nil, new non-nil", stats.peersChecked, account.Id, peerID)
|
||||
}
|
||||
// A separate cache per side: sharing one would let the first path
|
||||
// populate entries the second then reuses, which can mask a real diff.
|
||||
legacyProto := legacynmap.ToProtoNetworkMap(
|
||||
ctx, peer, legacyNM, equivDNSName, settings, nil, &cache.DNSConfigCache{}, 0,
|
||||
)
|
||||
|
||||
canonicalize(legacyProto)
|
||||
canonicalize(newProto)
|
||||
stats.peersChecked++
|
||||
|
||||
if !goproto.Equal(legacyProto, newProto) {
|
||||
t.Fatalf("after %d peers: %s", stats.peersChecked, describeDivergence(legacyProto, newProto, account.Id, peerID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func equivDSN() string {
|
||||
if dsn := os.Getenv("NETBIRD_STORE_ENGINE_POSTGRES_DSN"); dsn != "" {
|
||||
return dsn
|
||||
}
|
||||
return os.Getenv("NB_STORE_ENGINE_POSTGRES_DSN")
|
||||
}
|
||||
|
||||
// equivAccountIDs lists account ids with an id-only query. store.GetAllAccounts
|
||||
// would hydrate every account in the database before the first comparison runs.
|
||||
// Sorting happens in Go so the order does not depend on database collation.
|
||||
func equivAccountIDs(t *testing.T, dsn string) []string {
|
||||
t.Helper()
|
||||
|
||||
if ids := strings.TrimSpace(os.Getenv("NETMAP_ACCOUNTS")); ids != "" {
|
||||
var out []string
|
||||
for _, id := range strings.Split(ids, ",") {
|
||||
if id = strings.TrimSpace(id); id != "" {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: gormlogger.Discard})
|
||||
require.NoError(t, err, "open id-listing connection")
|
||||
defer func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
var ids []string
|
||||
require.NoError(t, db.Model(&types.Account{}).Pluck("id", &ids).Error)
|
||||
sort.Strings(ids)
|
||||
|
||||
if max := envInt("NETMAP_MAX_ACCOUNTS", 0); max > 0 && len(ids) > max {
|
||||
ids = ids[:max]
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func envInt(name string, def int) int {
|
||||
if v := os.Getenv(name); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// canonicalize sorts every repeated field by a stable key. Both paths iterate Go
|
||||
// maps while building these slices, so order can differ even when the content is
|
||||
// identical; without this proto.Equal reports noise.
|
||||
func canonicalize(nm *proto.NetworkMap) {
|
||||
if nm == nil {
|
||||
return
|
||||
}
|
||||
slices.SortFunc(nm.RemotePeers, cmpRemotePeer)
|
||||
slices.SortFunc(nm.OfflinePeers, cmpRemotePeer)
|
||||
slices.SortFunc(nm.Routes, cmpRoute)
|
||||
slices.SortFunc(nm.FirewallRules, cmpFirewallRule)
|
||||
slices.SortFunc(nm.RoutesFirewallRules, cmpRouteFirewallRule)
|
||||
slices.SortFunc(nm.ForwardingRules, cmpForwardingRule)
|
||||
|
||||
for _, r := range nm.FirewallRules {
|
||||
slices.SortFunc(r.SourcePrefixes, bytes.Compare)
|
||||
}
|
||||
for _, r := range nm.RoutesFirewallRules {
|
||||
slices.Sort(r.SourceRanges)
|
||||
}
|
||||
canonicalizeDNSConfig(nm.DNSConfig)
|
||||
canonicalizeSSHAuth(nm.SshAuth)
|
||||
}
|
||||
|
||||
func canonicalizeDNSConfig(d *proto.DNSConfig) {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
for _, g := range d.NameServerGroups {
|
||||
if g == nil {
|
||||
continue
|
||||
}
|
||||
slices.Sort(g.Domains)
|
||||
slices.SortFunc(g.NameServers, func(a, b *proto.NameServer) int {
|
||||
if a == nil || b == nil {
|
||||
return boolCmp(a == nil, b == nil)
|
||||
}
|
||||
if c := cmp.Compare(a.IP, b.IP); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := cmp.Compare(a.Port, b.Port); c != 0 {
|
||||
return c
|
||||
}
|
||||
return cmp.Compare(a.NSType, b.NSType)
|
||||
})
|
||||
}
|
||||
slices.SortFunc(d.NameServerGroups, func(a, b *proto.NameServerGroup) int {
|
||||
return cmp.Compare(nsgKey(a), nsgKey(b))
|
||||
})
|
||||
for _, z := range d.CustomZones {
|
||||
if z == nil {
|
||||
continue
|
||||
}
|
||||
slices.SortFunc(z.Records, cmpSimpleRecord)
|
||||
}
|
||||
slices.SortFunc(d.CustomZones, func(a, b *proto.CustomZone) int {
|
||||
if a == nil || b == nil {
|
||||
return boolCmp(a == nil, b == nil)
|
||||
}
|
||||
return cmp.Compare(a.Domain, b.Domain)
|
||||
})
|
||||
}
|
||||
|
||||
// canonicalizeSSHAuth sorts AuthorizedUsers and re-keys MachineUsers.Indexes
|
||||
// against the new ordering, preserving which machine user maps to which hashes.
|
||||
func canonicalizeSSHAuth(s *proto.SSHAuth) {
|
||||
if s == nil || len(s.AuthorizedUsers) == 0 {
|
||||
return
|
||||
}
|
||||
type hashed struct {
|
||||
bytes []byte
|
||||
old uint32
|
||||
}
|
||||
entries := make([]hashed, len(s.AuthorizedUsers))
|
||||
for i, b := range s.AuthorizedUsers {
|
||||
entries[i] = hashed{bytes: b, old: uint32(i)}
|
||||
}
|
||||
slices.SortFunc(entries, func(a, b hashed) int { return bytes.Compare(a.bytes, b.bytes) })
|
||||
|
||||
remap := make(map[uint32]uint32, len(entries))
|
||||
sorted := make([][]byte, len(entries))
|
||||
for newIdx, e := range entries {
|
||||
remap[e.old] = uint32(newIdx)
|
||||
sorted[newIdx] = e.bytes
|
||||
}
|
||||
s.AuthorizedUsers = sorted
|
||||
|
||||
for _, mu := range s.MachineUsers {
|
||||
if mu == nil {
|
||||
continue
|
||||
}
|
||||
for i, oldIdx := range mu.Indexes {
|
||||
if newIdx, ok := remap[oldIdx]; ok {
|
||||
mu.Indexes[i] = newIdx
|
||||
}
|
||||
}
|
||||
slices.Sort(mu.Indexes)
|
||||
}
|
||||
}
|
||||
|
||||
func boolCmp(a, b bool) int {
|
||||
if a == b {
|
||||
return 0
|
||||
}
|
||||
if a {
|
||||
return 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func nsgKey(g *proto.NameServerGroup) string {
|
||||
if g == nil {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
for _, ns := range g.NameServers {
|
||||
if ns == nil {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, ns.IP+":"+strconv.FormatInt(ns.Port, 10)+":"+strconv.FormatInt(ns.NSType, 10))
|
||||
}
|
||||
slices.Sort(parts)
|
||||
key := strings.Join(parts, ",")
|
||||
domains := append([]string(nil), g.Domains...)
|
||||
slices.Sort(domains)
|
||||
key += "|" + strings.Join(domains, "|")
|
||||
if g.Primary {
|
||||
key += "|P"
|
||||
}
|
||||
if g.SearchDomainsEnabled {
|
||||
key += "|S"
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func cmpSimpleRecord(a, b *proto.SimpleRecord) int {
|
||||
if a == nil || b == nil {
|
||||
return boolCmp(a == nil, b == nil)
|
||||
}
|
||||
if c := cmp.Compare(a.Name, b.Name); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := cmp.Compare(a.Type, b.Type); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := cmp.Compare(a.Class, b.Class); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := cmp.Compare(a.RData, b.RData); c != 0 {
|
||||
return c
|
||||
}
|
||||
return cmp.Compare(a.TTL, b.TTL)
|
||||
}
|
||||
|
||||
func cmpRemotePeer(a, b *proto.RemotePeerConfig) int {
|
||||
if a == nil || b == nil {
|
||||
return boolCmp(a == nil, b == nil)
|
||||
}
|
||||
return cmp.Compare(a.WgPubKey, b.WgPubKey)
|
||||
}
|
||||
|
||||
func cmpRoute(a, b *proto.Route) int {
|
||||
if a == nil || b == nil {
|
||||
return boolCmp(a == nil, b == nil)
|
||||
}
|
||||
if c := cmp.Compare(a.ID, b.ID); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := cmp.Compare(a.NetID, b.NetID); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := cmp.Compare(a.Network, b.Network); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := cmp.Compare(a.Peer, b.Peer); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := cmp.Compare(a.Metric, b.Metric); c != 0 {
|
||||
return c
|
||||
}
|
||||
return slices.Compare(a.Domains, b.Domains)
|
||||
}
|
||||
|
||||
func cmpFirewallRule(a, b *proto.FirewallRule) int {
|
||||
if a == nil || b == nil {
|
||||
return boolCmp(a == nil, b == nil)
|
||||
}
|
||||
if c := bytes.Compare(a.PolicyID, b.PolicyID); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := cmp.Compare(a.PeerIP, b.PeerIP); c != 0 { //nolint:staticcheck
|
||||
return c
|
||||
}
|
||||
if c := cmp.Compare(int32(a.Direction), int32(b.Direction)); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := cmp.Compare(int32(a.Action), int32(b.Action)); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := cmp.Compare(a.Port, b.Port); c != 0 {
|
||||
return c
|
||||
}
|
||||
return cmp.Compare(portInfoKey(a.PortInfo), portInfoKey(b.PortInfo))
|
||||
}
|
||||
|
||||
func cmpRouteFirewallRule(a, b *proto.RouteFirewallRule) int {
|
||||
if a == nil || b == nil {
|
||||
return boolCmp(a == nil, b == nil)
|
||||
}
|
||||
if c := bytes.Compare(a.PolicyID, b.PolicyID); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := cmp.Compare(a.RouteID, b.RouteID); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := cmp.Compare(a.Destination, b.Destination); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := cmp.Compare(portInfoKey(a.PortInfo), portInfoKey(b.PortInfo)); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := cmp.Compare(int32(a.Action), int32(b.Action)); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := slices.Compare(a.Domains, b.Domains); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := slices.Compare(a.SourceRanges, b.SourceRanges); c != 0 {
|
||||
return c
|
||||
}
|
||||
if c := cmp.Compare(a.CustomProtocol, b.CustomProtocol); c != 0 {
|
||||
return c
|
||||
}
|
||||
return boolCmp(a.IsDynamic, b.IsDynamic)
|
||||
}
|
||||
|
||||
func cmpForwardingRule(a, b *proto.ForwardingRule) int {
|
||||
if a == nil || b == nil {
|
||||
return boolCmp(a == nil, b == nil)
|
||||
}
|
||||
if c := cmp.Compare(int32(a.Protocol), int32(b.Protocol)); c != 0 {
|
||||
return c
|
||||
}
|
||||
return bytes.Compare(a.TranslatedAddress, b.TranslatedAddress)
|
||||
}
|
||||
|
||||
func portInfoKey(pi *proto.PortInfo) string {
|
||||
if pi == nil {
|
||||
return ""
|
||||
}
|
||||
switch sel := pi.PortSelection.(type) {
|
||||
case *proto.PortInfo_Port:
|
||||
return "P" + strconv.FormatUint(uint64(sel.Port), 10)
|
||||
case *proto.PortInfo_Range_:
|
||||
if sel.Range == nil {
|
||||
return "R"
|
||||
}
|
||||
return "R" + strconv.FormatUint(uint64(sel.Range.Start), 10) + "-" + strconv.FormatUint(uint64(sel.Range.End), 10)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// describeDivergence names the first differing field so a failure is actionable
|
||||
// without re-running against the database.
|
||||
func describeDivergence(legacy, updated *proto.NetworkMap, accountID, peerID string) string {
|
||||
prefix := "account=" + accountID + " peer=" + peerID
|
||||
|
||||
lens := []struct {
|
||||
field string
|
||||
a, b int
|
||||
}{
|
||||
{"RemotePeers", len(legacy.RemotePeers), len(updated.RemotePeers)},
|
||||
{"OfflinePeers", len(legacy.OfflinePeers), len(updated.OfflinePeers)},
|
||||
{"Routes", len(legacy.Routes), len(updated.Routes)},
|
||||
{"FirewallRules", len(legacy.FirewallRules), len(updated.FirewallRules)},
|
||||
{"RoutesFirewallRules", len(legacy.RoutesFirewallRules), len(updated.RoutesFirewallRules)},
|
||||
{"ForwardingRules", len(legacy.ForwardingRules), len(updated.ForwardingRules)},
|
||||
}
|
||||
for _, l := range lens {
|
||||
if l.a != l.b {
|
||||
return prefix + " field=" + l.field + " legacy_len=" + strconv.Itoa(l.a) + " new_len=" + strconv.Itoa(l.b)
|
||||
}
|
||||
}
|
||||
|
||||
for i := range legacy.RemotePeers {
|
||||
if !goproto.Equal(legacy.RemotePeers[i], updated.RemotePeers[i]) {
|
||||
return prefix + " field=RemotePeers[" + strconv.Itoa(i) + "] legacy=" + protoStr(legacy.RemotePeers[i]) + " new=" + protoStr(updated.RemotePeers[i])
|
||||
}
|
||||
}
|
||||
for i := range legacy.Routes {
|
||||
if !goproto.Equal(legacy.Routes[i], updated.Routes[i]) {
|
||||
return prefix + " field=Routes[" + strconv.Itoa(i) + "] legacy=" + protoStr(legacy.Routes[i]) + " new=" + protoStr(updated.Routes[i])
|
||||
}
|
||||
}
|
||||
for i := range legacy.FirewallRules {
|
||||
if !goproto.Equal(legacy.FirewallRules[i], updated.FirewallRules[i]) {
|
||||
return prefix + " field=FirewallRules[" + strconv.Itoa(i) + "] legacy=" + protoStr(legacy.FirewallRules[i]) + " new=" + protoStr(updated.FirewallRules[i])
|
||||
}
|
||||
}
|
||||
for i := range legacy.RoutesFirewallRules {
|
||||
if !goproto.Equal(legacy.RoutesFirewallRules[i], updated.RoutesFirewallRules[i]) {
|
||||
return prefix + " field=RoutesFirewallRules[" + strconv.Itoa(i) + "] legacy=" + protoStr(legacy.RoutesFirewallRules[i]) + " new=" + protoStr(updated.RoutesFirewallRules[i])
|
||||
}
|
||||
}
|
||||
if !goproto.Equal(legacy.PeerConfig, updated.PeerConfig) {
|
||||
return prefix + " field=PeerConfig legacy=" + protoStr(legacy.PeerConfig) + " new=" + protoStr(updated.PeerConfig)
|
||||
}
|
||||
if !goproto.Equal(legacy.DNSConfig, updated.DNSConfig) {
|
||||
return prefix + " field=DNSConfig legacy=" + protoStr(legacy.DNSConfig) + " new=" + protoStr(updated.DNSConfig)
|
||||
}
|
||||
if !goproto.Equal(legacy.SshAuth, updated.SshAuth) {
|
||||
return prefix + " field=SshAuth legacy=" + protoStr(legacy.SshAuth) + " new=" + protoStr(updated.SshAuth)
|
||||
}
|
||||
if legacy.Serial != updated.Serial {
|
||||
return prefix + " field=Serial legacy=" + strconv.FormatUint(legacy.Serial, 10) + " new=" + strconv.FormatUint(updated.Serial, 10)
|
||||
}
|
||||
return prefix + " (repeated fields equal element-wise — scalar/oneof mismatch)"
|
||||
}
|
||||
|
||||
func protoStr(m goproto.Message) string {
|
||||
if m == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
s := prototext.Format(m)
|
||||
const maxLen = 800
|
||||
if len(s) > maxLen {
|
||||
return s[:maxLen] + "...(truncated)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
157
management/server/types/legacynmap/firewall_helpers.go
Normal file
157
management/server/types/legacynmap/firewall_helpers.go
Normal file
@@ -0,0 +1,157 @@
|
||||
//go:build nmapequiv
|
||||
|
||||
package legacynmap
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
v "github.com/hashicorp/go-version"
|
||||
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
const (
|
||||
firewallRuleMinPortRangesVer = "0.48.0"
|
||||
firewallRuleMinNativeSSHVer = "0.60.0"
|
||||
|
||||
nativeSSHPortString = "22022"
|
||||
nativeSSHPortNumber = 22022
|
||||
defaultSSHPortString = "22"
|
||||
defaultSSHPortNumber = 22
|
||||
)
|
||||
|
||||
type supportedFeatures struct {
|
||||
nativeSSH bool
|
||||
portRanges bool
|
||||
}
|
||||
|
||||
type LookupMap map[string]struct{}
|
||||
|
||||
func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool {
|
||||
return rule.Protocol == PolicyRuleProtocolALL || (rule.Protocol == PolicyRuleProtocolTCP && (portsIncludesSSH(rule.Ports) || portRangeIncludesSSH(rule.PortRanges)))
|
||||
}
|
||||
|
||||
func portRangeIncludesSSH(portRanges []RulePortRange) bool {
|
||||
for _, pr := range portRanges {
|
||||
if (pr.Start <= defaultSSHPortNumber && pr.End >= defaultSSHPortNumber) || (pr.Start <= nativeSSHPortNumber && pr.End >= nativeSSHPortNumber) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func portsIncludesSSH(ports []string) bool {
|
||||
for _, port := range ports {
|
||||
if port == defaultSSHPortString || port == nativeSSHPortString {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ExpandPortsAndRanges expands Ports and PortRanges of a rule into individual firewall rules.
|
||||
func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPeer) []*FirewallRule {
|
||||
features := peerSupportedFirewallFeatures(peer.AgentVersion)
|
||||
|
||||
var expanded []*FirewallRule
|
||||
|
||||
for _, port := range rule.Ports {
|
||||
fr := base
|
||||
fr.Port = port
|
||||
expanded = append(expanded, &fr)
|
||||
}
|
||||
|
||||
for _, portRange := range rule.PortRanges {
|
||||
if len(rule.Ports) > 0 {
|
||||
break
|
||||
}
|
||||
fr := base
|
||||
|
||||
if features.portRanges {
|
||||
fr.PortRange = portRange
|
||||
} else {
|
||||
if portRange.Start != portRange.End {
|
||||
continue
|
||||
}
|
||||
fr.Port = strconv.FormatUint(uint64(portRange.Start), 10)
|
||||
}
|
||||
expanded = append(expanded, &fr)
|
||||
}
|
||||
|
||||
if shouldCheckRulesForNativeSSH(features.nativeSSH, rule, peer) || rule.Protocol == PolicyRuleProtocolNetbirdSSH {
|
||||
expanded = addNativeSSHRule(base, expanded)
|
||||
}
|
||||
|
||||
return expanded
|
||||
}
|
||||
|
||||
func addNativeSSHRule(base FirewallRule, expanded []*FirewallRule) []*FirewallRule {
|
||||
shouldAdd := false
|
||||
for _, fr := range expanded {
|
||||
if isPortInRule(nativeSSHPortString, 22022, fr) {
|
||||
return expanded
|
||||
}
|
||||
if isPortInRule(defaultSSHPortString, 22, fr) {
|
||||
shouldAdd = true
|
||||
}
|
||||
}
|
||||
if !shouldAdd {
|
||||
return expanded
|
||||
}
|
||||
|
||||
fr := base
|
||||
fr.Port = nativeSSHPortString
|
||||
return append(expanded, &fr)
|
||||
}
|
||||
|
||||
func isPortInRule(portString string, portInt uint16, rule *FirewallRule) bool {
|
||||
return rule.Port == portString || (rule.PortRange.Start <= portInt && portInt <= rule.PortRange.End)
|
||||
}
|
||||
|
||||
func shouldCheckRulesForNativeSSH(supportsNative bool, rule *PolicyRule, peer *ComponentPeer) bool {
|
||||
return supportsNative && peer.SSHEnabled && peer.ServerSSHAllowed && rule.Protocol == PolicyRuleProtocolTCP
|
||||
}
|
||||
|
||||
func peerSupportedFirewallFeatures(peerVer string) supportedFeatures {
|
||||
if version.IsDevelopmentVersion(peerVer) {
|
||||
return supportedFeatures{true, true}
|
||||
}
|
||||
|
||||
var features supportedFeatures
|
||||
|
||||
meetMinVer, err := meetsMinVersion(firewallRuleMinNativeSSHVer, peerVer)
|
||||
features.nativeSSH = err == nil && meetMinVer
|
||||
|
||||
if features.nativeSSH {
|
||||
features.portRanges = true
|
||||
} else {
|
||||
meetMinVer, err = meetsMinVersion(firewallRuleMinPortRangesVer, peerVer)
|
||||
features.portRanges = err == nil && meetMinVer
|
||||
}
|
||||
|
||||
return features
|
||||
}
|
||||
|
||||
// meetsMinVersion is main's version.MeetsMinVersion, which does not exist at HEAD.
|
||||
func meetsMinVersion(minVer, peerVer string) (bool, error) {
|
||||
peerVer = sanitizeVersion(peerVer)
|
||||
minVer = sanitizeVersion(minVer)
|
||||
|
||||
peerNBVer, err := v.NewVersion(peerVer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
constraints, err := v.NewConstraint(">= " + minVer)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return constraints.Check(peerNBVer), nil
|
||||
}
|
||||
|
||||
func sanitizeVersion(version string) string {
|
||||
parts := strings.Split(version, "-")
|
||||
return parts[0]
|
||||
}
|
||||
1034
management/server/types/legacynmap/networkmap_components.go
Normal file
1034
management/server/types/legacynmap/networkmap_components.go
Normal file
File diff suppressed because it is too large
Load Diff
208
management/server/types/legacynmap/proto_legacy.go
Normal file
208
management/server/types/legacynmap/proto_legacy.go
Normal file
@@ -0,0 +1,208 @@
|
||||
//go:build nmapequiv
|
||||
|
||||
package legacynmap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ssh/auth"
|
||||
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
types "github.com/netbirdio/netbird/management/server/types"
|
||||
nbroute "github.com/netbirdio/netbird/route"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/shared/netiputil"
|
||||
)
|
||||
|
||||
func ToProtocolRoutes(routes []*nbroute.Route) []*proto.Route {
|
||||
protoRoutes := make([]*proto.Route, 0, len(routes))
|
||||
for _, r := range routes {
|
||||
protoRoutes = append(protoRoutes, ToProtocolRoute(r))
|
||||
}
|
||||
return protoRoutes
|
||||
}
|
||||
|
||||
func ToProtocolRoute(route *nbroute.Route) *proto.Route {
|
||||
return &proto.Route{
|
||||
ID: string(route.ID),
|
||||
NetID: string(route.NetID),
|
||||
Network: route.Network.String(),
|
||||
Domains: route.Domains.ToPunycodeList(),
|
||||
NetworkType: int64(route.NetworkType),
|
||||
Peer: route.Peer,
|
||||
Metric: int64(route.Metric),
|
||||
Masquerade: route.Masquerade,
|
||||
KeepRoute: route.KeepRoute,
|
||||
SkipAutoApply: route.SkipAutoApply,
|
||||
}
|
||||
}
|
||||
|
||||
func AppendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*ComponentPeer, dnsName string, includeIPv6 bool) []*proto.RemotePeerConfig {
|
||||
for _, rPeer := range peers {
|
||||
allowedIPs := []string{rPeer.IP.String() + "/32"}
|
||||
if includeIPv6 && rPeer.IPv6.IsValid() {
|
||||
allowedIPs = append(allowedIPs, rPeer.IPv6.String()+"/128")
|
||||
}
|
||||
dst = append(dst, &proto.RemotePeerConfig{
|
||||
WgPubKey: rPeer.Key,
|
||||
AllowedIps: allowedIPs,
|
||||
SshConfig: &proto.SSHConfig{SshPubKey: []byte(rPeer.SSHKey)},
|
||||
Fqdn: rPeer.FQDN(dnsName),
|
||||
AgentVersion: rPeer.AgentVersion,
|
||||
})
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func buildJWTConfig(config *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow) *proto.JWTConfig {
|
||||
if config == nil || config.AuthAudience == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
issuer := strings.TrimSpace(config.AuthIssuer)
|
||||
if issuer == "" && deviceFlowConfig != nil {
|
||||
if d := deriveIssuerFromTokenEndpoint(deviceFlowConfig.ProviderConfig.TokenEndpoint); d != "" {
|
||||
issuer = d
|
||||
}
|
||||
}
|
||||
if issuer == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
keysLocation := strings.TrimSpace(config.AuthKeysLocation)
|
||||
if keysLocation == "" {
|
||||
keysLocation = strings.TrimSuffix(issuer, "/") + "/.well-known/jwks.json"
|
||||
}
|
||||
|
||||
audience := config.AuthAudience
|
||||
if config.CLIAuthAudience != "" {
|
||||
audience = config.CLIAuthAudience
|
||||
}
|
||||
|
||||
audiences := []string{config.AuthAudience}
|
||||
if config.CLIAuthAudience != "" && config.CLIAuthAudience != config.AuthAudience {
|
||||
audiences = append(audiences, config.CLIAuthAudience)
|
||||
}
|
||||
|
||||
return &proto.JWTConfig{
|
||||
Issuer: issuer,
|
||||
Audience: audience,
|
||||
Audiences: audiences,
|
||||
KeysLocation: keysLocation,
|
||||
}
|
||||
}
|
||||
|
||||
func toPeerConfig(peer *nbpeer.Peer, network *Network, dnsName string, settings *types.Settings, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, enableSSH bool, forceRoutingPeerDNS bool) *proto.PeerConfig {
|
||||
netmask, _ := network.Net.Mask.Size()
|
||||
fqdn := peer.FQDN(dnsName)
|
||||
|
||||
sshConfig := &proto.SSHConfig{
|
||||
SshEnabled: peer.SSHEnabled || enableSSH,
|
||||
}
|
||||
|
||||
if sshConfig.SshEnabled {
|
||||
sshConfig.JwtConfig = buildJWTConfig(httpConfig, deviceFlowConfig)
|
||||
}
|
||||
|
||||
peerConfig := &proto.PeerConfig{
|
||||
Address: fmt.Sprintf("%s/%d", peer.IP.String(), netmask),
|
||||
SshConfig: sshConfig,
|
||||
Fqdn: fqdn,
|
||||
RoutingPeerDnsResolutionEnabled: settings.RoutingPeerDNSResolutionEnabled || peer.ProxyMeta.Embedded || forceRoutingPeerDNS,
|
||||
LazyConnectionEnabled: settings.LazyConnectionEnabled,
|
||||
AutoUpdate: &proto.AutoUpdateSettings{
|
||||
Version: settings.AutoUpdateVersion,
|
||||
AlwaysUpdate: settings.AutoUpdateAlways,
|
||||
},
|
||||
}
|
||||
|
||||
if peer.SupportsIPv6() && peer.IPv6.IsValid() && network.NetV6.IP != nil {
|
||||
ones, _ := network.NetV6.Mask.Size()
|
||||
v6Prefix := netip.PrefixFrom(peer.IPv6.Unmap(), ones)
|
||||
if b, err := netiputil.EncodePrefix(v6Prefix); err == nil {
|
||||
peerConfig.AddressV6 = b
|
||||
}
|
||||
}
|
||||
|
||||
return peerConfig
|
||||
}
|
||||
|
||||
// ToProtoNetworkMap mirrors main's ToSyncResponse, restricted to the
|
||||
// proto.NetworkMap it produces. SyncResponse-level fields (NetbirdConfig,
|
||||
// Checks, the deprecated top-level RemotePeers) are omitted — they are not part
|
||||
// of the equivalence surface. PeerConfig is included because proto.NetworkMap
|
||||
// carries it, and it is where main's ForceRoutingPeerDNSResolution surfaces.
|
||||
func ToProtoNetworkMap(
|
||||
ctx context.Context,
|
||||
peer *nbpeer.Peer,
|
||||
nm *NetworkMap,
|
||||
dnsName string,
|
||||
settings *types.Settings,
|
||||
httpConfig *nbconfig.HttpServerConfig,
|
||||
dnsCache networkmap.DNSConfigCache,
|
||||
dnsFwdPort int64,
|
||||
) *proto.NetworkMap {
|
||||
includeIPv6 := peer.SupportsIPv6() && peer.IPv6.IsValid()
|
||||
useSourcePrefixes := peer.SupportsSourcePrefixes()
|
||||
|
||||
peerConfig := toPeerConfig(peer, nm.Network, dnsName, settings, httpConfig, nil, nm.EnableSSH, nm.ForceRoutingPeerDNSResolution)
|
||||
|
||||
pm := &proto.NetworkMap{
|
||||
Serial: nm.Network.CurrentSerial(),
|
||||
Routes: ToProtocolRoutes(nm.Routes),
|
||||
DNSConfig: networkmap.ToProtocolDNSConfig(nm.DNSConfig, dnsCache, dnsFwdPort),
|
||||
PeerConfig: peerConfig,
|
||||
}
|
||||
|
||||
remotePeers := make([]*proto.RemotePeerConfig, 0, len(nm.Peers)+len(nm.OfflinePeers))
|
||||
remotePeers = AppendRemotePeerConfig(remotePeers, nm.Peers, dnsName, includeIPv6)
|
||||
pm.RemotePeers = remotePeers
|
||||
pm.RemotePeersIsEmpty = len(remotePeers) == 0
|
||||
|
||||
pm.OfflinePeers = AppendRemotePeerConfig(nil, nm.OfflinePeers, dnsName, includeIPv6)
|
||||
|
||||
firewallRules := networkmap.ToProtocolFirewallRules(nm.FirewallRules, includeIPv6, useSourcePrefixes)
|
||||
pm.FirewallRules = firewallRules
|
||||
pm.FirewallRulesIsEmpty = len(firewallRules) == 0
|
||||
|
||||
routesFirewallRules := networkmap.ToProtocolRoutesFirewallRules(nm.RoutesFirewallRules)
|
||||
pm.RoutesFirewallRules = routesFirewallRules
|
||||
pm.RoutesFirewallRulesIsEmpty = len(routesFirewallRules) == 0
|
||||
|
||||
if nm.ForwardingRules != nil {
|
||||
forwardingRules := make([]*proto.ForwardingRule, 0, len(nm.ForwardingRules))
|
||||
for _, rule := range nm.ForwardingRules {
|
||||
forwardingRules = append(forwardingRules, rule.ToProto())
|
||||
}
|
||||
pm.ForwardingRules = forwardingRules
|
||||
}
|
||||
|
||||
if nm.AuthorizedUsers != nil {
|
||||
hashedUsers, machineUsers := networkmap.BuildAuthorizedUsersProto(ctx, nm.AuthorizedUsers)
|
||||
userIDClaim := auth.DefaultUserIDClaim
|
||||
if httpConfig != nil && httpConfig.AuthUserIDClaim != "" {
|
||||
userIDClaim = httpConfig.AuthUserIDClaim
|
||||
}
|
||||
pm.SshAuth = &proto.SSHAuth{AuthorizedUsers: hashedUsers, MachineUsers: machineUsers, UserIDClaim: userIDClaim}
|
||||
}
|
||||
|
||||
return pm
|
||||
}
|
||||
|
||||
func deriveIssuerFromTokenEndpoint(tokenEndpoint string) string {
|
||||
if tokenEndpoint == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
u, err := url.Parse(tokenEndpoint)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s://%s/", u.Scheme, u.Host)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/posture"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
func networkMapFromComponents(t *testing.T, account *types.Account, peerID string, validatedPeers map[string]struct{}) *types.NetworkMap {
|
||||
@@ -49,7 +50,7 @@ func allPeersValidated(account *types.Account, excludePeerIDs ...string) map[str
|
||||
return validated
|
||||
}
|
||||
|
||||
func peerIDs(peers []*types.ComponentPeer) []string {
|
||||
func peerIDs(peers []*nmdata.Peer) []string {
|
||||
ids := make([]string, len(peers))
|
||||
for i, p := range peers {
|
||||
ids[i] = p.ID
|
||||
@@ -625,7 +626,7 @@ func TestNetworkMapComponents_DomainNetworkResource(t *testing.T) {
|
||||
|
||||
var hasDomainRoute bool
|
||||
for _, r := range nm.Routes {
|
||||
if r.NetworkType == route.DomainNetwork && len(r.Domains) > 0 && r.Domains[0].SafeString() == "api.example.com" {
|
||||
if r.NetworkType == int(route.DomainNetwork) && len(r.Domains) > 0 && r.Domains[0].SafeString() == "api.example.com" {
|
||||
hasDomainRoute = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,3 +19,34 @@ func Difference(a, b []string) []string {
|
||||
func ToPtr[T any](value T) *T {
|
||||
return &value
|
||||
}
|
||||
|
||||
type comparableObject[T any] interface {
|
||||
Equal(other T) bool
|
||||
}
|
||||
|
||||
func MergeUnique[T comparableObject[T]](arr1, arr2 []T) []T {
|
||||
var result []T
|
||||
|
||||
for _, item := range arr1 {
|
||||
if !contains(result, item) {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range arr2 {
|
||||
if !contains(result, item) {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func contains[T comparableObject[T]](slice []T, element T) bool {
|
||||
for _, item := range slice {
|
||||
if item.Equal(element) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package types
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -17,7 +17,7 @@ func (t testObject) Equal(other testObject) bool {
|
||||
func Test_MergeUniqueArraysWithoutDuplicates(t *testing.T) {
|
||||
arr1 := []testObject{{value: 1}, {value: 2}}
|
||||
arr2 := []testObject{{value: 2}, {value: 3}}
|
||||
result := mergeUnique(arr1, arr2)
|
||||
result := MergeUnique(arr1, arr2)
|
||||
assert.Len(t, result, 3)
|
||||
assert.Contains(t, result, testObject{value: 1})
|
||||
assert.Contains(t, result, testObject{value: 2})
|
||||
@@ -27,14 +27,14 @@ func Test_MergeUniqueArraysWithoutDuplicates(t *testing.T) {
|
||||
func Test_MergeUniqueHandlesEmptyArrays(t *testing.T) {
|
||||
arr1 := []testObject{}
|
||||
arr2 := []testObject{}
|
||||
result := mergeUnique(arr1, arr2)
|
||||
result := MergeUnique(arr1, arr2)
|
||||
assert.Empty(t, result)
|
||||
}
|
||||
|
||||
func Test_MergeUniqueHandlesOneEmptyArray(t *testing.T) {
|
||||
arr1 := []testObject{{value: 1}, {value: 2}}
|
||||
arr2 := []testObject{}
|
||||
result := mergeUnique(arr1, arr2)
|
||||
result := MergeUnique(arr1, arr2)
|
||||
assert.Len(t, result, 2)
|
||||
assert.Contains(t, result, testObject{value: 1})
|
||||
assert.Contains(t, result, testObject{value: 2})
|
||||
@@ -221,21 +221,14 @@ func (l *Logger) allowDenyLog(serviceID types.ServiceID, reason string) bool {
|
||||
// proxy/internal/middleware/keys.go — only the dimensions management needs to
|
||||
// record a usage row (provider / model / tokens / cost / groups).
|
||||
var usageMetadataKeys = map[string]struct{}{
|
||||
"llm.provider": {},
|
||||
"llm.model": {},
|
||||
"llm.resolved_provider_id": {},
|
||||
"llm.input_tokens": {},
|
||||
"llm.output_tokens": {},
|
||||
"llm.total_tokens": {},
|
||||
"llm.cached_input_tokens": {},
|
||||
"llm.cache_creation_tokens": {},
|
||||
"cost.usd_input": {},
|
||||
"cost.usd_cached_input": {},
|
||||
"cost.usd_cache_creation": {},
|
||||
"cost.usd_output": {},
|
||||
"cost.usd_total": {},
|
||||
"cost.usd_cache": {},
|
||||
"llm.authorising_groups": {},
|
||||
"llm.provider": {},
|
||||
"llm.model": {},
|
||||
"llm.resolved_provider_id": {},
|
||||
"llm.input_tokens": {},
|
||||
"llm.output_tokens": {},
|
||||
"llm.total_tokens": {},
|
||||
"cost.usd_total": {},
|
||||
"llm.authorising_groups": {},
|
||||
}
|
||||
|
||||
// stripAgentNetworkEntryForUsage returns the entry reduced to what's needed to
|
||||
|
||||
@@ -56,12 +56,10 @@ type bedrockResponse struct {
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
|
||||
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
|
||||
// Converse — camelCase; cache buckets are additive to inputTokens (AWS names the write bucket cacheWriteInputTokens).
|
||||
InputTokensCamel int64 `json:"inputTokens"`
|
||||
OutputTokensCamel int64 `json:"outputTokens"`
|
||||
TotalTokensCamel int64 `json:"totalTokens"`
|
||||
CacheReadTokensCamel int64 `json:"cacheReadInputTokens"`
|
||||
CacheWriteTokensCamel int64 `json:"cacheWriteInputTokens"`
|
||||
// Converse — camelCase.
|
||||
InputTokensCamel int64 `json:"inputTokens"`
|
||||
OutputTokensCamel int64 `json:"outputTokens"`
|
||||
TotalTokensCamel int64 `json:"totalTokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
@@ -85,18 +83,16 @@ func (BedrockParser) ParseResponse(status int, contentType string, body []byte)
|
||||
}
|
||||
inTok := firstNonZero(resp.Usage.InputTokens, resp.Usage.InputTokensCamel)
|
||||
outTok := firstNonZero(resp.Usage.OutputTokens, resp.Usage.OutputTokensCamel)
|
||||
cacheRead := firstNonZero(resp.Usage.CacheReadInputTokens, resp.Usage.CacheReadTokensCamel)
|
||||
cacheWrite := firstNonZero(resp.Usage.CacheCreationInputTokens, resp.Usage.CacheWriteTokensCamel)
|
||||
total := resp.Usage.TotalTokensCamel
|
||||
if total == 0 {
|
||||
total = inTok + outTok + cacheRead + cacheWrite
|
||||
total = inTok + outTok + resp.Usage.CacheReadInputTokens + resp.Usage.CacheCreationInputTokens
|
||||
}
|
||||
return Usage{
|
||||
InputTokens: inTok,
|
||||
OutputTokens: outTok,
|
||||
TotalTokens: total,
|
||||
CachedInputTokens: cacheRead,
|
||||
CacheCreationTokens: cacheWrite,
|
||||
CachedInputTokens: resp.Usage.CacheReadInputTokens,
|
||||
CacheCreationTokens: resp.Usage.CacheCreationInputTokens,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -26,18 +26,6 @@ func TestBedrockParser_ParseResponse_Converse(t *testing.T) {
|
||||
require.Equal(t, int64(14), u.TotalTokens, "converse uses provider total")
|
||||
}
|
||||
|
||||
// Converse camelCase cache fields must land in the billed Usage buckets, same as the InvokeModel snake_case fields.
|
||||
func TestBedrockParser_ParseResponse_ConverseCacheBuckets(t *testing.T) {
|
||||
body := []byte(`{"usage":{"inputTokens":11,"outputTokens":3,"cacheReadInputTokens":7,"cacheWriteInputTokens":9}}`)
|
||||
u, err := BedrockParser{}.ParseResponse(200, "application/json", body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(11), u.InputTokens, "converse input tokens")
|
||||
require.Equal(t, int64(3), u.OutputTokens, "converse output tokens")
|
||||
require.Equal(t, int64(7), u.CachedInputTokens, "converse cache-read tokens")
|
||||
require.Equal(t, int64(9), u.CacheCreationTokens, "converse cache-write tokens")
|
||||
require.Equal(t, int64(11+3+7+9), u.TotalTokens, "total backfill is additive when the provider omits totalTokens")
|
||||
}
|
||||
|
||||
func TestBedrockParser_ParseResponse_StreamingUnsupported(t *testing.T) {
|
||||
_, err := BedrockParser{}.ParseResponse(200, "application/vnd.amazon.eventstream", []byte("binary"))
|
||||
require.ErrorIs(t, err, ErrStreamingUnsupported, "event-stream must route to the streaming accumulator")
|
||||
|
||||
@@ -28,12 +28,12 @@ func TestDefaultTable_FirstPartyModelCoverage(t *testing.T) {
|
||||
"ministral-8b-latest", "mistral-embed",
|
||||
},
|
||||
"anthropic": {
|
||||
"claude-fable-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6",
|
||||
"claude-fable-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6",
|
||||
"claude-opus-4-1", "claude-sonnet-4-6", "claude-sonnet-4-5", "claude-haiku-4-5",
|
||||
},
|
||||
// bedrock keys are the normalized ids the request parser emits.
|
||||
"bedrock": {
|
||||
"anthropic.claude-opus-5", "anthropic.claude-opus-4-8", "anthropic.claude-opus-4-7", "anthropic.claude-opus-4-6",
|
||||
"anthropic.claude-opus-4-8", "anthropic.claude-opus-4-7", "anthropic.claude-opus-4-6",
|
||||
"anthropic.claude-opus-4-1", "anthropic.claude-sonnet-4-6", "anthropic.claude-sonnet-4-5",
|
||||
"anthropic.claude-haiku-4-5", "meta.llama3-3-70b-instruct",
|
||||
"amazon.nova-pro", "amazon.nova-lite", "amazon.nova-micro", "amazon.nova-2-lite",
|
||||
|
||||
@@ -180,11 +180,6 @@ anthropic:
|
||||
output_per_1k: 0.050
|
||||
cache_read_per_1k: 0.001
|
||||
cache_creation_per_1k: 0.0125
|
||||
claude-opus-5:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
claude-opus-4-8:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
@@ -241,11 +236,6 @@ bedrock:
|
||||
# eu.anthropic.claude-sonnet-4-5-20250929-v1:0 -> anthropic.claude-sonnet-4-5.
|
||||
# Anthropic-on-Bedrock keeps the additive cache buckets (read ≈0.1x input,
|
||||
# write ≈1.25x input); Nova / Llama report no cache, so cost is input+output.
|
||||
anthropic.claude-opus-5:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
cache_read_per_1k: 0.0005
|
||||
cache_creation_per_1k: 0.00625
|
||||
anthropic.claude-opus-4-8:
|
||||
input_per_1k: 0.005
|
||||
output_per_1k: 0.025
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user