mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-18 20:49:56 +00:00
* [management,proxy] Agent network: per-account LLM gateway (policy, metering, multi-provider) (#6555) * [agent-network] Shared proto, OpenAPI schema, and generated types * [agent-network] Management: store, manager, synthesizer, policy engine, provider catalog, HTTP/gRPC API Adds the account-scoped agent-network module: provider/policy/budget CRUD and store, the reverse-proxy service synthesizer, policy selection + limit enforcement, the provider catalog (incl. Vertex AI and AWS Bedrock entries), and the management HTTP + proxy gRPC surfaces. * [management] Fix agent-network proxy-peer fan-out on affected-peer recompute The affected-peers resolver loaded only persisted reverse-proxy services, but agent-network services are synthesized on demand and never persisted. As a result the embedded proxy peer was never folded into the affected set when a client's group changed, so the proxy received no network-map update for a newly authorised client and rejected its handshake until a full resync (restart). loadProxyServices now merges the synthesized agent-network services (injected via a registration hook to avoid an import cycle), so proxy peers learn newly authorised clients immediately. * [proxy] Reverse-proxy middleware framework, chain, and request plumbing The per-target middleware chain (slots, dispatcher, mutation gate, metadata merger), body capture, access-log terminal sink, and the proxy wiring that builds + runs chains for synthesized agent-network services. * [proxy] LLM parsers, pricing, and builtin middlewares (OpenAI, Anthropic, Vertex AI, AWS Bedrock) Request/response parsers and SSE/event-stream metering, the embedded pricing table, and the builtin middleware set: request parser, router, policy limit-check/record, cost meter, guardrail, identity inject, response parser. Includes the path-routed providers — Google Vertex AI (keyfile:: service-account OAuth minting) and AWS Bedrock (bearer auth, invoke/converse/streaming, optional /bedrock prefix) — plus the Models allowlist and unmeterable-publisher deny. * [proxy] IPv6 in-place apply and TCP accept-loop hardening on netstack listeners * [agent-network] End-to-end test suite, module docs, and deployment preset * [agent-network] Fix codespell typos and exclude false positives - labelgen word pool: vermillion -> vermilion, racoon -> raccoon. - codespell ignore list: add flate (Go compress/flate package), recordin (a test-local identifier), and unparseable (a valid alternative spelling used consistently across identifiers + a metadata-value constant). * [management] Set LastSeen on injected proxy peer in realstack test (MySQL strict-mode) The injected embedded proxy peer had a PeerStatus with a zero LastSeen, which serializes to '0000-00-00' and is rejected by MySQL in strict mode (SQLite tolerates it). Set LastSeen to a valid time so SaveAccount succeeds on both engines. * [agent-network] Remove e2e shell-script suite from this branch The end-to-end shell scripts under scripts/e2e/ are maintained in a separate testing suite and are not part of this change set. * [agent-network] Polish module docs: remove internal review scaffolding, fix links, verify diagrams Strip PR-review framing, commit references, absolute paths, and stale internal references from the agent-network module docs; fix broken relative links; verify all diagrams against the current architecture. Remove the internal AI-reviewer prompt file. * [management] Refine session expiration handling to support 3-state encoding for SSO deadlines * [agent-network] Relocate agentnetwork package to internals/modules Move management/server/agentnetwork (and its catalog/, labelgen/, types/ subpackages) to management/internals/modules/agentnetwork, alongside the reverse-proxy module, and rewrite all importers. Pure relocation: package names, the synthesizer + affectedpeers registration hook, and store access (shared store.Store) are unchanged, so no import cycle is introduced (affectedpeers still depends only on the agentnetwork/types leaf). * [agent-network] Co-locate HTTP handlers in the module (RegisterEndpoints) Move the agent-network HTTP handlers from server/http/handlers/agentnetwork into the module at internals/modules/agentnetwork/handlers (package handlers) and rename the entrypoint AddEndpoints -> RegisterEndpoints, matching the reverse-proxy module convention. Wiring in http/handler.go updated accordingly. * Update getting started to point to rc when agent network enabled * Add a reference to a commercial license * Fix docs localhost link * Fix docs localhost link * Add private services domain note * [management] Add agent-network telemetry metrics (#6561) Surface agent-network adoption and usage in the self-hosted metrics worker: distinct accounts, providers, policies, budget rules, accounts with log collection enabled, and aggregated input/output tokens plus cost. Tokens and cost are summed from agent_network_request_usage (the always-written per-request ledger) so the figures are accurate regardless of the log-collection toggle and carry no double-counting. All values come from a handful of indexed aggregate queries run only on the worker's periodic tick. Adds store.AgentNetworkMetrics with GetAgentNetworkMetrics on the Store interface, the SqlStore implementation, and a zero-valued FileStore stub. * Update NetBird server and proxy image versions to 0.74.0-rc.2 * [management,proxy] Reduce agent-network cognitive complexity (#6566) Address the SonarCloud quality-gate findings in new agent-network code by extracting focused helpers. No behavior change. - synthesizer.go: split buildIdentityInjectConfigJSON into per-shape rule builders; extract mergeGuardrail from mergeGuardrails to cut nesting depth. - llm_identity_inject: extract injectionEmitsAnything validation predicate from New. - llm_response_parser/streaming.go: extract applyOpenAIStreamUsage and applyAnthropicStreamUsage (via a named anthropicStreamUsage type) and simplify the OpenAI scanner loop. - reverseproxy.go: decompose ServeHTTP into serveRouteError, buildTargetContext, serveDirect, serveWithChain, captureRequestForChain, serveDeny, newResponseWriter, observeResponse, and forwardUpstream, preserving the defer ordering so response observation still reads the captured writer before it is released. * [management] Move agent-network access-log ingest into the agentnetwork module (#6568) The agent-network access-log ingest path (metaKey wire contract, flatten, usage derivation, and the dual-write of the usage ledger + settings-gated full row) lived in the reverseproxy accesslogs manager, even though the agentnetwork module already owns the rest of that domain — types, read (ListAccessLogs / GetUsageOverview), the budget-counter writes, and retention cleanup. Move it next to the rest: a stateless agentnetwork.IngestAccessLog(ctx, store, entry) that the reverseproxy SaveAccessLog delegates to when the entry is agent-network. Removes the agentNetworkTypes import from the reverseproxy manager. No behavior change; the write/read table separation is unchanged. Adds real-store coverage for the disable->enable log-collection toggle (usage ledger always written, full row gated) plus the metadata parse and group-dedup helpers, which previously had no dedicated tests. * Add session view support in the access log * [management,proxy] Container-based agent-network e2e harness (#6577) * [e2e] Add container-based agent-network e2e harness (Pillar 1) Introduce a self-contained, OIDC-free e2e harness that stands up NetBird in containers, so suites no longer depend on the hand-maintained Tilt stack or a real IdP. - harness brings up the combined server (management + signal + relay + STUN + embedded IdP) in a single container built from combined/Dockerfile.multistage, and mints an admin PAT through the unauthenticated /api/setup bootstrap (NB_SETUP_PAT_ENABLED). API access goes through the existing shared/management/client/rest typed client. - the image is built via the docker CLI (BuildKit) so the Dockerfile's cache mounts are honored; testcontainers then runs the tagged image. - everything is behind the `e2e` build tag so normal builds and unit tests never pull in testcontainers. Adds BuildKit cache mounts to combined/Dockerfile.multistage so source changes recompile incrementally rather than from scratch. Pillar 1 proven by TestCombinedBootstrap: server builds, boots, mints a PAT, and the PAT authenticates a real management API call. * [e2e] Add management-side agent-network scenarios (Pillar 2) Port the API-driven agent-network scenarios from the bash suites to Go, sharing one combined server per package run (TestMain) with each test owning its resource cleanup. Drives the /api/agent-network/* endpoints through the shared REST client's NewRequest primitive with the generated api types. Scenarios: - provider lifecycle (create/get/list/delete + 404 after delete) - provider validation (missing api_key, unknown catalog id → 4xx) - settings collection-toggle round-trip with cluster/subdomain immutability - policy window floor (reject <60s enabled limit, accept at 60s) - consumption read endpoint returns an array All deterministic and dependency-free (dummy provider keys; no upstream calls), so they run headless in CI. * [e2e] Add live chat-through-proxy scenario (Pillar 3) Stand up the full agent-network data path in containers and drive a real chat-completion through the gateway: - harness: a shared docker network (combined server reachable by alias), a proxy container built from the published reverse-proxy image (NB_PROXY_PRIVATE, NB_PROXY_ALLOW_INSECURE, NB_RELAY_TRANSPORT=ws to match the combined server's WS-multiplexed relay) with a generated self-signed wildcard cert, and a netbird client container that joins via a setup key. - the combined image, proxy image, and client image default to the published rc.2 releases (overridable via NB_E2E_*_IMAGE; a bare local tag is built from source instead). Geolocation download is disabled so the server starts without external fetches. - one shared domain is used for the management exposed address, the proxy domain, and the agent-network cluster; the proxy token is minted via the server CLI (global) to match the manual install. TestChatCompletionThroughProxy provisions provider+policy+group+setup key, runs proxy+client, drives an OpenAI chat-completion through the tunnel, and asserts a 200 plus the ingested access-log row. Requires OPENAI_TOKEN (skips otherwise). The provider must be created with enabled=true explicitly — the create default is false despite the API doc. * [e2e] Run the live chat scenario across a provider matrix Replace the single-provider chat test with a data-driven matrix that runs the same scenario through every provider whose credentials are present in the environment (keys/URLs sourced from ~/.llm-keys locally, Actions secrets in CI): - OpenAI (chat), Anthropic (messages), Vercel, OpenRouter, Cloudflare (OpenAI-compatible gateways), and Bedrock (path-routed, bearer, via the messages shape) — covering both wire shapes and the gateway routing. - all providers are created enabled with a unique model string so the proxy's connect-time snapshot carries them all and model->provider routing is unambiguous (provider toggles after connect don't reconcile to a connected proxy). - the client supports both wire shapes (/v1/chat/completions and /v1/messages); Cloudflare gets the openai provider segment appended to its gateway URL. Each provider must return 200 through the tunnel and produce an ingested access-log row. Vertex is intentionally excluded from the uniform matrix: it needs a bespoke rawPredict request shape rather than the shared chat/messages path, so it warrants a dedicated scenario. * [ci] Add manual workflow for the agent-network e2e suite The e2e suite (build tag `e2e`) stands up the combined server + proxy + client in Docker and drives live chat-completions, so it is slow and needs provider credentials. Gate it out of normal CI (it already is, via the build tag) and run it on demand via workflow_dispatch. Provider scenarios skip when their secret is unset, so it degrades gracefully. * [e2e] Add Vertex to the provider matrix; run e2e on ubuntu-latest Vertex (Anthropic-on-Vertex) doesn't share the chat/messages wire shapes: the model travels in a rawPredict path and the proxy mints the service account's OAuth token. Add a Vertex client method that posts /v1/projects/<project>/locations/<region>/publishers/anthropic/models/<model>:rawPredict with the Vertex anthropic_version body, and wire it into the matrix as a path-routed provider (created without a models array). It is keyed off GOOGLE_VERTEX_SA_BASE64 + GOOGLE_VERTEX_PROJECT (region defaults to "global", model to a pinned claude snapshot, both overridable). Also bump the e2e workflow runner to ubuntu-latest and add the Vertex secrets. * Add docker/docker and docker/go-connections as direct dependencies in go.mod * [ci] Trigger agent-network e2e workflow on push to main and pull requests * [e2e] Fix proxy cert permission denied on Linux CI runners The proxy bind-mounts a temp dir of self-signed certs. MkdirTemp creates it 0700 and the key was 0600, which Docker Desktop on macOS ignores but a non-root proxy container on Linux runners cannot traverse/read, so the cert watcher failed with "open /certs/tls.crt: permission denied" and the container exited. Widen the cert dir to 0755 and write the throwaway key 0644 so the proxy uid can read the bind-mounted material. * [e2e] Build images from source by default instead of pulling rc.2 The agent-network code under test lives in this branch, so the e2e should exercise it rather than a frozen published release. Flip the harness default: combined/proxy/client are now built from their in-repo Dockerfiles (combined/Dockerfile.multistage, proxy/Dockerfile.multistage, e2e/harness/Dockerfile.client) under local tags. Pulling a published image stays available by setting NB_E2E_*_IMAGE to a registry reference. Builds now go through buildx --load so the Dockerfile cache mounts are honored and the result is loaded for testcontainers. The CI workflow adds a container-driver builder and a local layer cache (NB_E2E_BUILDX_CACHE) persisted via actions/cache, which caches the base/apt/dep-download layers across runs. The Go compile still re-runs each time, as BuildKit mount caches cannot be exported to the GitHub cache. * [e2e] Cover real providers in lifecycle + assert real consumption metering - TestProviderLifecycle now runs per available real provider (create → get → list → delete → 404) instead of a single dummy provider, exercising each catalog's create and field round-trip. Create is offline, so it stays fast and burns no provider quota; falls back to a synthetic OpenAI provider when no keys are set. - TestProvidersMatrix attaches a token limit (high caps, 60s window) to its policy, which switches on usage metering, and asserts consumption rows are recorded with positive token counts after the live traffic. Consumption is account-scoped (keyed by source group / user and window, not per provider), so the assertion is aggregate. - TestProviderValidation gains invalid-upstream and blank-name cases. Create validation is uniform across catalogs (no per-provider required-field rules), so per-provider rejection cases would be redundant. * [e2e] Assert session id propagates per provider Each matrix request now sends a unique session id as the universal x-session-id header and asserts it round-trips into that provider's access-log row. This guards the session-grouping contract end to end for every provider (header extraction runs in llm_request_parser ahead of the parser-specific body extraction, so it is provider-agnostic). * [e2e] Drop accidentally committed sync-phases dashboard netbird-sync-phases.json was swept into the Pillar 1 commit by a broad git add; it belongs to the unrelated sync-phases metrics work, not this e2e harness. Remove it from the branch so the PR diff is scoped to the e2e changes. * [e2e] Revert accidentally committed sync-phase ingest spec The netbird_sync_phase measurement spec in metrics ingest was swept into the Pillar 1 commit; it belongs to the unrelated sync-phases metrics work, not this e2e harness. Its emission side never landed here, so the spec was orphaned anyway. Restore ingest/main.go to its origin/main state. * Fix golint issues * Fix sonar * Add access log session test * Fix access log tests --------- Co-authored-by: braginini <bangvalo@gmail.com> Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com>
1125 lines
43 KiB
Go
1125 lines
43 KiB
Go
// Package affectedpeers computes which peers' network maps a change touches, so
|
||
// only those peers are refreshed instead of the whole account.
|
||
//
|
||
// Two phases keep the dependency walk off the write transaction:
|
||
// - Load: reads the needed collections. Call INSIDE the mutating tx (consistent,
|
||
// and before a delete/removal severs the old state).
|
||
// - Snapshot.Expand: in-memory walk, no store access. Run AFTER the tx commits.
|
||
//
|
||
// Enabled handling differs by source. Disabled objects in the SNAPSHOT (existing
|
||
// account policies/resources/routers/routes/proxy services and their rules/targets)
|
||
// route to nobody and are skipped — they cannot affect any peer's map. Objects in
|
||
// the CHANGE itself are processed regardless of Enabled, so disabling one still
|
||
// refreshes the peers that lose access (the toggle is the observable change, and the
|
||
// update carries the old∪new state).
|
||
package affectedpeers
|
||
|
||
import (
|
||
"context"
|
||
|
||
log "github.com/sirupsen/logrus"
|
||
|
||
nbdns "github.com/netbirdio/netbird/dns"
|
||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||
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"
|
||
"github.com/netbirdio/netbird/management/server/store"
|
||
"github.com/netbirdio/netbird/management/server/types"
|
||
"github.com/netbirdio/netbird/route"
|
||
)
|
||
|
||
// agentNetworkSynthesizer returns the account's synthesised (never-persisted)
|
||
// agent-network reverse-proxy services. It is registered at boot via
|
||
// SetAgentNetworkSynthesizer to avoid an import cycle (agentnetwork → account →
|
||
// affectedpeers). nil when agent-network is not wired, in which case only
|
||
// persisted services are considered.
|
||
var agentNetworkSynthesizer func(ctx context.Context, s store.Store, accountID string) ([]*rpservice.Service, error)
|
||
|
||
// SetAgentNetworkSynthesizer registers the agent-network service synthesiser.
|
||
// Called once during boot, before any request is served.
|
||
func SetAgentNetworkSynthesizer(fn func(ctx context.Context, s store.Store, accountID string) ([]*rpservice.Service, error)) {
|
||
agentNetworkSynthesizer = fn
|
||
}
|
||
|
||
// Snapshot is an in-memory view of the collections needed to expand a Change.
|
||
// Loaded in-tx, walked by Expand after commit. Only the collections the Change
|
||
// can touch are loaded; the rest stay nil (see Load).
|
||
type Snapshot struct {
|
||
policies []*types.Policy
|
||
routes []*route.Route
|
||
nsGroups []*nbdns.NameServerGroup
|
||
dnsSettings *types.DNSSettings
|
||
routers []*routerTypes.NetworkRouter
|
||
resources []*resourceTypes.NetworkResource
|
||
services []*rpservice.Service
|
||
proxyByCluster map[string][]string
|
||
groups map[string]*types.Group
|
||
groupPeers map[string]map[string]struct{} // groupID -> member peer IDs
|
||
}
|
||
|
||
// Load reads the collections a Change requires, inside the caller's tx. It mirrors
|
||
// Expand's walker preconditions, loading only what the change can touch.
|
||
func Load(ctx context.Context, s store.Store, accountID string, c Change) (*Snapshot, error) {
|
||
snap := &Snapshot{}
|
||
if c.isEmpty() {
|
||
return snap, nil
|
||
}
|
||
|
||
if err := snap.loadCollections(ctx, s, accountID, c); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := snap.loadGroupIndex(ctx, s, accountID); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return snap, nil
|
||
}
|
||
|
||
// loadCollections reads the policy/route/nameserver/dns/router/resource/proxy
|
||
// collections a Change can touch, gated to what the walk needs.
|
||
func (snap *Snapshot) loadCollections(ctx context.Context, s store.Store, accountID string, c Change) error {
|
||
// LinkGroups drive the same policy/route/dns walk as a changed group or peer.
|
||
hasGroupOrPeerChange := len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 || len(c.LinkGroups) > 0 || len(c.Resources) > 0
|
||
hasNetworkObject := len(c.Routers) > 0 || len(c.Resources) > 0 || len(c.Networks) > 0
|
||
// the resource<->router bridge can fire for any of these
|
||
needsRoutersResources := hasGroupOrPeerChange || len(c.PostureCheckIDs) > 0 || len(c.Policies) > 0 || hasNetworkObject
|
||
|
||
if needsRoutersResources {
|
||
if err := snap.loadPolicyRoutersResources(ctx, s, accountID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if hasGroupOrPeerChange {
|
||
if err := snap.loadRoutesAndProxy(ctx, s, accountID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if len(c.ChangedGroupIDs) > 0 || len(c.ChangedPeerIDs) > 0 || len(c.LinkGroups) > 0 {
|
||
if err := snap.loadDNS(ctx, s, accountID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// loadPolicyRoutersResources loads the policies plus the routers and resources
|
||
// the resource<->router bridge walks.
|
||
func (snap *Snapshot) loadPolicyRoutersResources(ctx context.Context, s store.Store, accountID string) error {
|
||
var err error
|
||
if snap.policies, err = s.GetAccountPolicies(ctx, store.LockingStrengthNone, accountID); err != nil {
|
||
return err
|
||
}
|
||
if snap.routers, err = s.GetNetworkRoutersByAccountID(ctx, store.LockingStrengthNone, accountID); err != nil {
|
||
return err
|
||
}
|
||
snap.resources, err = s.GetNetworkResourcesByAccountID(ctx, store.LockingStrengthNone, accountID)
|
||
return err
|
||
}
|
||
|
||
// loadRoutesAndProxy loads the routes and the embedded-proxy services index.
|
||
func (snap *Snapshot) loadRoutesAndProxy(ctx context.Context, s store.Store, accountID string) error {
|
||
var err error
|
||
if snap.routes, err = s.GetAccountRoutes(ctx, store.LockingStrengthNone, accountID); err != nil {
|
||
return err
|
||
}
|
||
return snap.loadProxyServices(ctx, s, accountID)
|
||
}
|
||
|
||
// loadDNS loads the nameserver groups and account DNS settings.
|
||
func (snap *Snapshot) loadDNS(ctx context.Context, s store.Store, accountID string) error {
|
||
var err error
|
||
if snap.nsGroups, err = s.GetAccountNameServerGroups(ctx, store.LockingStrengthNone, accountID); err != nil {
|
||
return err
|
||
}
|
||
snap.dnsSettings, err = s.GetAccountDNSSettings(ctx, store.LockingStrengthNone, accountID)
|
||
return err
|
||
}
|
||
|
||
// loadProxyServices loads the embedded-proxy cluster index, and the services only
|
||
// when the account actually has embedded proxy peers. Both the persisted
|
||
// reverse-proxy services and the synthesised agent-network services are loaded:
|
||
// agent-network services are never persisted, so without synthesising them here
|
||
// collectFromProxyServices can't fold the embedded proxy peer into the affected
|
||
// set when a client's group changes, and the proxy never learns a newly
|
||
// authorised client until it reconnects (full network-map resync).
|
||
func (snap *Snapshot) loadProxyServices(ctx context.Context, s store.Store, accountID string) error {
|
||
var err error
|
||
if snap.proxyByCluster, err = s.GetEmbeddedProxyPeerIDsByCluster(ctx, accountID); err != nil {
|
||
return err
|
||
}
|
||
if len(snap.proxyByCluster) == 0 {
|
||
return nil
|
||
}
|
||
if snap.services, err = s.GetAccountServices(ctx, store.LockingStrengthNone, accountID); err != nil {
|
||
return err
|
||
}
|
||
if agentNetworkSynthesizer == nil {
|
||
return nil
|
||
}
|
||
synth, serr := agentNetworkSynthesizer(ctx, s, accountID)
|
||
if serr != nil {
|
||
// Non-fatal: fall back to persisted services. The next full
|
||
// network-map resync still converges the proxy.
|
||
log.WithContext(ctx).Warnf("affectedpeers: synthesise agent-network services for account %s: %v", accountID, serr)
|
||
return nil
|
||
}
|
||
snap.services = append(snap.services, synth...)
|
||
return nil
|
||
}
|
||
|
||
// loadGroupIndex loads all groups (for group.Resources) and builds the
|
||
// group->member-peers index. Always needed: the bridge resolves group.Resources
|
||
// and Expand maps groups to member peers.
|
||
func (snap *Snapshot) loadGroupIndex(ctx context.Context, s store.Store, accountID string) error {
|
||
groups, err := s.GetAccountGroups(ctx, store.LockingStrengthNone, accountID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
snap.groups = make(map[string]*types.Group, len(groups))
|
||
snap.groupPeers = make(map[string]map[string]struct{}, len(groups))
|
||
for _, g := range groups {
|
||
snap.groups[g.ID] = g
|
||
members := make(map[string]struct{}, len(g.Peers))
|
||
for _, pID := range g.Peers {
|
||
members[pID] = struct{}{}
|
||
}
|
||
snap.groupPeers[g.ID] = members
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Change describes what changed in an account.
|
||
type Change struct {
|
||
ChangedGroupIDs []string
|
||
ChangedPeerIDs []string
|
||
Policies []*types.Policy
|
||
Routes []*route.Route
|
||
Routers []*routerTypes.NetworkRouter
|
||
Resources []*resourceTypes.NetworkResource
|
||
Networks []*networkTypes.Network
|
||
PostureCheckIDs []string
|
||
|
||
// DistributionGroupIDs are groups whose members are directly affected, with no
|
||
// dependency walk — the change distributes config to the groups' member peers
|
||
// only (nameserver groups, DNS DisabledManagementGroups), not through the
|
||
// policy/route reachability graph. Pass old∪new so both states refresh.
|
||
DistributionGroupIDs []string
|
||
|
||
// RemovedPeersByGroup: peers that left a group, keyed by that group. They are no
|
||
// longer in the group's member index but still lose its reachability, so they are
|
||
// folded in — but only when the group is linked (an unlinked group has no map
|
||
// impact), matching how current members are handled.
|
||
RemovedPeersByGroup map[string][]string
|
||
|
||
// OutputPeerIDs are peers folded straight into the result without seeding their
|
||
// group memberships into the walk. Use for the peer whose group membership changed:
|
||
// the peer itself must refresh, but its OTHER groups did not change, so they must
|
||
// not be walked. Contrast ChangedPeerIDs, which seeds ALL of the peer's groups
|
||
// (correct when the peer's own attributes changed, e.g. IP/status).
|
||
OutputPeerIDs []string
|
||
|
||
// LinkGroups are groups used ONLY to match policies/routes/routers and walk to the
|
||
// OPPOSITE side — they are never expanded to their own members. Use this when a
|
||
// peer's group membership changed: pass the peer in ChangedPeerIDs and its
|
||
// group(s) here. The opposite side of the policies the group participates in
|
||
// refreshes, but the group's other members (siblings) do not — nothing changed for
|
||
// them. For an intra-group policy (A→A) the opposite side IS the group, so its
|
||
// members still refresh via the opposite-side fold, exactly when they genuinely
|
||
// gain/lose the changed peer. Unlike ChangedGroupIDs, a LinkGroup is not added to
|
||
// the output, so a one-sided membership change never wakes the whole group.
|
||
LinkGroups []string
|
||
}
|
||
|
||
func (c Change) isEmpty() bool {
|
||
return len(c.ChangedGroupIDs) == 0 &&
|
||
len(c.ChangedPeerIDs) == 0 &&
|
||
len(c.Policies) == 0 &&
|
||
len(c.Routes) == 0 &&
|
||
len(c.Routers) == 0 &&
|
||
len(c.Resources) == 0 &&
|
||
len(c.Networks) == 0 &&
|
||
len(c.PostureCheckIDs) == 0 &&
|
||
len(c.DistributionGroupIDs) == 0 &&
|
||
len(c.RemovedPeersByGroup) == 0 &&
|
||
len(c.LinkGroups) == 0 &&
|
||
len(c.OutputPeerIDs) == 0
|
||
}
|
||
|
||
// Expand returns the deduplicated affected peer IDs from the preloaded Snapshot,
|
||
// no store access. Run after the producing tx commits. Logs the full walk at
|
||
// trace level for diagnosing a miscalculation.
|
||
func (snap *Snapshot) Expand(ctx context.Context, accountID string, c Change) []string {
|
||
if c.isEmpty() {
|
||
return nil
|
||
}
|
||
r := newResolver(ctx, snap, accountID, c)
|
||
log.WithContext(ctx).Tracef("affectedpeers expand start: account=%s changedGroups=%v changedPeers=%v linkGroups=%v policies=%d routes=%d routers=%d resources=%d networks=%d postureChecks=%v distributionGroups=%v",
|
||
accountID, c.ChangedGroupIDs, c.ChangedPeerIDs, c.LinkGroups, len(c.Policies), len(c.Routes), len(c.Routers), len(c.Resources), len(c.Networks), c.PostureCheckIDs, c.DistributionGroupIDs)
|
||
r.walk()
|
||
return r.expand()
|
||
}
|
||
|
||
// Collect returns the affected group and direct-peer IDs without expanding groups
|
||
// to members. Test-only introspection; use Resolve otherwise.
|
||
func Collect(ctx context.Context, s store.Store, accountID string, c Change) (groupIDs []string, directPeerIDs []string) {
|
||
if c.isEmpty() {
|
||
return nil, nil
|
||
}
|
||
snap, err := Load(ctx, s, accountID, c)
|
||
if err != nil {
|
||
log.WithContext(ctx).Errorf("failed to load snapshot for affected peers collect: %v", err)
|
||
return nil, nil
|
||
}
|
||
r := newResolver(ctx, snap, accountID, c)
|
||
r.walk()
|
||
return setToSlice(r.affectedGroups), setToSlice(r.affectedPeers)
|
||
}
|
||
|
||
func newResolver(ctx context.Context, snap *Snapshot, accountID string, c Change) *resolver {
|
||
r := &resolver{
|
||
ctx: ctx,
|
||
snap: snap,
|
||
accountID: accountID,
|
||
change: c,
|
||
linkGroups: toSet(c.ChangedGroupIDs),
|
||
outputGroups: toSet(c.ChangedGroupIDs),
|
||
changedPeers: toSet(c.ChangedPeerIDs),
|
||
affectedGroups: make(map[string]struct{}),
|
||
affectedPeers: make(map[string]struct{}),
|
||
}
|
||
// LinkGroups match policies/routes to find the opposite side but are NOT output:
|
||
// they go into linkGroups only, never outputGroups, so their members never fold in.
|
||
addAll(r.linkGroups, c.LinkGroups)
|
||
// Resolve each changed peer to its groups here so callers pass only ChangedPeerIDs.
|
||
r.seedChangedGroupsFromPeers()
|
||
return r
|
||
}
|
||
|
||
// seedChangedGroupsFromPeers adds each changed peer's groups to linkGroups so
|
||
// the group-driven walkers fire for memberships, not just direct peer references.
|
||
// These seeded groups are for MATCHING only — folding the changed entity's own
|
||
// side is gated on outputGroups (the caller-reported groups), so a seeded group
|
||
// never folds its whole membership; only the changed peer itself folds in.
|
||
func (r *resolver) seedChangedGroupsFromPeers() {
|
||
if len(r.changedPeers) == 0 {
|
||
return
|
||
}
|
||
for groupID, members := range r.snap.groupPeers {
|
||
for pID := range r.changedPeers {
|
||
if _, ok := members[pID]; ok {
|
||
r.linkGroups[groupID] = struct{}{}
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// policySide selects which side of a policy rule to walk.
|
||
type policySide int
|
||
|
||
const (
|
||
sideSource policySide = iota
|
||
sideDestination
|
||
)
|
||
|
||
func (s policySide) opposite() policySide {
|
||
if s == sideSource {
|
||
return sideDestination
|
||
}
|
||
return sideSource
|
||
}
|
||
|
||
// walk resolves affected peers in two buckets, by how far each change propagates.
|
||
//
|
||
// BOTH-SIDES — the rule itself changed (an explicit policy edit, or a policy whose
|
||
// posture check changed). Source AND destination refresh, so each such policy is
|
||
// walked on both sides.
|
||
//
|
||
// OPPOSITE-SIDE — an endpoint moved but no rule changed. For each policy the change
|
||
// touches we fold only the side AWAY from the change:
|
||
// - a changed peer/group sits ON a policy side -> fold the opposite side;
|
||
// - a changed router/resource/network sits on a NETWORK -> fold the SOURCE side of
|
||
// the policies whose destination reaches it (and the routers it implies).
|
||
//
|
||
// Routes, nameserver groups, DNS and embedded-proxy services distribute to their own
|
||
// member peers, outside the policy graph, and are folded here too.
|
||
func (r *resolver) walk() {
|
||
for _, policy := range r.bothSidesPolicies() {
|
||
r.foldPolicySide(policy, sideSource)
|
||
r.foldPolicySide(policy, sideDestination)
|
||
}
|
||
|
||
if len(r.linkGroups) > 0 || len(r.changedPeers) > 0 {
|
||
r.collectFromPolicies()
|
||
r.collectFromRoutes()
|
||
r.collectFromNameServers()
|
||
r.collectFromDNSSettings()
|
||
r.collectFromNetworkRouters()
|
||
r.collectFromProxyServices()
|
||
}
|
||
|
||
r.collectFromChangedRoutes(r.change.Routes)
|
||
r.collectFromChangedRouters(r.change.Routers)
|
||
r.collectFromChangedResources(r.change.Resources)
|
||
r.collectFromChangedNetworks(r.change.Networks)
|
||
|
||
// The explicitly changed peers always refresh their own maps. OnPeersUpdated only
|
||
// refreshes the resolver's output (it ignores the separately-passed changed peers),
|
||
// so the changed peer reaches its own new map only via here. An offline/deleted
|
||
// peer in the set is filtered downstream (filterConnectedAffectedPeers).
|
||
addAll(r.affectedPeers, setToSlice(r.changedPeers))
|
||
// OutputPeerIDs refresh themselves too, but unlike changedPeers their group
|
||
// memberships were not seeded into the walk (only the changed group was).
|
||
addAll(r.affectedPeers, r.change.OutputPeerIDs)
|
||
|
||
// Distribution groups (nameserver/DNS) affect only their member peers: fold them
|
||
// straight into affectedGroups so expand() maps them to members, without the
|
||
// policy/route walk that linkGroups would trigger.
|
||
addAll(r.affectedGroups, r.change.DistributionGroupIDs)
|
||
}
|
||
|
||
// bothSidesPolicies are the policies whose rule changed: the explicitly edited ones
|
||
// plus those gated by a changed posture check. walk folds both their sides.
|
||
func (r *resolver) bothSidesPolicies() []*types.Policy {
|
||
policies := append([]*types.Policy(nil), r.change.Policies...)
|
||
return r.appendPoliciesForPostureChecks(policies, r.change.PostureCheckIDs)
|
||
}
|
||
|
||
type resolver struct {
|
||
ctx context.Context
|
||
snap *Snapshot
|
||
accountID string
|
||
change Change
|
||
|
||
// Inputs — what changed. Set once at construction, read-only during the walk
|
||
// (except linkGroups, which collectFromExplicitResources also seeds).
|
||
//
|
||
// linkGroups is the MATCH set: caller-changed groups ∪ the groups of changed
|
||
// peers ∪ changed-resource groups. A rule/route/router matches the change when
|
||
// one of its groups is here — used only to find the opposite side to fold.
|
||
//
|
||
// outputGroups is the FOLD-WHOLE-GROUP set: ONLY Change.ChangedGroupIDs. When a
|
||
// matched group is here, its whole membership is affected. A peer-seeded group
|
||
// is in linkGroups but NOT outputGroups, so it folds only the changed peer
|
||
// (changedPeers), never its siblings.
|
||
linkGroups map[string]struct{}
|
||
outputGroups map[string]struct{}
|
||
changedPeers map[string]struct{}
|
||
|
||
// Outputs — the answer. The only sets the walk accumulates into. affectedGroups
|
||
// is expanded to its member peers in expand().
|
||
affectedGroups map[string]struct{}
|
||
affectedPeers map[string]struct{}
|
||
}
|
||
|
||
// policies returns the account's ENABLED policies from the snapshot. Disabled
|
||
// policies grant no access, so the walk skips them when scanning existing account
|
||
// data. Explicitly changed policies (Change.Policies, via bothSidesPolicies) are
|
||
// processed regardless of Enabled, so disabling one still refreshes its peers.
|
||
func (r *resolver) policies() []*types.Policy {
|
||
enabled := make([]*types.Policy, 0, len(r.snap.policies))
|
||
for _, policy := range r.snap.policies {
|
||
if policy != nil && policy.Enabled {
|
||
enabled = append(enabled, policy)
|
||
}
|
||
}
|
||
return enabled
|
||
}
|
||
|
||
// networkResources / networkRouters return the account's ENABLED resources/routers
|
||
// from the snapshot. Disabled objects route to nobody, so the walk skips them when
|
||
// it scans existing account data. The explicitly changed objects in the Change are
|
||
// processed regardless of Enabled (collectFromChanged*), so disabling one still
|
||
// refreshes the peers that lose access.
|
||
func (r *resolver) networkResources() []*resourceTypes.NetworkResource {
|
||
enabled := make([]*resourceTypes.NetworkResource, 0, len(r.snap.resources))
|
||
for _, resource := range r.snap.resources {
|
||
if resource.Enabled {
|
||
enabled = append(enabled, resource)
|
||
}
|
||
}
|
||
return enabled
|
||
}
|
||
|
||
func (r *resolver) networkRouters() []*routerTypes.NetworkRouter {
|
||
enabled := make([]*routerTypes.NetworkRouter, 0, len(r.snap.routers))
|
||
for _, router := range r.snap.routers {
|
||
if router.Enabled {
|
||
enabled = append(enabled, router)
|
||
}
|
||
}
|
||
return enabled
|
||
}
|
||
|
||
// peerIDsForGroups maps a group set to its member peer IDs via the preloaded index.
|
||
func (r *resolver) peerIDsForGroups(groups map[string]struct{}) []string {
|
||
seen := make(map[string]struct{})
|
||
var ids []string
|
||
for gID := range groups {
|
||
for pID := range r.snap.groupPeers[gID] {
|
||
if _, ok := seen[pID]; ok {
|
||
continue
|
||
}
|
||
seen[pID] = struct{}{}
|
||
ids = append(ids, pID)
|
||
}
|
||
}
|
||
return ids
|
||
}
|
||
|
||
func (r *resolver) expand() []string {
|
||
peerIDs := r.peerIDsForGroups(r.affectedGroups)
|
||
|
||
log.WithContext(r.ctx).Tracef("affectedpeers expand: account=%s affectedGroups=%v -> %d group-member peers; direct peers=%v",
|
||
r.accountID, setToSlice(r.affectedGroups), len(peerIDs), setToSlice(r.affectedPeers))
|
||
|
||
seen := make(map[string]struct{}, len(peerIDs))
|
||
for _, id := range peerIDs {
|
||
seen[id] = struct{}{}
|
||
}
|
||
for id := range r.affectedPeers {
|
||
if _, ok := seen[id]; !ok {
|
||
peerIDs = append(peerIDs, id)
|
||
seen[id] = struct{}{}
|
||
}
|
||
}
|
||
|
||
// Fold in removed peers only when their group is linked (in affectedGroups).
|
||
for groupID, removed := range r.change.RemovedPeersByGroup {
|
||
if _, linked := r.affectedGroups[groupID]; !linked {
|
||
continue
|
||
}
|
||
for _, id := range removed {
|
||
if _, ok := seen[id]; !ok {
|
||
peerIDs = append(peerIDs, id)
|
||
seen[id] = struct{}{}
|
||
log.WithContext(r.ctx).Tracef("affectedpeers expand: removed peer %s from linked group %s -> affected", id, groupID)
|
||
}
|
||
}
|
||
}
|
||
|
||
log.WithContext(r.ctx).Tracef("affectedpeers expand done: account=%s -> %d affected peers: %v", r.accountID, len(peerIDs), peerIDs)
|
||
return peerIDs
|
||
}
|
||
|
||
// ruleSideGroups / ruleSideResource return the groups and the resource on the given
|
||
// side of a rule.
|
||
func ruleSideGroups(rule *types.PolicyRule, side policySide) []string {
|
||
if side == sideDestination {
|
||
return rule.Destinations
|
||
}
|
||
return rule.Sources
|
||
}
|
||
|
||
func ruleSideResource(rule *types.PolicyRule, side policySide) types.Resource {
|
||
if side == sideDestination {
|
||
return rule.DestinationResource
|
||
}
|
||
return rule.SourceResource
|
||
}
|
||
|
||
// foldPolicySide folds one side of a policy down to affected peers: its groups
|
||
// (resolved to members in expand) and its direct peer. When the side is the
|
||
// DESTINATION and references a network resource (directly or via a destination
|
||
// group's resources), it also folds the routers that serve that resource's network
|
||
// — a destination resource is reached through its routers. A resource on the SOURCE
|
||
// side routes to nobody (GetPoliciesForNetworkResource matches destinations only),
|
||
// so the router hop is destination-only.
|
||
func (r *resolver) foldPolicySide(policy *types.Policy, side policySide) {
|
||
if policy == nil {
|
||
return
|
||
}
|
||
for _, rule := range policy.Rules {
|
||
addAll(r.affectedGroups, ruleSideGroups(rule, side))
|
||
res := ruleSideResource(rule, side)
|
||
if res.Type == types.ResourceTypePeer && res.ID != "" {
|
||
r.affectedPeers[res.ID] = struct{}{}
|
||
}
|
||
}
|
||
if side == sideDestination {
|
||
r.foldRoutersForResources(r.policyDestinationResourceIDs(policy))
|
||
}
|
||
}
|
||
|
||
// appendPoliciesForPostureChecks appends every policy that references a changed
|
||
// posture check (a rule change, so walk both sides).
|
||
func (r *resolver) appendPoliciesForPostureChecks(policies []*types.Policy, postureCheckIDs []string) []*types.Policy {
|
||
if len(postureCheckIDs) == 0 {
|
||
return policies
|
||
}
|
||
ids := toSet(postureCheckIDs)
|
||
for _, policy := range r.policies() {
|
||
if !policyReferencesPostureChecks(policy, ids) || !policy.Enabled {
|
||
continue
|
||
}
|
||
log.WithContext(r.ctx).Tracef("appendPoliciesForPostureChecks: policy %s (%s) references changed posture checks %v -> both-sides policy",
|
||
policy.ID, policy.Name, postureCheckIDs)
|
||
policies = append(policies, policy)
|
||
}
|
||
return policies
|
||
}
|
||
|
||
// collectFromPolicies folds, for every policy whose rule a changed group or peer
|
||
// touches, only the OPPOSITE side (down to peers, incl. destination routers), plus
|
||
// the changed entity's own side: the changed group's whole membership when the
|
||
// group itself changed (outputGroups), or the changed peer alone when matched via a
|
||
// peer-seeded group (never its co-members).
|
||
func (r *resolver) collectFromPolicies() {
|
||
for _, policy := range r.policies() {
|
||
for _, rule := range policy.Rules {
|
||
if !rule.Enabled {
|
||
continue // a disabled rule grants no access
|
||
}
|
||
r.foldRuleSideIfChanged(policy, rule, sideSource)
|
||
r.foldRuleSideIfChanged(policy, rule, sideDestination)
|
||
}
|
||
}
|
||
}
|
||
|
||
// foldRuleSideIfChanged: when a changed group or direct peer sits on `side` of the
|
||
// rule, fold the opposite side fully (groups/peers + destination routers) and fold
|
||
// the changed entity's own side (the whole changed group, or the changed peer alone).
|
||
func (r *resolver) foldRuleSideIfChanged(policy *types.Policy, rule *types.PolicyRule, side policySide) {
|
||
nearGroups := ruleSideGroups(rule, side)
|
||
nearResource := ruleSideResource(rule, side)
|
||
|
||
matchedByGroup := anyInSet(nearGroups, r.linkGroups)
|
||
matchedByPeer := isDirectPeerInSet(nearResource, r.changedPeers)
|
||
if !matchedByGroup && !matchedByPeer {
|
||
return
|
||
}
|
||
|
||
// Opposite side, fully down to peers (a destination opposite also folds routers).
|
||
r.foldPolicySideForRule(policy, rule, side.opposite())
|
||
|
||
// Own side: fold the whole changed group's members only when the group itself
|
||
// changed (outputGroups). A peer-seeded or link-only group is not folded here —
|
||
// its siblings never refresh. The changed peers themselves are folded once, after
|
||
// the walk (see walk()).
|
||
for _, gID := range nearGroups {
|
||
if _, ok := r.outputGroups[gID]; ok {
|
||
r.affectedGroups[gID] = struct{}{}
|
||
}
|
||
}
|
||
|
||
// When the changed side IS a destination, the resources it targets are reached
|
||
// through their network's routers, so those routers refresh too (e.g. attaching a
|
||
// resource to a destination group, or a changed destination group/resource).
|
||
if side == sideDestination {
|
||
r.foldRoutersForResources(r.ruleDestinationResourceIDs(rule))
|
||
}
|
||
}
|
||
|
||
// foldPolicySideForRule folds one side of a single rule (groups + direct peer), and
|
||
// for a destination side the routers of that rule's destination resources.
|
||
func (r *resolver) foldPolicySideForRule(policy *types.Policy, rule *types.PolicyRule, side policySide) {
|
||
addAll(r.affectedGroups, ruleSideGroups(rule, side))
|
||
res := ruleSideResource(rule, side)
|
||
if res.Type == types.ResourceTypePeer && res.ID != "" {
|
||
r.affectedPeers[res.ID] = struct{}{}
|
||
}
|
||
if side == sideDestination {
|
||
r.foldRoutersForResources(r.ruleDestinationResourceIDs(rule))
|
||
}
|
||
}
|
||
|
||
// collectFromChangedRoutes folds an explicitly changed route's own groups and peer.
|
||
func (r *resolver) collectFromChangedRoutes(routes []*route.Route) {
|
||
for _, rt := range routes {
|
||
if rt == nil {
|
||
continue
|
||
}
|
||
log.WithContext(r.ctx).Tracef("collectFromChangedRoutes: changed route %s -> folding groups=%v peerGroups=%v accessControlGroups=%v peer=%q",
|
||
rt.ID, rt.Groups, rt.PeerGroups, rt.AccessControlGroups, rt.Peer)
|
||
addAll(r.affectedGroups, rt.Groups, rt.PeerGroups, rt.AccessControlGroups)
|
||
if rt.Peer != "" {
|
||
r.affectedPeers[rt.Peer] = struct{}{}
|
||
}
|
||
}
|
||
}
|
||
|
||
// collectFromChangedRouters: a changed router refreshes its OWN backing peer/groups
|
||
// (the changed entity) and the SOURCE side of every policy reaching a resource on
|
||
// its network (the router serves the whole network). Sibling routers on the network
|
||
// are independent and are NOT folded. Passing the old router state keeps a repointed
|
||
// router's previous backing affected without a post-commit read.
|
||
func (r *resolver) collectFromChangedRouters(routers []*routerTypes.NetworkRouter) {
|
||
for _, router := range routers {
|
||
if router == nil {
|
||
continue
|
||
}
|
||
log.WithContext(r.ctx).Tracef("collectFromChangedRouters: changed router %s on network %s -> folding its own peerGroups=%v peer=%q + sources reaching network resources",
|
||
router.ID, router.NetworkID, router.PeerGroups, router.Peer)
|
||
addAll(r.affectedGroups, router.PeerGroups)
|
||
if router.Peer != "" {
|
||
r.affectedPeers[router.Peer] = struct{}{}
|
||
}
|
||
if router.NetworkID != "" {
|
||
r.foldPolicySourcesForResources(r.networkResourceIDs(router.NetworkID))
|
||
}
|
||
}
|
||
}
|
||
|
||
// collectFromChangedResources: a changed resource refreshes the SOURCE side of the
|
||
// policies targeting EXACTLY that resource — directly, or via one of the resource's
|
||
// own groups (old∪new across the change, so a now-detached group's sources still
|
||
// refresh) — plus the routers serving its network (the resource is reached through
|
||
// them). It does not touch sibling resources on the same network.
|
||
func (r *resolver) collectFromChangedResources(resources []*resourceTypes.NetworkResource) {
|
||
for _, resource := range resources {
|
||
if resource == nil {
|
||
continue
|
||
}
|
||
log.WithContext(r.ctx).Tracef("collectFromChangedResources: changed resource %s on network %s (groups %v) -> folding sources of policies targeting it + its network's routers",
|
||
resource.ID, resource.NetworkID, resource.GroupIDs)
|
||
r.foldPolicySourcesForResource(resource.ID, resource.GroupIDs)
|
||
if resource.NetworkID != "" {
|
||
r.foldRoutersOnNetworks(map[string]struct{}{resource.NetworkID: {}})
|
||
}
|
||
}
|
||
}
|
||
|
||
// foldPolicySourcesForResource folds the source side of every policy whose
|
||
// destination is the given resource — referenced directly, or via any of the given
|
||
// groups (the resource's own old∪new groups, which captures a detached group).
|
||
func (r *resolver) foldPolicySourcesForResource(resourceID string, groupIDs []string) {
|
||
groups := toSet(groupIDs)
|
||
for _, policy := range r.policies() {
|
||
if !policyTargetsResourceOrGroups(policy, resourceID, groups) {
|
||
continue
|
||
}
|
||
log.WithContext(r.ctx).Tracef("foldPolicySourcesForResource: policy %s (%s) targets changed resource %s -> folding its source groups/peers", policy.ID, policy.Name, resourceID)
|
||
collectPolicySources(policy, r.affectedGroups, r.affectedPeers)
|
||
}
|
||
}
|
||
|
||
// policyTargetsResourceOrGroups reports whether a policy's destination is the given
|
||
// resource directly, or one of the given destination groups.
|
||
func policyTargetsResourceOrGroups(policy *types.Policy, resourceID string, groups map[string]struct{}) bool {
|
||
if policy == nil {
|
||
return false
|
||
}
|
||
for _, rule := range policy.Rules {
|
||
if !rule.Enabled {
|
||
continue
|
||
}
|
||
if rule.DestinationResource.Type != types.ResourceTypePeer && rule.DestinationResource.ID == resourceID && resourceID != "" {
|
||
return true
|
||
}
|
||
if anyInSet(rule.Destinations, groups) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// collectFromChangedNetworks: a changed network refreshes the SOURCE side of the
|
||
// policies reaching any of its resources, plus its routers. A network has no
|
||
// groups/peers of its own.
|
||
func (r *resolver) collectFromChangedNetworks(networks []*networkTypes.Network) {
|
||
for _, network := range networks {
|
||
if network == nil || network.ID == "" {
|
||
continue
|
||
}
|
||
log.WithContext(r.ctx).Tracef("collectFromChangedNetworks: changed network %s -> folding sources reaching its resources + its routers", network.ID)
|
||
resourceIDs := r.networkResourceIDs(network.ID)
|
||
r.foldPolicySourcesForResources(resourceIDs)
|
||
r.foldRoutersOnNetworks(map[string]struct{}{network.ID: {}})
|
||
}
|
||
}
|
||
|
||
// foldPolicySourcesForResources folds the source groups/peers of every policy whose
|
||
// destination targets one of resourceIDs (directly or via a destination group).
|
||
func (r *resolver) foldPolicySourcesForResources(resourceIDs map[string]struct{}) {
|
||
if len(resourceIDs) == 0 {
|
||
return
|
||
}
|
||
for _, policy := range r.policies() {
|
||
if r.policyTargetsResources(policy, resourceIDs) {
|
||
log.WithContext(r.ctx).Tracef("foldPolicySourcesForResources: policy %s (%s) targets a changed resource -> folding its source groups/peers", policy.ID, policy.Name)
|
||
collectPolicySources(policy, r.affectedGroups, r.affectedPeers)
|
||
}
|
||
}
|
||
}
|
||
|
||
// collectFromRoutes folds, per matched route, the OPPOSITE side(s) fully and the
|
||
// matched side's own groups only on a whole-group change (outputGroups). A route has
|
||
// three peer sides — routing (Peer/PeerGroups), consumer (Groups) and ACL
|
||
// (AccessControlGroups) — that each refresh the others; the changed side's own group
|
||
// folds its siblings only when the group itself changed, never on a one-peer move.
|
||
func (r *resolver) collectFromRoutes() {
|
||
for _, rt := range r.snap.routes {
|
||
if !rt.Enabled {
|
||
continue // disabled routes route to nobody; skip existing account data
|
||
}
|
||
routing := anyInSet(rt.PeerGroups, r.linkGroups) || (rt.Peer != "" && isInSet(rt.Peer, r.changedPeers))
|
||
consumer := anyInSet(rt.Groups, r.linkGroups)
|
||
acl := anyInSet(rt.AccessControlGroups, r.linkGroups)
|
||
if !routing && !consumer && !acl {
|
||
continue
|
||
}
|
||
log.WithContext(r.ctx).Tracef("collectFromRoutes: route %s matched (routing=%t consumer=%t acl=%t) -> folding opposite sides; own side gated on outputGroups",
|
||
rt.ID, routing, consumer, acl)
|
||
r.foldRouteSide(rt.PeerGroups, routing)
|
||
r.foldRouteSide(rt.Groups, consumer)
|
||
r.foldRouteSide(rt.AccessControlGroups, acl)
|
||
// The single routing Peer folds when the routing side is the OPPOSITE of the
|
||
// match (consumer/acl need it), or when that very peer is the change.
|
||
if rt.Peer != "" && (consumer || acl || isInSet(rt.Peer, r.changedPeers)) {
|
||
r.affectedPeers[rt.Peer] = struct{}{}
|
||
}
|
||
}
|
||
}
|
||
|
||
// foldRouteSide folds a route side: when this side is the one that matched, fold its
|
||
// groups only on a whole-group change (outputGroups) so siblings of a single moved
|
||
// peer stay put; otherwise it is an opposite side and folds fully.
|
||
func (r *resolver) foldRouteSide(groups []string, matchedHere bool) {
|
||
if matchedHere {
|
||
r.foldOutputGroups(groups)
|
||
return
|
||
}
|
||
addAll(r.affectedGroups, groups)
|
||
}
|
||
|
||
// foldOutputGroups folds only the groups that the caller reported as wholly changed
|
||
// (outputGroups). Used for a matched object's OWN side, where a peer-seeded or
|
||
// link-only group must not pull in its siblings.
|
||
func (r *resolver) foldOutputGroups(groups ...[]string) {
|
||
for _, gs := range groups {
|
||
for _, gID := range gs {
|
||
if _, ok := r.outputGroups[gID]; ok {
|
||
r.affectedGroups[gID] = struct{}{}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func (r *resolver) collectFromNameServers() {
|
||
if len(r.linkGroups) == 0 {
|
||
return
|
||
}
|
||
for _, ns := range r.snap.nsGroups {
|
||
if anyInSet(ns.Groups, r.linkGroups) {
|
||
// A nameserver group has no opposite side: a peer's DNS config depends only
|
||
// on its own membership, so a one-peer move refreshes that peer alone (folded
|
||
// elsewhere). Fold the referenced groups only on a whole-group change.
|
||
log.WithContext(r.ctx).Tracef("collectFromNameServers: nameserver group %s references a linked group -> folding its groups %v (outputGroups only)", ns.ID, ns.Groups)
|
||
r.foldOutputGroups(ns.Groups)
|
||
}
|
||
}
|
||
}
|
||
|
||
func (r *resolver) collectFromDNSSettings() {
|
||
if len(r.linkGroups) == 0 || r.snap.dnsSettings == nil {
|
||
return
|
||
}
|
||
for _, gID := range r.snap.dnsSettings.DisabledManagementGroups {
|
||
if _, ok := r.linkGroups[gID]; ok {
|
||
log.WithContext(r.ctx).Tracef("collectFromDNSSettings: changed group %s is in DisabledManagementGroups -> folding it", gID)
|
||
r.affectedGroups[gID] = struct{}{}
|
||
}
|
||
}
|
||
}
|
||
|
||
// collectFromNetworkRouters handles a changed group/peer that BACKS a router (the
|
||
// routing peer set moved): the router's own peers refresh and so do the sources of
|
||
// the policies reaching its network's resources. Sibling routers on the network are
|
||
// independent and are not folded.
|
||
func (r *resolver) collectFromNetworkRouters() {
|
||
for _, router := range r.networkRouters() {
|
||
matchedByGroup := anyInSet(router.PeerGroups, r.linkGroups)
|
||
matchedByPeer := router.Peer != "" && len(r.changedPeers) > 0 && isInSet(router.Peer, r.changedPeers)
|
||
if !matchedByGroup && !matchedByPeer {
|
||
continue
|
||
}
|
||
log.WithContext(r.ctx).Tracef("collectFromNetworkRouters: router %s on network %s matched (byGroup=%t byPeer=%t) -> folding its peerGroups=%v peer=%q (own groups on outputGroups) + sources reaching network resources",
|
||
router.ID, router.NetworkID, matchedByGroup, matchedByPeer, router.PeerGroups, router.Peer)
|
||
// The backing PeerGroups are the matched (own) side: fold them only on a
|
||
// whole-group change so a one-peer move does not wake sibling backing peers. The
|
||
// opposite side (policy sources reaching the network) is folded below.
|
||
r.foldOutputGroups(router.PeerGroups)
|
||
if router.Peer != "" {
|
||
r.affectedPeers[router.Peer] = struct{}{}
|
||
}
|
||
if router.NetworkID != "" {
|
||
r.foldPolicySourcesForResources(r.networkResourceIDs(router.NetworkID))
|
||
}
|
||
}
|
||
}
|
||
|
||
func (r *resolver) collectFromProxyServices() {
|
||
if len(r.snap.proxyByCluster) == 0 || len(r.snap.services) == 0 {
|
||
return
|
||
}
|
||
services, proxyByCluster := r.snap.services, r.snap.proxyByCluster
|
||
|
||
expanded := r.expandChangedPeersWithGroups()
|
||
|
||
for _, svc := range services {
|
||
if svc == nil || !svc.Enabled {
|
||
continue // a disabled service proxies nothing; skip existing account data
|
||
}
|
||
proxyPeers := proxyByCluster[svc.ProxyCluster]
|
||
if len(proxyPeers) == 0 {
|
||
continue
|
||
}
|
||
matchedByPeer := serviceMatchesChangedPeers(svc, proxyPeers, expanded)
|
||
matchedByAccessGroup := anyInSet(svc.AccessGroups, r.linkGroups)
|
||
if !matchedByPeer && !matchedByAccessGroup {
|
||
continue
|
||
}
|
||
log.WithContext(r.ctx).Tracef("collectFromProxyServices: service %s (cluster=%s) matched (byProxyOrTargetPeer=%t byAccessGroup=%t) -> folding %d proxy peers, peer targets; access groups %v on outputGroups only",
|
||
svc.ID, svc.ProxyCluster, matchedByPeer, matchedByAccessGroup, len(proxyPeers), svc.AccessGroups)
|
||
for _, pid := range proxyPeers {
|
||
r.affectedPeers[pid] = struct{}{}
|
||
}
|
||
for _, target := range svc.Targets {
|
||
if !target.Enabled {
|
||
continue // a disabled target forwards nothing
|
||
}
|
||
if target.TargetType == rpservice.TargetTypePeer && target.TargetId != "" {
|
||
r.affectedPeers[target.TargetId] = struct{}{}
|
||
}
|
||
}
|
||
// AccessGroups are the matched (own) side with no opposite to fold: a member's
|
||
// proxy access is self-contained, so a one-peer move refreshes that peer alone.
|
||
// Fold the groups only on a whole-group change.
|
||
r.foldOutputGroups(svc.AccessGroups)
|
||
}
|
||
}
|
||
|
||
func (r *resolver) expandChangedPeersWithGroups() map[string]struct{} {
|
||
if len(r.linkGroups) == 0 {
|
||
return r.changedPeers
|
||
}
|
||
ids := r.peerIDsForGroups(r.linkGroups)
|
||
if len(ids) == 0 {
|
||
return r.changedPeers
|
||
}
|
||
merged := make(map[string]struct{}, len(r.changedPeers)+len(ids))
|
||
for id := range r.changedPeers {
|
||
merged[id] = struct{}{}
|
||
}
|
||
for _, id := range ids {
|
||
merged[id] = struct{}{}
|
||
}
|
||
return merged
|
||
}
|
||
|
||
// foldRoutersForResources folds the routers serving the networks of the given
|
||
// resources (a destination resource is reached through its network's routers). It is
|
||
// the resource -> network -> router hop used by foldPolicySide for a destination.
|
||
func (r *resolver) foldRoutersForResources(resourceIDs map[string]struct{}) {
|
||
if len(resourceIDs) == 0 {
|
||
return
|
||
}
|
||
r.foldRoutersOnNetworks(r.resourceNetworkIDs(resourceIDs))
|
||
}
|
||
|
||
// ruleDestinationResourceIDs returns the destination resource IDs of a single rule:
|
||
// the direct DestinationResource plus the resources of its destination groups.
|
||
func (r *resolver) ruleDestinationResourceIDs(rule *types.PolicyRule) map[string]struct{} {
|
||
resourceIDs := make(map[string]struct{})
|
||
if rule.DestinationResource.Type != types.ResourceTypePeer && rule.DestinationResource.ID != "" {
|
||
resourceIDs[rule.DestinationResource.ID] = struct{}{}
|
||
}
|
||
r.addGroupResourceIDs(toSet(rule.Destinations), resourceIDs)
|
||
return resourceIDs
|
||
}
|
||
|
||
// networkResourceIDs returns the IDs of all resources on the given network.
|
||
func (r *resolver) networkResourceIDs(networkID string) map[string]struct{} {
|
||
resourceIDs := make(map[string]struct{})
|
||
for _, resource := range r.networkResources() {
|
||
if resource.NetworkID == networkID {
|
||
resourceIDs[resource.ID] = struct{}{}
|
||
}
|
||
}
|
||
return resourceIDs
|
||
}
|
||
|
||
func (r *resolver) foldRoutersOnNetworks(networkIDs map[string]struct{}) {
|
||
for _, router := range r.networkRouters() {
|
||
if _, ok := networkIDs[router.NetworkID]; !ok {
|
||
continue
|
||
}
|
||
log.WithContext(r.ctx).Tracef("bridgeRoutersToSources: router %s serves affected network %s -> folding peerGroups=%v peer=%q",
|
||
router.ID, router.NetworkID, router.PeerGroups, router.Peer)
|
||
addAll(r.affectedGroups, router.PeerGroups)
|
||
if router.Peer != "" {
|
||
r.affectedPeers[router.Peer] = struct{}{}
|
||
}
|
||
}
|
||
}
|
||
|
||
func (r *resolver) resourceNetworkIDs(resourceIDs map[string]struct{}) map[string]struct{} {
|
||
networkIDs := make(map[string]struct{})
|
||
for _, resource := range r.networkResources() {
|
||
if _, ok := resourceIDs[resource.ID]; ok {
|
||
networkIDs[resource.NetworkID] = struct{}{}
|
||
}
|
||
}
|
||
return networkIDs
|
||
}
|
||
|
||
func (r *resolver) policyTargetsResources(policy *types.Policy, resourceIDs map[string]struct{}) bool {
|
||
if policy == nil {
|
||
return false
|
||
}
|
||
destGroupSet := make(map[string]struct{})
|
||
for _, rule := range policy.Rules {
|
||
if !rule.Enabled {
|
||
continue
|
||
}
|
||
if rule.DestinationResource.Type != types.ResourceTypePeer && isInSet(rule.DestinationResource.ID, resourceIDs) {
|
||
return true
|
||
}
|
||
for _, gID := range rule.Destinations {
|
||
destGroupSet[gID] = struct{}{}
|
||
}
|
||
}
|
||
if len(destGroupSet) == 0 {
|
||
return false
|
||
}
|
||
for gID := range destGroupSet {
|
||
group := r.snap.groups[gID]
|
||
if group == nil {
|
||
continue
|
||
}
|
||
for _, res := range group.Resources {
|
||
if isInSet(res.ID, resourceIDs) {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (r *resolver) policyDestinationResourceIDs(policies ...*types.Policy) map[string]struct{} {
|
||
resourceIDs := make(map[string]struct{})
|
||
destGroupSet := collectPolicyDestinations(resourceIDs, policies...)
|
||
r.addGroupResourceIDs(destGroupSet, resourceIDs)
|
||
return resourceIDs
|
||
}
|
||
|
||
// collectPolicyDestinations adds direct destination resource IDs to resourceIDs and
|
||
// returns the referenced destination group IDs.
|
||
func collectPolicyDestinations(resourceIDs map[string]struct{}, policies ...*types.Policy) map[string]struct{} {
|
||
destGroupSet := make(map[string]struct{})
|
||
for _, policy := range policies {
|
||
if policy == nil {
|
||
continue
|
||
}
|
||
for _, rule := range policy.Rules {
|
||
addAll(destGroupSet, rule.Destinations)
|
||
if rule.DestinationResource.Type != types.ResourceTypePeer && rule.DestinationResource.ID != "" {
|
||
resourceIDs[rule.DestinationResource.ID] = struct{}{}
|
||
}
|
||
}
|
||
}
|
||
return destGroupSet
|
||
}
|
||
|
||
// addGroupResourceIDs folds the resource IDs of the given groups into resourceIDs.
|
||
func (r *resolver) addGroupResourceIDs(groupIDs map[string]struct{}, resourceIDs map[string]struct{}) {
|
||
for gID := range groupIDs {
|
||
group := r.snap.groups[gID]
|
||
if group == nil {
|
||
continue
|
||
}
|
||
for _, res := range group.Resources {
|
||
if res.ID != "" {
|
||
resourceIDs[res.ID] = struct{}{}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// collectPolicySources folds the source groups/peers of a snapshot policy's enabled
|
||
// rules (a disabled rule grants no access).
|
||
func collectPolicySources(policy *types.Policy, groups, peers map[string]struct{}) {
|
||
for _, rule := range policy.Rules {
|
||
if !rule.Enabled {
|
||
continue
|
||
}
|
||
addAll(groups, rule.Sources)
|
||
if rule.SourceResource.Type == types.ResourceTypePeer && rule.SourceResource.ID != "" {
|
||
peers[rule.SourceResource.ID] = struct{}{}
|
||
}
|
||
}
|
||
}
|
||
|
||
func policyReferencesPostureChecks(policy *types.Policy, ids map[string]struct{}) bool {
|
||
for _, id := range policy.SourcePostureChecks {
|
||
if _, ok := ids[id]; ok {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func isDirectPeerInSet(res types.Resource, set map[string]struct{}) bool {
|
||
if res.Type != types.ResourceTypePeer || res.ID == "" {
|
||
return false
|
||
}
|
||
_, ok := set[res.ID]
|
||
return ok
|
||
}
|
||
|
||
func serviceMatchesChangedPeers(svc *rpservice.Service, proxyPeers []string, changedPeers map[string]struct{}) bool {
|
||
for _, pid := range proxyPeers {
|
||
if _, ok := changedPeers[pid]; ok {
|
||
return true
|
||
}
|
||
}
|
||
for _, target := range svc.Targets {
|
||
if !target.Enabled || target.TargetType != rpservice.TargetTypePeer || target.TargetId == "" {
|
||
continue
|
||
}
|
||
if _, ok := changedPeers[target.TargetId]; ok {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func anyInSet(ids []string, set map[string]struct{}) bool {
|
||
for _, id := range ids {
|
||
if _, ok := set[id]; ok {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func isInSet(id string, set map[string]struct{}) bool {
|
||
_, ok := set[id]
|
||
return ok
|
||
}
|
||
|
||
func addAll(set map[string]struct{}, slices ...[]string) {
|
||
for _, s := range slices {
|
||
for _, id := range s {
|
||
set[id] = struct{}{}
|
||
}
|
||
}
|
||
}
|
||
|
||
func toSet(ids []string) map[string]struct{} {
|
||
set := make(map[string]struct{}, len(ids))
|
||
for _, id := range ids {
|
||
set[id] = struct{}{}
|
||
}
|
||
return set
|
||
}
|
||
|
||
func setToSlice(set map[string]struct{}) []string {
|
||
s := make([]string, 0, len(set))
|
||
for id := range set {
|
||
s = append(s, id)
|
||
}
|
||
return s
|
||
}
|