From cb7ca8ef3f02cf747d35412362598c57784eb79c Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:50:44 +0200 Subject: [PATCH 01/33] [client,management] Skip route firewall rule computation when no firewall (#7624) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [client,management] Skip route firewall rule computation when no firewall A peer that runs with the firewall disabled has no ACL manager and no firewall to program, so nothing ever reads RoutesFirewallRules: the only consumers are acl.Manager, which is reached solely when e.acl is set, and the legacy-management probe in updateNetworkMap, which is guarded by a non-nil firewall. Building those rules is the most expensive part of a sync on a peer that routes many network resources. On a 15k-peer deployment a debug bundle showed getPeerNetworkResourceFirewallRules accounting for 62% of the allocations of Calculate, and Calculate for effectively all of the allocations of handleSync, which was taking 3.2s on average and holding the engine lock for the duration. Let the caller ask Calculate to leave the rules out. The client passes its existing DisableFirewall setting; the management server keeps the default and still produces them. RoutesFirewallRulesIsEmpty is set from the resulting empty list, so a receiver that would otherwise infer legacy management from an empty rule set does not misread the skip. * [client,management] Cover the skip flag through the envelope Review feedback on #7624. The components test compared only the length of the peer firewall rules, so a change to their content would have passed while the message claimed they came out unchanged. Compare the slices. The skip path was also only exercised by setting the field directly on the components, which bypasses the envelope conversion where RoutesFirewallRulesIsEmpty is derived. That bit is what keeps the client from reading skipped rules as a legacy management server, so it gets a test that goes through EnvelopeToNetworkMap with the flag set. * [management] Give the router a peer ACL so the rule comparison bites Review feedback on #7624. peer-router-1 appears in no peer ACL in the shared fixture, so its FirewallRules came out empty and the equality assertion compared two empty slices — it would have passed even if the peer rules were dropped entirely. Add a policy covering the router and require the baseline to be non-empty before comparing. --- client/internal/engine.go | 6 +- .../network_map/nmaptest/runner.go | 2 +- .../types/networkmap_components_test.go | 57 ++++++++ shared/management/networkmap/envelope.go | 8 +- shared/management/networkmap/envelope_test.go | 124 ++++++++++++++++-- .../management/types/networkmap_components.go | 15 ++- 6 files changed, 199 insertions(+), 13 deletions(-) diff --git a/client/internal/engine.go b/client/internal/engine.go index d517d1d68..1613bbbc9 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -1061,7 +1061,11 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { // back to empty if the FQDN doesn't have the expected shape. dnsName = extractDNSDomainFromFQDN(pc.GetFqdn()) } - result, err := nbnetworkmap.EnvelopeToNetworkMap(e.ctx, envelope, localKey, dnsName) + // With the firewall disabled there is no ACL manager to program, so + // RoutesFirewallRules would be built and then dropped. On a peer that + // routes many network resources that is the single most expensive + // step of the sync. + result, err := nbnetworkmap.EnvelopeToNetworkMap(e.ctx, envelope, localKey, dnsName, e.config.DisableFirewall) if err != nil { return fmt.Errorf("decode network map envelope: %w", err) } diff --git a/management/internals/controllers/network_map/nmaptest/runner.go b/management/internals/controllers/network_map/nmaptest/runner.go index 0d6ac9c18..ffce6483e 100644 --- a/management/internals/controllers/network_map/nmaptest/runner.go +++ b/management/internals/controllers/network_map/nmaptest/runner.go @@ -245,7 +245,7 @@ func computeMode(t *testing.T, ctx context.Context, mode Mode, nmData *networkma peerGroups := maps.Keys(nmData.GetPeerGroups(peerID)) resp := mgmtgrpc.ToComponentSyncResponse(ctx, nil, nil, nil, peer, nil, nil, components, nil, dnsDomain, nil, nmData.AccountSettings, nil, peerGroups, dnsFwdPort) - res, err := networkmap.EnvelopeToNetworkMap(ctx, resp.NetworkMapEnvelope, peer.Key, dnsDomain) + res, err := networkmap.EnvelopeToNetworkMap(ctx, resp.NetworkMapEnvelope, peer.Key, dnsDomain, false) require.NoError(t, err, "expand envelope") return res.NetworkMap default: diff --git a/management/server/types/networkmap_components_test.go b/management/server/types/networkmap_components_test.go index f6d542609..9e76de775 100644 --- a/management/server/types/networkmap_components_test.go +++ b/management/server/types/networkmap_components_test.go @@ -175,6 +175,63 @@ func TestNetworkMapComponents_NetworkResourceRoutes_RouterPeer(t *testing.T) { assert.NotEmpty(t, nm.RoutesFirewallRules, "router peer should have route firewall rules for the resource") } +// A receiver without a firewall asks Calculate to skip the route firewall +// rules. Everything the rest of the sync consumes — routes, peers, peer +// firewall rules — must come out unchanged. +func TestNetworkMapComponents_SkipRouteFirewallRules(t *testing.T) { + ctx := context.Background() + account := createComponentTestAccount() + + // The shared fixture leaves peer-router-1 out of every peer ACL, so its + // FirewallRules would be empty and the comparison below vacuous. Give the + // router a policy of its own. + account.Policies = append(account.Policies, &types.Policy{ + ID: "policy-router", Name: "Router connectivity", Enabled: true, + Rules: []*types.PolicyRule{{ + ID: "rule-router", Name: "Allow all <-> router", Enabled: true, + Action: types.PolicyTrafficActionAccept, Protocol: types.PolicyRuleProtocolALL, + Bidirectional: true, + Sources: []string{"group-all"}, Destinations: []string{"group-all"}, + }}, + }) + + validated := allPeersValidated(account) + + components := account.GetPeerNetworkMapComponents( + ctx, + "peer-router-1", + account.GetPeersCustomZone(ctx, "netbird.io"), + nil, + validated, + account.GetResourcePoliciesMap(), + account.GetResourceRoutersMap(), + account.GetActiveGroupUsers(), + ) + + full := components.Calculate(ctx) + require.NotEmpty(t, full.RoutesFirewallRules, "baseline: router peer must get route firewall rules") + require.NotEmpty(t, full.FirewallRules, "baseline: router peer must get peer firewall rules") + + components.SkipRouteFirewallRules = true + skipped := components.Calculate(ctx) + + assert.Empty(t, skipped.RoutesFirewallRules, "route firewall rules must not be computed when skipped") + assert.ElementsMatch(t, routeNetworks(full.Routes), routeNetworks(skipped.Routes), + "skipping route firewall rules must not change the routes") + assert.ElementsMatch(t, peerIDs(full.Peers), peerIDs(skipped.Peers), + "skipping route firewall rules must not change the peers to connect") + assert.Equal(t, full.FirewallRules, skipped.FirewallRules, + "peer firewall rules are unrelated and must come out unchanged") +} + +func routeNetworks(routes []*nmdata.Route) []string { + networks := make([]string, 0, len(routes)) + for _, r := range routes { + networks = append(networks, r.Network.String()) + } + return networks +} + func TestNetworkMapComponents_NetworkResourceRoutes_UnrelatedPeer(t *testing.T) { account := createComponentTestAccount() validated := allPeersValidated(account) diff --git a/shared/management/networkmap/envelope.go b/shared/management/networkmap/envelope.go index e7961fd7b..fd9dd6bbd 100644 --- a/shared/management/networkmap/envelope.go +++ b/shared/management/networkmap/envelope.go @@ -35,7 +35,12 @@ type EnvelopeResult struct { // // dnsName is the account's DNS domain ("netbird.cloud" etc.); used when // rebuilding the per-peer FQDNs that proto.RemotePeerConfig carries. -func EnvelopeToNetworkMap(ctx context.Context, env *proto.NetworkMapEnvelope, localPeerKey, dnsName string) (*EnvelopeResult, error) { +// +// skipRouteFirewallRules leaves RoutesFirewallRules empty. Callers that have +// no firewall to program pass true: the rules are the most expensive part of +// Calculate on a peer that routes many network resources, and nothing reads +// them afterwards. +func EnvelopeToNetworkMap(ctx context.Context, env *proto.NetworkMapEnvelope, localPeerKey, dnsName string, skipRouteFirewallRules bool) (*EnvelopeResult, error) { components, err := DecodeEnvelope(ctx, env) if err != nil { return nil, fmt.Errorf("decode envelope: %w", err) @@ -53,6 +58,7 @@ func EnvelopeToNetworkMap(ctx context.Context, env *proto.NetworkMapEnvelope, lo return nil, fmt.Errorf("receiving peer (wg_key prefix %q) not found among %d decoded peers — components have no PeerID, Calculate would return empty", trimKey(localPeerKey), len(components.Peers)) } components.PeerID = canonicalKey + components.SkipRouteFirewallRules = skipRouteFirewallRules includeIPv6 := localPeer.SupportsIPv6() && localPeer.IPv6.IsValid() useSourcePrefixes := localPeer.SupportsSourcePrefixes() diff --git a/shared/management/networkmap/envelope_test.go b/shared/management/networkmap/envelope_test.go index 7fe2a5277..98333a3c8 100644 --- a/shared/management/networkmap/envelope_test.go +++ b/shared/management/networkmap/envelope_test.go @@ -9,6 +9,7 @@ import ( "net/netip" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" goproto "google.golang.org/protobuf/proto" @@ -37,7 +38,7 @@ func TestEnvelopeToNetworkMap_RoundTrip(t *testing.T) { var decoded proto.NetworkMapEnvelope require.NoError(t, goproto.Unmarshal(wire, &decoded), "unmarshal envelope") - result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud") + result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud", false) require.NoError(t, err, "EnvelopeToNetworkMap") require.NotNil(t, result) require.NotNil(t, result.NetworkMap, "decoded NetworkMap must be non-nil") @@ -78,7 +79,7 @@ func TestCalculate_FirewallRuleProtocol_NeverNetbirdSSH(t *testing.T) { var decoded proto.NetworkMapEnvelope require.NoError(t, goproto.Unmarshal(wire, &decoded)) - result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud") + result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud", false) require.NoError(t, err) require.NotEmpty(t, result.NetworkMap.FirewallRules, "ssh policy should produce firewall rules") for i, fr := range result.NetworkMap.FirewallRules { @@ -88,13 +89,13 @@ func TestCalculate_FirewallRuleProtocol_NeverNetbirdSSH(t *testing.T) { } func TestEnvelopeToNetworkMap_NilEnvelope(t *testing.T) { - _, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), nil, "key", "netbird.cloud") + _, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), nil, "key", "netbird.cloud", false) require.Error(t, err, "nil envelope must produce an error rather than panic") } func TestEnvelopeToNetworkMap_FullPayloadMissing(t *testing.T) { env := &proto.NetworkMapEnvelope{} - _, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), env, "key", "netbird.cloud") + _, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), env, "key", "netbird.cloud", false) require.Error(t, err, "envelope with no Full payload must produce an error") } @@ -126,7 +127,7 @@ func TestDecodeEnvelope_MalformedWgKeyPeerSkipped(t *testing.T) { var decoded proto.NetworkMapEnvelope require.NoError(t, goproto.Unmarshal(wire, &decoded), "unmarshal envelope") - result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud") + result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud", false) require.NoError(t, err, "EnvelopeToNetworkMap must tolerate one bad peer key") require.NotNil(t, result) require.NotNil(t, result.Components) @@ -195,7 +196,7 @@ func TestEnvelopeRoundTrip_AllGroupShortCircuitParity(t *testing.T) { var decodedEnv proto.NetworkMapEnvelope require.NoError(t, goproto.Unmarshal(wire, &decodedEnv), "unmarshal envelope") - result, err := nbnetworkmap.EnvelopeToNetworkMap(ctx, &decodedEnv, peers["peer-T"].Key, "netbird.cloud") + result, err := nbnetworkmap.EnvelopeToNetworkMap(ctx, &decodedEnv, peers["peer-T"].Key, "netbird.cloud", false) require.NoError(t, err, "EnvelopeToNetworkMap") clientNM := result.NetworkMap @@ -253,7 +254,7 @@ func TestEnvelopeToNetworkMap_EmptyComponents(t *testing.T) { var decoded proto.NetworkMapEnvelope require.NoError(t, goproto.Unmarshal(wire, &decoded), "unmarshal envelope") - result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud") + result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud", false) require.NoError(t, err, "EnvelopeToNetworkMap must degrade gracefully on empty components") require.Equal(t, uint64(7), result.NetworkMap.Serial) require.Empty(t, result.NetworkMap.RemotePeers, "unvalidated peer connects to nobody") @@ -276,7 +277,7 @@ func TestEnvelopeToNetworkMap_MissingNetwork(t *testing.T) { var decoded proto.NetworkMapEnvelope require.NoError(t, goproto.Unmarshal(wire, &decoded), "unmarshal envelope") - result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud") + result, err := nbnetworkmap.EnvelopeToNetworkMap(context.Background(), &decoded, localPeerKey, "netbird.cloud", false) require.NoError(t, err, "a missing AccountNetwork must not panic the client") require.NotNil(t, result.Components.Network) require.NotEmpty(t, result.NetworkMap.RemotePeers, "the rest of the snapshot stays usable") @@ -353,3 +354,110 @@ func randomWgKey(t *testing.T) string { require.NoError(t, err) return base64.StdEncoding.EncodeToString(raw[:]) } + +// TestEnvelopeToNetworkMap_SkipRouteFirewallRules covers the flag end to end, +// through the envelope rather than by poking Calculate directly. The +// RoutesFirewallRulesIsEmpty derivation is the part that matters: the client's +// legacy-management probe reads an empty rule list together with that bit, so +// skipping the rules must set it rather than leave it false. +func TestEnvelopeToNetworkMap_SkipRouteFirewallRules(t *testing.T) { + ctx := context.Background() + c, routerKey := buildRoutedResourceComponents(t) + + envelope := mgmtgrpc.EncodeNetworkMapEnvelope(mgmtgrpc.ComponentsEnvelopeInput{ + Components: c, + DNSDomain: "netbird.cloud", + }) + wire, err := goproto.Marshal(envelope) + require.NoError(t, err, "marshal envelope") + var decoded proto.NetworkMapEnvelope + require.NoError(t, goproto.Unmarshal(wire, &decoded), "unmarshal envelope") + + full, err := nbnetworkmap.EnvelopeToNetworkMap(ctx, &decoded, routerKey, "netbird.cloud", false) + require.NoError(t, err, "EnvelopeToNetworkMap without skip") + require.NotEmpty(t, full.NetworkMap.RoutesFirewallRules, + "baseline: the router peer must receive route firewall rules") + require.False(t, full.NetworkMap.RoutesFirewallRulesIsEmpty, + "baseline: the empty bit must be false when rules are present") + + var decodedSkip proto.NetworkMapEnvelope + require.NoError(t, goproto.Unmarshal(wire, &decodedSkip), "unmarshal envelope") + skipped, err := nbnetworkmap.EnvelopeToNetworkMap(ctx, &decodedSkip, routerKey, "netbird.cloud", true) + require.NoError(t, err, "EnvelopeToNetworkMap with skip") + + assert.Empty(t, skipped.NetworkMap.RoutesFirewallRules, + "route firewall rules must not be computed when skipped") + assert.True(t, skipped.NetworkMap.RoutesFirewallRulesIsEmpty, + "the empty bit must be derived from the skipped list, or the client misreads it as legacy management") + assert.Len(t, skipped.NetworkMap.Routes, len(full.NetworkMap.Routes), + "skipping route firewall rules must not change the routes") + assert.Len(t, skipped.NetworkMap.RemotePeers, len(full.NetworkMap.RemotePeers), + "skipping route firewall rules must not change the remote peers") +} + +// buildRoutedResourceComponents returns components in which the local peer is +// the routing peer for one enabled network resource, reachable by a second +// peer through a resource policy — the minimum shape that yields a non-empty +// RoutesFirewallRules. It also returns the local peer's WG key. +func buildRoutedResourceComponents(t *testing.T) (*types.NetworkMapComponents, string) { + t.Helper() + + routerKey := randomWgKey(t) + peers := map[string]*nmdata.Peer{ + "peer-R": { + ID: "peer-R", Key: routerKey, DNSLabel: "router", + IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}), + Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"}, + }, + "peer-S": { + ID: "peer-S", Key: randomWgKey(t), DNSLabel: "source", + IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}), + Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"}, + }, + } + + resourcePolicy := &nmdata.Policy{ + ID: "pol-res", PublicID: "10", Enabled: true, + Rules: []*nmdata.PolicyRule{{ + ID: "rule-res", + Enabled: true, + Action: string(types.PolicyTrafficActionAccept), + Protocol: string(types.PolicyRuleProtocolALL), + Sources: []string{"g-src"}, + }}, + } + + c := &types.NetworkMapComponents{ + PeerID: "peer-R", + Network: &nmdata.Network{ + Identifier: "net-routed-resource", + Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}, + Serial: 1, + }, + AccountSettings: &nmdata.AccountSettingsInfo{}, + DNSSettings: &nmdata.DNSSettings{}, + Peers: peers, + Groups: map[string]*nmdata.Group{ + "g-src": {PublicID: "1", Name: "sources", Peers: []string{"peer-S"}}, + "g-routers": {PublicID: "2", Name: "routers", Peers: []string{"peer-R"}}, + }, + NetworkResources: []*nmdata.NetworkResource{{ + ID: "res-1", NetworkID: "netid-1", PublicID: "100", Name: "res1", + Type: "subnet", + Prefix: netip.MustParsePrefix("10.200.0.0/24"), + Enabled: true, + }}, + RoutersMap: map[string]map[string]*nmdata.NetworkRouter{ + "netid-1": {"peer-R": { + PublicID: "200", PeerGroups: []string{"g-routers"}, Metric: 9999, Enabled: true, + }}, + }, + ResourcePoliciesMap: map[string][]*nmdata.Policy{ + "res-1": {resourcePolicy}, + }, + Policies: []*nmdata.Policy{resourcePolicy}, + NetworkXIDToPublicID: map[string]string{"netid-1": "1"}, + } + + return c, routerKey +} diff --git a/shared/management/types/networkmap_components.go b/shared/management/types/networkmap_components.go index e18db4ec0..7742ca244 100644 --- a/shared/management/types/networkmap_components.go +++ b/shared/management/types/networkmap_components.go @@ -58,6 +58,13 @@ type NetworkMapComponents struct { // domain targets. ForceRoutingPeerDNSResolution bool + // SkipRouteFirewallRules drops the route firewall rule computation from + // Calculate. A receiver without a firewall manager never reads + // RoutesFirewallRules, and on a routing peer with many network resources + // building them dominates the cost of a sync. Defaults to false so the + // management server keeps producing them. + SkipRouteFirewallRules bool + routesByPeerOnce sync.Once routesByPeerIdx map[string][]routeIndexEntry @@ -149,11 +156,15 @@ func (c *NetworkMapComponents) Calculate(ctx context.Context) *NetworkMap { includeIPv6 = p.SupportsIPv6() && p.IPv6.IsValid() } routesUpdate := filterAndExpandRoutes(c.getRoutesToSync(targetPeerID, peersToConnect, peerGroups), includeIPv6) - routesFirewallRules := c.getPeerRoutesFirewallRules(ctx, targetPeerID, includeIPv6) + + var routesFirewallRules []*RouteFirewallRule + if !c.SkipRouteFirewallRules { + routesFirewallRules = c.getPeerRoutesFirewallRules(ctx, targetPeerID, includeIPv6) + } isRouter, networkResourcesRoutes, sourcePeers := c.getNetworkResourcesRoutesToSync(targetPeerID) var networkResourcesFirewallRules []*RouteFirewallRule - if isRouter { + if isRouter && !c.SkipRouteFirewallRules { networkResourcesFirewallRules = c.getPeerNetworkResourceFirewallRules(ctx, targetPeerID, networkResourcesRoutes, includeIPv6) } From 40dffc69ae9b61f5fefb35a8e0c077a78e42a050 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:59:58 +0200 Subject: [PATCH 02/33] [management] record proxy version on connect (#7630) --- management/cmd/proxy/proxy.go | 27 +++++- management/cmd/proxy/proxy_test.go | 39 ++++++++ .../domain/manager/manager_realstore_test.go | 2 +- .../modules/reverseproxy/proxy/manager.go | 2 +- .../reverseproxy/proxy/manager/manager.go | 14 ++- .../proxy/manager/manager_test.go | 32 ++++++- .../reverseproxy/proxy/manager_mock.go | 8 +- .../modules/reverseproxy/proxy/proxy.go | 4 + .../service/manager/domain_validation_test.go | 2 +- management/internals/shared/grpc/proxy.go | 5 +- .../shared/grpc/proxy_connect_version_test.go | 93 +++++++++++++++++++ .../shared/grpc/validate_session_test.go | 2 +- proxy/management_integration_test.go | 2 +- 13 files changed, 214 insertions(+), 18 deletions(-) create mode 100644 management/internals/shared/grpc/proxy_connect_version_test.go diff --git a/management/cmd/proxy/proxy.go b/management/cmd/proxy/proxy.go index 73f83b3d6..1186c8d61 100644 --- a/management/cmd/proxy/proxy.go +++ b/management/cmd/proxy/proxy.go @@ -10,6 +10,7 @@ import ( "io" "strings" "text/tabwriter" + "unicode" "github.com/spf13/cobra" @@ -68,8 +69,8 @@ func runDisconnectAll(ctx context.Context, s store.Store, out io.Writer, in io.R toDisconnect := 0 w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintln(w, "ID\tCLUSTER\tIP\tACCOUNT\tSTATUS\tLAST SEEN") - _, _ = fmt.Fprintln(w, "--\t-------\t--\t-------\t------\t---------") + _, _ = fmt.Fprintln(w, "ID\tCLUSTER\tIP\tVERSION\tACCOUNT\tSTATUS\tLAST SEEN") + _, _ = fmt.Fprintln(w, "--\t-------\t--\t-------\t-------\t------\t---------") for _, p := range proxies { if p.Status != rpproxy.StatusDisconnected { @@ -80,11 +81,16 @@ func runDisconnectAll(ctx context.Context, s store.Store, out io.Writer, in io.R if p.AccountID != nil { account = *p.AccountID } + version := "-" + if p.Version != "" { + version = sanitizeReportedValue(p.Version) + } - _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", - p.ID, + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + sanitizeReportedValue(p.ID), p.ClusterAddress, p.IPAddress, + version, account, p.Status, p.LastSeen.Format("2006-01-02 15:04:05"), @@ -139,3 +145,16 @@ func confirmDisconnectAll(out io.Writer, in io.Reader) (bool, error) { return strings.EqualFold(strings.TrimSpace(scanner.Text()), disconnectAllConfirmation), nil } + +// sanitizeReportedValue replaces non-printable characters in a value the proxy +// reports about itself. Both the id and the version arrive unvalidated over +// gRPC, so a tab would forge a column, a carriage return or ANSI escape would +// redraw the operator's terminal, and U+202E would reverse the rest of the line. +func sanitizeReportedValue(s string) string { + return strings.Map(func(r rune) rune { + if unicode.IsPrint(r) { + return r + } + return '\uFFFD' + }, s) +} diff --git a/management/cmd/proxy/proxy_test.go b/management/cmd/proxy/proxy_test.go index ff0dc8119..6e3cd0c01 100644 --- a/management/cmd/proxy/proxy_test.go +++ b/management/cmd/proxy/proxy_test.go @@ -35,6 +35,7 @@ func seedProxies(t *testing.T, ctx context.Context, s store.Store) { SessionID: "session-1", ClusterAddress: "cluster-a.example.com", IPAddress: "10.0.0.1", + Version: "0.60.0", LastSeen: time.Now(), Status: rpproxy.StatusConnected, }, @@ -89,6 +90,7 @@ func TestRunDisconnectAllWithConfirmation(t *testing.T) { require.Contains(t, output, "proxy-2") require.Contains(t, output, "proxy-3") require.Contains(t, output, "cluster-a.example.com") + require.Contains(t, output, "0.60.0") require.Contains(t, output, "account-1") require.Contains(t, output, "Type \"disconnect all proxies\" to continue") require.Contains(t, output, "Force-marked 2 of 3 reverse proxy instance(s) as disconnected.") @@ -178,3 +180,40 @@ func TestRunDisconnectAllEmpty(t *testing.T) { require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader(""), false, false)) require.Contains(t, out.String(), "No reverse proxy instances found.") } + +func TestRunDisconnectAllEscapesProxyReportedFields(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + + // A proxy reports its own id and version on connect, so both reach this + // listing unvalidated. Carriage returns, tabs and ANSI escapes would let + // a malicious proxy redraw the table or forge a row on the operator's + // terminal; U+202E would reverse the rendering of the rest of the line. + require.NoError(t, s.SaveProxy(ctx, &rpproxy.Proxy{ + ID: "proxy-\r\x1b[2Kevil", + SessionID: "session-1", + ClusterAddress: "cluster-a.example.com", + IPAddress: "10.0.0.1", + Version: "0.60.0\tfake\rcolumn\u202e", + LastSeen: time.Now(), + Status: rpproxy.StatusConnected, + })) + + var out bytes.Buffer + require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader(disconnectAllConfirmation+"\n"), true, false)) + + output := out.String() + for _, forbidden := range []string{"\r", "\x1b", "\u202e"} { + require.NotContains(t, output, forbidden, "listing must not carry proxy-reported control characters") + } + // The table has one data row; a smuggled tab would add a phantom column. + var dataRow string + for _, line := range strings.Split(output, "\n") { + if strings.Contains(line, "evil") { + dataRow = line + } + } + require.NotEmpty(t, dataRow, "listing should still show the proxy row") + require.NotContains(t, dataRow, "\t", "tabwriter output should not carry a smuggled column separator") + require.Contains(t, dataRow, "0.60.0", "the printable part of the version should survive") +} diff --git a/management/internals/modules/reverseproxy/domain/manager/manager_realstore_test.go b/management/internals/modules/reverseproxy/domain/manager/manager_realstore_test.go index 5c973c40e..fed402498 100644 --- a/management/internals/modules/reverseproxy/domain/manager/manager_realstore_test.go +++ b/management/internals/modules/reverseproxy/domain/manager/manager_realstore_test.go @@ -99,7 +99,7 @@ func setupDomainTest(t *testing.T) *domainTestEnv { proxyMgr, err := proxymanager.NewManager(testStore, noop.NewMeterProvider().Meter("")) require.NoError(t, err) - _, err = proxyMgr.Connect(ctx, "proxy-1", "session-1", testCluster, "127.0.0.1", nil, nil) + _, err = proxyMgr.Connect(ctx, "proxy-1", "session-1", testCluster, "127.0.0.1", "", nil, nil) require.NoError(t, err) resolver := &stubResolver{cnames: make(map[string]string)} diff --git a/management/internals/modules/reverseproxy/proxy/manager.go b/management/internals/modules/reverseproxy/proxy/manager.go index 26214c11b..a591b86ca 100644 --- a/management/internals/modules/reverseproxy/proxy/manager.go +++ b/management/internals/modules/reverseproxy/proxy/manager.go @@ -11,7 +11,7 @@ import ( // Manager defines the interface for proxy operations type Manager interface { - Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress string, accountID *string, capabilities *Capabilities) (*Proxy, error) + Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress, version string, accountID *string, capabilities *Capabilities) (*Proxy, error) Disconnect(ctx context.Context, proxyID, sessionID string) error Heartbeat(ctx context.Context, p *Proxy) error GetActiveClusterAddresses(ctx context.Context) ([]string, error) diff --git a/management/internals/modules/reverseproxy/proxy/manager/manager.go b/management/internals/modules/reverseproxy/proxy/manager/manager.go index 943766004..edfa32aa9 100644 --- a/management/internals/modules/reverseproxy/proxy/manager/manager.go +++ b/management/internals/modules/reverseproxy/proxy/manager/manager.go @@ -50,7 +50,7 @@ func NewManager(store store, meter metric.Meter) (*Manager, error) { // Connect registers a new proxy connection in the database. // capabilities may be nil for old proxies that do not report them. -func (m *Manager) Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress string, accountID *string, capabilities *proxy.Capabilities) (*proxy.Proxy, error) { +func (m *Manager) Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress, version string, accountID *string, capabilities *proxy.Capabilities) (*proxy.Proxy, error) { now := time.Now() var caps proxy.Capabilities if capabilities != nil { @@ -61,6 +61,7 @@ func (m *Manager) Connect(ctx context.Context, proxyID, sessionID, clusterAddres SessionID: sessionID, ClusterAddress: clusterAddress, IPAddress: ipAddress, + Version: truncateVersion(version), AccountID: accountID, LastSeen: now, ConnectedAt: &now, @@ -78,6 +79,7 @@ func (m *Manager) Connect(ctx context.Context, proxyID, sessionID, clusterAddres "sessionID": sessionID, "clusterAddress": clusterAddress, "ipAddress": ipAddress, + "version": p.Version, }).Info("proxy connected") return p, nil @@ -184,3 +186,13 @@ func (m *Manager) DeleteAccountCluster(ctx context.Context, clusterAddress, acco } return nil } + +// truncateVersion cuts a proxy-reported version to the column width so an +// oversized value cannot fail the save and block the connect. +func truncateVersion(version string) string { + runes := []rune(version) + if len(runes) <= proxy.MaxVersionLength { + return version + } + return string(runes[:proxy.MaxVersionLength]) +} diff --git a/management/internals/modules/reverseproxy/proxy/manager/manager_test.go b/management/internals/modules/reverseproxy/proxy/manager/manager_test.go index 5c44470a3..d5a3ce777 100644 --- a/management/internals/modules/reverseproxy/proxy/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/proxy/manager/manager_test.go @@ -4,8 +4,10 @@ import ( "context" "errors" "fmt" + "strings" "testing" "time" + "unicode/utf8" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -124,7 +126,7 @@ func TestConnect_WithAccountID(t *testing.T) { } mgr := newTestManager(s) - _, err := mgr.Connect(context.Background(), "proxy-1", "session-1", "cluster.example.com", "10.0.0.1", &accountID, nil) + _, err := mgr.Connect(context.Background(), "proxy-1", "session-1", "cluster.example.com", "10.0.0.1", "0.60.0", &accountID, nil) require.NoError(t, err) require.NotNil(t, savedProxy) @@ -132,6 +134,7 @@ func TestConnect_WithAccountID(t *testing.T) { assert.Equal(t, "session-1", savedProxy.SessionID) assert.Equal(t, "cluster.example.com", savedProxy.ClusterAddress) assert.Equal(t, "10.0.0.1", savedProxy.IPAddress) + assert.Equal(t, "0.60.0", savedProxy.Version, "reported proxy version should be stored") assert.Equal(t, &accountID, savedProxy.AccountID) assert.Equal(t, proxy.StatusConnected, savedProxy.Status) assert.NotNil(t, savedProxy.ConnectedAt) @@ -147,7 +150,7 @@ func TestConnect_WithoutAccountID(t *testing.T) { } mgr := newTestManager(s) - _, err := mgr.Connect(context.Background(), "proxy-1", "session-1", "eu.proxy.netbird.io", "10.0.0.1", nil, nil) + _, err := mgr.Connect(context.Background(), "proxy-1", "session-1", "eu.proxy.netbird.io", "10.0.0.1", "", nil, nil) require.NoError(t, err) require.NotNil(t, savedProxy) @@ -155,6 +158,29 @@ func TestConnect_WithoutAccountID(t *testing.T) { assert.Equal(t, proxy.StatusConnected, savedProxy.Status) } +func TestConnect_TruncatesOversizedVersion(t *testing.T) { + var savedProxy *proxy.Proxy + s := &mockStore{ + saveProxyFunc: func(_ context.Context, p *proxy.Proxy) error { + savedProxy = p + return nil + }, + } + + // Multi-byte runes make sure the cut counts characters, as varchar does, + // and never splits a rune into invalid UTF-8. + version := strings.Repeat("ü", proxy.MaxVersionLength+10) + + mgr := newTestManager(s) + _, err := mgr.Connect(context.Background(), "proxy-1", "session-1", "cluster.example.com", "10.0.0.1", version, nil, nil) + require.NoError(t, err) + + require.NotNil(t, savedProxy) + assert.Equal(t, proxy.MaxVersionLength, utf8.RuneCountInString(savedProxy.Version), "stored version should be cut to the column width") + assert.True(t, utf8.ValidString(savedProxy.Version), "stored version should remain valid UTF-8") + assert.True(t, strings.HasPrefix(version, savedProxy.Version), "stored version should be a prefix of the reported one") +} + func TestConnect_StoreError(t *testing.T) { s := &mockStore{ saveProxyFunc: func(_ context.Context, _ *proxy.Proxy) error { @@ -163,7 +189,7 @@ func TestConnect_StoreError(t *testing.T) { } mgr := newTestManager(s) - _, err := mgr.Connect(context.Background(), "proxy-1", "session-1", "cluster.example.com", "10.0.0.1", nil, nil) + _, err := mgr.Connect(context.Background(), "proxy-1", "session-1", "cluster.example.com", "10.0.0.1", "", nil, nil) assert.Error(t, err) } diff --git a/management/internals/modules/reverseproxy/proxy/manager_mock.go b/management/internals/modules/reverseproxy/proxy/manager_mock.go index 36d6f53fc..ec6df8a4a 100644 --- a/management/internals/modules/reverseproxy/proxy/manager_mock.go +++ b/management/internals/modules/reverseproxy/proxy/manager_mock.go @@ -113,18 +113,18 @@ func (mr *MockManagerMockRecorder) ClusterSupportsPrivate(ctx, clusterAddr any) } // Connect mocks base method. -func (m *MockManager) Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress string, accountID *string, capabilities *Capabilities) (*Proxy, error) { +func (m *MockManager) Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress, version string, accountID *string, capabilities *Capabilities) (*Proxy, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Connect", ctx, proxyID, sessionID, clusterAddress, ipAddress, accountID, capabilities) + ret := m.ctrl.Call(m, "Connect", ctx, proxyID, sessionID, clusterAddress, ipAddress, version, accountID, capabilities) ret0, _ := ret[0].(*Proxy) ret1, _ := ret[1].(error) return ret0, ret1 } // Connect indicates an expected call of Connect. -func (mr *MockManagerMockRecorder) Connect(ctx, proxyID, sessionID, clusterAddress, ipAddress, accountID, capabilities any) *gomock.Call { +func (mr *MockManagerMockRecorder) Connect(ctx, proxyID, sessionID, clusterAddress, ipAddress, version, accountID, capabilities any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Connect", reflect.TypeOf((*MockManager)(nil).Connect), ctx, proxyID, sessionID, clusterAddress, ipAddress, accountID, capabilities) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Connect", reflect.TypeOf((*MockManager)(nil).Connect), ctx, proxyID, sessionID, clusterAddress, ipAddress, version, accountID, capabilities) } // CountAccountProxies mocks base method. diff --git a/management/internals/modules/reverseproxy/proxy/proxy.go b/management/internals/modules/reverseproxy/proxy/proxy.go index 4404b0d24..fdecc5bfd 100644 --- a/management/internals/modules/reverseproxy/proxy/proxy.go +++ b/management/internals/modules/reverseproxy/proxy/proxy.go @@ -9,6 +9,9 @@ const ( StatusDisconnected = "disconnected" ) +// MaxVersionLength is the width of the Version column, in characters. +const MaxVersionLength = 255 + // Capabilities describes what a proxy can handle, as reported via gRPC. // Nil fields mean the proxy never reported this capability. type Capabilities struct { @@ -31,6 +34,7 @@ type Proxy struct { SessionID string `gorm:"type:varchar(36)"` ClusterAddress string `gorm:"type:varchar(255);not null;index:idx_proxy_cluster_status"` IPAddress string `gorm:"type:varchar(45)"` + Version string `gorm:"type:varchar(255)"` AccountID *string `gorm:"type:varchar(255);index:idx_proxy_account_id"` LastSeen time.Time `gorm:"not null;index:idx_proxy_last_seen"` ConnectedAt *time.Time diff --git a/management/internals/modules/reverseproxy/service/manager/domain_validation_test.go b/management/internals/modules/reverseproxy/service/manager/domain_validation_test.go index ccb955cd8..6f641b73b 100644 --- a/management/internals/modules/reverseproxy/service/manager/domain_validation_test.go +++ b/management/internals/modules/reverseproxy/service/manager/domain_validation_test.go @@ -30,7 +30,7 @@ func withRealDomainManager(t *testing.T, mgr *Manager, testStore store.Store) { proxyMgr, err := proxymanager.NewManager(testStore, noop.NewMeterProvider().Meter("")) require.NoError(t, err) - _, err = proxyMgr.Connect(ctx, "proxy-1", "session-1", validationTestCluster, "127.0.0.1", nil, nil) + _, err = proxyMgr.Connect(ctx, "proxy-1", "session-1", validationTestCluster, "127.0.0.1", "", nil, nil) require.NoError(t, err) accountMgr := &mock_server.MockAccountManager{ diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go index b1527f0ab..28df7ed6f 100644 --- a/management/internals/shared/grpc/proxy.go +++ b/management/internals/shared/grpc/proxy.go @@ -414,6 +414,7 @@ func (s *ProxyServiceServer) SetProxyController(proxyController proxy.Controller type proxyConnectParams struct { proxyID string address string + version string capabilities *proto.ProxyCapabilities } @@ -424,6 +425,7 @@ func (s *ProxyServiceServer) GetMappingUpdate(req *proto.GetMappingUpdateRequest return err } params.capabilities = req.GetCapabilities() + params.version = req.GetVersion() conn, proxyRecord, err := s.registerProxyConnection(stream.Context(), params, &proxyConnection{ stream: stream, @@ -457,6 +459,7 @@ func (s *ProxyServiceServer) SyncMappings(stream proto.ProxyService_SyncMappings return err } params.capabilities = init.GetCapabilities() + params.version = init.GetVersion() conn, proxyRecord, err := s.registerProxyConnection(stream.Context(), params, &proxyConnection{ syncStream: stream, @@ -568,7 +571,7 @@ func (s *ProxyServiceServer) registerProxyConnection(ctx context.Context, params } } - proxyRecord, err := s.proxyManager.Connect(ctx, params.proxyID, sessionID, params.address, peerInfo, accountID, caps) + proxyRecord, err := s.proxyManager.Connect(ctx, params.proxyID, sessionID, params.address, peerInfo, params.version, accountID, caps) if err != nil { cancel() if accountID != nil { diff --git a/management/internals/shared/grpc/proxy_connect_version_test.go b/management/internals/shared/grpc/proxy_connect_version_test.go new file mode 100644 index 000000000..e2fc49a38 --- /dev/null +++ b/management/internals/shared/grpc/proxy_connect_version_test.go @@ -0,0 +1,93 @@ +package grpc + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/shared/management/proto" +) + +const ( + versionTestProxyID = "proxy-a" + versionTestCluster = "cluster.example.com" + versionTestVersion = "0.60.0" +) + +// hangupStream cancels its context on the first Send, emulating a proxy that +// disconnects right after receiving the initial snapshot. The legacy stream +// carries no proxy-to-management messages, so this is the only way for +// GetMappingUpdate to return. +type hangupStream struct { + recordingStream + ctx context.Context + cancel context.CancelFunc +} + +func (s *hangupStream) Send(m *proto.GetMappingUpdateResponse) error { + s.cancel() + return s.recordingStream.Send(m) +} + +func (s *hangupStream) Context() context.Context { return s.ctx } + +// newVersionTestServer wires a server whose proxy manager only accepts a +// Connect carrying versionTestVersion, so a dropped or mangled version fails +// the test as an unexpected call. +func newVersionTestServer(t *testing.T) *ProxyServiceServer { + t.Helper() + ctrl := gomock.NewController(t) + + svcMgr := rpservice.NewMockManager(ctrl) + svcMgr.EXPECT().GetGlobalServices(gomock.Any()).Return(nil, nil) + + proxyMgr := proxy.NewMockManager(ctrl) + proxyMgr.EXPECT(). + Connect(gomock.Any(), versionTestProxyID, gomock.Any(), versionTestCluster, gomock.Any(), versionTestVersion, gomock.Any(), gomock.Any()). + Return(&proxy.Proxy{ID: versionTestProxyID, Version: versionTestVersion}, nil) + proxyMgr.EXPECT().Disconnect(gomock.Any(), versionTestProxyID, gomock.Any()).Return(nil) + + s := newSnapshotTestServer(t, 10) + s.serviceManager = svcMgr + s.proxyManager = proxyMgr + return s +} + +func TestSyncMappings_ForwardsProxyVersion(t *testing.T) { + s := newVersionTestServer(t) + + // The init carries the version, the ack acknowledges the empty snapshot, + // and the exhausted fake stream then ends the RPC. + stream := &syncRecordingStream{ + recvMsgs: []*proto.SyncMappingsRequest{ + {Msg: &proto.SyncMappingsRequest_Init{Init: &proto.SyncMappingsInit{ + ProxyId: versionTestProxyID, + Address: versionTestCluster, + Version: versionTestVersion, + }}}, + ackMsg(), + }, + } + + err := s.SyncMappings(stream) + require.ErrorContains(t, err, "no more recv messages") +} + +func TestGetMappingUpdate_ForwardsProxyVersion(t *testing.T) { + s := newVersionTestServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + stream := &hangupStream{ctx: ctx, cancel: cancel} + + err := s.GetMappingUpdate(&proto.GetMappingUpdateRequest{ + ProxyId: versionTestProxyID, + Address: versionTestCluster, + Version: versionTestVersion, + }, stream) + require.ErrorIs(t, err, context.Canceled) +} diff --git a/management/internals/shared/grpc/validate_session_test.go b/management/internals/shared/grpc/validate_session_test.go index 4e70e61e4..b300e8c1d 100644 --- a/management/internals/shared/grpc/validate_session_test.go +++ b/management/internals/shared/grpc/validate_session_test.go @@ -570,7 +570,7 @@ func (m *testValidateSessionServiceManager) DeleteAccountCluster(_ context.Conte type testValidateSessionProxyManager struct{} -func (m *testValidateSessionProxyManager) Connect(_ context.Context, _, _, _, _ string, _ *string, _ *proxy.Capabilities) (*proxy.Proxy, error) { +func (m *testValidateSessionProxyManager) Connect(_ context.Context, _, _, _, _, _ string, _ *string, _ *proxy.Capabilities) (*proxy.Proxy, error) { return nil, nil } diff --git a/proxy/management_integration_test.go b/proxy/management_integration_test.go index df016e790..0e148f858 100644 --- a/proxy/management_integration_test.go +++ b/proxy/management_integration_test.go @@ -204,7 +204,7 @@ func (m *testAccessLogManager) GetAllAccessLogs(_ context.Context, _, _ string, // testProxyManager is a mock implementation of proxy.Manager for testing. type testProxyManager struct{} -func (m *testProxyManager) Connect(_ context.Context, proxyID, sessionID, _, _ string, _ *string, _ *nbproxy.Capabilities) (*nbproxy.Proxy, error) { +func (m *testProxyManager) Connect(_ context.Context, proxyID, sessionID, _, _, _ string, _ *string, _ *nbproxy.Capabilities) (*nbproxy.Proxy, error) { return &nbproxy.Proxy{ID: proxyID, SessionID: sessionID, Status: nbproxy.StatusConnected}, nil } From 7009add7a9e61cb51d373621ab5a06afe33a8b02 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:01:35 +0200 Subject: [PATCH 03/33] [management,signal,proxy] add pyroscope profiling (#7536) --- combined/cmd/root.go | 8 +- go.mod | 5 +- go.sum | 10 +- management/internals/server/server.go | 18 ++ proxy/cmd/proxy/cmd/root.go | 6 + proxy/cmd/proxy/main.go | 11 +- proxy/internal/metrics/client_metrics_test.go | 49 +++++ proxy/internal/metrics/metrics.go | 15 ++ proxy/server.go | 59 +++-- proxy/server_test.go | 20 ++ shared/lifecycle/stop_handlers.go | 57 +++++ shared/lifecycle/stop_handlers_test.go | 43 ++++ shared/profiling/profiling.go | 127 +++++++++++ shared/profiling/profiling_test.go | 202 ++++++++++++++++++ signal/cmd/run.go | 1 + signal/server/signal.go | 13 ++ 16 files changed, 614 insertions(+), 30 deletions(-) create mode 100644 proxy/internal/metrics/client_metrics_test.go create mode 100644 shared/lifecycle/stop_handlers.go create mode 100644 shared/lifecycle/stop_handlers_test.go create mode 100644 shared/profiling/profiling.go create mode 100644 shared/profiling/profiling_test.go diff --git a/combined/cmd/root.go b/combined/cmd/root.go index 917312e57..26d6ceedb 100644 --- a/combined/cmd/root.go +++ b/combined/cmd/root.go @@ -120,7 +120,7 @@ func execute(cmd *cobra.Command, _ []string) error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - err = shutdownServers(ctx, servers.relaySrv, servers.healthcheck, servers.stunServer, servers.mgmtSrv, servers.metricsServer) + err = shutdownServers(ctx, servers.relaySrv, servers.healthcheck, servers.stunServer, servers.mgmtSrv, servers.signalSrv, servers.metricsServer) wg.Wait() return err } @@ -399,7 +399,7 @@ func startServers(wg *sync.WaitGroup, srv *relayServer.Server, httpHealthcheck * } } -func shutdownServers(ctx context.Context, srv *relayServer.Server, httpHealthcheck *healthcheck.Server, stunServer *stun.Server, mgmtSrv mgmtServer.Server, metricsServer *sharedMetrics.Metrics) error { +func shutdownServers(ctx context.Context, srv *relayServer.Server, httpHealthcheck *healthcheck.Server, stunServer *stun.Server, mgmtSrv mgmtServer.Server, signalSrv *signalServer.Server, metricsServer *sharedMetrics.Metrics) error { var errs error if err := httpHealthcheck.Shutdown(ctx); err != nil { @@ -425,6 +425,10 @@ func shutdownServers(ctx context.Context, srv *relayServer.Server, httpHealthche } } + if signalSrv != nil { + signalSrv.Stop() + } + if metricsServer != nil { log.Infof("shutting down metrics server") if err := metricsServer.Shutdown(ctx); err != nil { diff --git a/go.mod b/go.mod index 8e8b7b1d4..543dcc713 100644 --- a/go.mod +++ b/go.mod @@ -40,6 +40,7 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.18.10 github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3 github.com/c-robinson/iplib v1.0.3 + github.com/caarlos0/env/v11 v11.4.1 github.com/caddyserver/certmagic v0.21.3 github.com/cilium/ebpf v0.19.0 github.com/coder/websocket v1.8.14 @@ -68,6 +69,7 @@ require ( github.com/google/gopacket v1.1.19 github.com/google/nftables v0.3.0 github.com/gopacket/gopacket v1.4.0 + github.com/grafana/pyroscope-go v1.4.2 github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.2-0.20240212192251-757544f21357 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 github.com/hashicorp/go-multierror v1.1.1 @@ -236,6 +238,7 @@ require ( github.com/googleapis/gax-go/v2 v2.21.0 // indirect github.com/goreleaser/chglog v0.7.4 // indirect github.com/gorilla/handlers v1.5.2 // indirect + github.com/grafana/pyroscope-go/godeltaprof v0.1.11 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect @@ -259,7 +262,7 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/kelseyhightower/envconfig v1.4.0 // indirect github.com/kevinburke/ssh_config v1.4.0 // indirect - github.com/klauspost/compress v1.18.3 // indirect + github.com/klauspost/compress v1.18.7 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/koron/go-ssdp v0.0.4 // indirect github.com/kr/fs v0.1.0 // indirect diff --git a/go.sum b/go.sum index 75a0f1c42..6e5fd0693 100644 --- a/go.sum +++ b/go.sum @@ -106,6 +106,8 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/c-robinson/iplib v1.0.3 h1:NG0UF0GoEsrC1/vyfX1Lx2Ss7CySWl3KqqXh3q4DdPU= github.com/c-robinson/iplib v1.0.3/go.mod h1:i3LuuFL1hRT5gFpBRnEydzw8R6yhGkF4szNDIbF8pgo= +github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= +github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/caddyserver/certmagic v0.21.3 h1:pqRRry3yuB4CWBVq9+cUqu+Y6E2z8TswbhNx1AZeYm0= github.com/caddyserver/certmagic v0.21.3/go.mod h1:Zq6pklO9nVRl3DIFUw9gVUfXKdpc/0qwTUAQMBlfgtI= github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA= @@ -327,6 +329,10 @@ github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyE github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/grafana/pyroscope-go v1.4.2 h1:0LW5HrUJXgGr9zF5gITP/HaFXN9/LsMiwlgVJAK75l0= +github.com/grafana/pyroscope-go v1.4.2/go.mod h1:Ej13Jr05rRJrjWvrrFhfh6gGYXtfibuukOs3Tl3Y7QQ= +github.com/grafana/pyroscope-go/godeltaprof v0.1.11 h1:el5LYpXissAiCKZ5/6yjlr6mhYVV6Cp5lahTocxraXM= +github.com/grafana/pyroscope-go/godeltaprof v0.1.11/go.mod h1:jl1V8M4cWsXciROCPIDDG7CtjSjT/ECbp6eLVuMxYRI= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.2-0.20240212192251-757544f21357 h1:Fkzd8ktnpOR9h47SXHe2AYPwelXLH2GjGsjlAloiWfo= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.2-0.20240212192251-757544f21357/go.mod h1:w9Y7gY31krpLmrVU5ZPG9H7l9fZuRu5/3R3S3FMtVQ4= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= @@ -413,8 +419,8 @@ github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PW github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= -github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= +github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= diff --git a/management/internals/server/server.go b/management/internals/server/server.go index a1b58fdf1..6d51745a7 100644 --- a/management/internals/server/server.go +++ b/management/internals/server/server.go @@ -23,6 +23,8 @@ import ( "github.com/netbirdio/netbird/management/server/idp" "github.com/netbirdio/netbird/management/server/metrics" "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/lifecycle" + "github.com/netbirdio/netbird/shared/profiling" "github.com/netbirdio/netbird/util/wsproxy" wsproxyserver "github.com/netbirdio/netbird/util/wsproxy/server" "github.com/netbirdio/netbird/version" @@ -36,6 +38,8 @@ const ( DefaultSelfHostedDomain = "netbird.selfhosted" ContainerKeyBaseServer = "baseServer" + + applicationName = "management" ) type Server interface { @@ -82,6 +86,8 @@ type BaseServer struct { errCh chan error wg sync.WaitGroup cancel context.CancelFunc + + lifecycle.StopHandlers } // Config holds the configuration parameters for creating a new server @@ -117,6 +123,9 @@ func NewServer(cfg *Config) *BaseServer { } s.container[ContainerKeyBaseServer] = s + stopProfiling := profiling.Start(applicationName) + s.OnStop(stopProfiling) + return s } @@ -126,6 +135,14 @@ func (s *BaseServer) AfterInit(fn func(s *BaseServer)) { // Start begins listening for HTTP requests on the configured address func (s *BaseServer) Start(ctx context.Context) error { + if err := s.start(ctx); err != nil { + s.RunStopHandlers() + return err + } + return nil +} + +func (s *BaseServer) start(ctx context.Context) error { srvCtx, cancel := context.WithCancel(ctx) s.cancel = cancel s.errCh = make(chan error, 4) @@ -278,6 +295,7 @@ func (s *BaseServer) setupTLS(ctx context.Context) (bool, error) { func (s *BaseServer) Stop() error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + defer s.RunStopHandlers() if s.domainCleanupStop != nil { s.domainCleanupStop() } diff --git a/proxy/cmd/proxy/cmd/root.go b/proxy/cmd/proxy/cmd/root.go index 9b180a5c4..765d5c05a 100644 --- a/proxy/cmd/proxy/cmd/root.go +++ b/proxy/cmd/proxy/cmd/root.go @@ -14,6 +14,7 @@ import ( "golang.org/x/crypto/acme" "github.com/netbirdio/netbird/shared/management/domain" + "github.com/netbirdio/netbird/shared/profiling" "github.com/netbirdio/netbird/client/embed" "github.com/netbirdio/netbird/proxy" @@ -30,6 +31,8 @@ const ( // how many buffers each receive/TUN worker eagerly allocates. Zero // (unset) keeps the platform default. envMaxBatchSize = "NB_PROXY_MAX_BATCH_SIZE" + + applicationName = "proxy" ) const DefaultManagementURL = "https://api.netbird.io:443" @@ -160,6 +163,9 @@ func runServer(cmd *cobra.Command, args []string) error { logger.Infof("configured log level: %s", level) + stopProfiling := profiling.Start(applicationName) + defer stopProfiling() + var wgPool, wgBatch uint64 var perf embed.Performance if raw := os.Getenv(envPreallocatedBuffers); raw != "" { diff --git a/proxy/cmd/proxy/main.go b/proxy/cmd/proxy/main.go index 16e7e8ac2..6851c6cfc 100644 --- a/proxy/cmd/proxy/main.go +++ b/proxy/cmd/proxy/main.go @@ -4,6 +4,7 @@ import ( "net/http" // nolint:gosec _ "net/http/pprof" + "os" "runtime" log "github.com/sirupsen/logrus" @@ -26,9 +27,13 @@ var ( ) func main() { - go func() { - log.Println(http.ListenAndServe("localhost:6060", nil)) - }() + if pprofAddr := os.Getenv("NB_PPROF_ADDR"); pprofAddr != "" { + log.Infof("pprof enabled, listening on: %s", pprofAddr) + go func() { + log.Println(http.ListenAndServe(pprofAddr, nil)) + }() + } + cmd.SetVersionInfo(Version, Commit, BuildDate, GoVersion) cmd.Execute() } diff --git a/proxy/internal/metrics/client_metrics_test.go b/proxy/internal/metrics/client_metrics_test.go new file mode 100644 index 000000000..c71e6fb57 --- /dev/null +++ b/proxy/internal/metrics/client_metrics_test.go @@ -0,0 +1,49 @@ +package metrics_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + + "github.com/netbirdio/netbird/proxy/internal/metrics" +) + +func TestRegisterClientObserver(t *testing.T) { + reader := sdkmetric.NewManualReader() + provider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + m, err := metrics.New(context.Background(), provider.Meter("test")) + require.NoError(t, err) + + clients := 2 + require.NoError(t, m.RegisterClientObserver(func() int { return clients })) + + var rm metricdata.ResourceMetrics + require.NoError(t, reader.Collect(context.Background(), &rm)) + assert.Equal(t, int64(2), gaugeValue(t, rm, "proxy.clients.count"), "gauge must report the current client count") + + clients = 1 + require.NoError(t, reader.Collect(context.Background(), &rm)) + assert.Equal(t, int64(1), gaugeValue(t, rm, "proxy.clients.count"), "gauge must follow the client count on the next collection") +} + +func gaugeValue(t *testing.T, rm metricdata.ResourceMetrics, name string) int64 { + t.Helper() + + for _, sm := range rm.ScopeMetrics { + for _, mtr := range sm.Metrics { + if mtr.Name != name { + continue + } + gauge, ok := mtr.Data.(metricdata.Gauge[int64]) + require.True(t, ok, "%s must be an int64 gauge", name) + require.Len(t, gauge.DataPoints, 1, "%s must have a single data point", name) + return gauge.DataPoints[0].Value + } + } + t.Fatalf("gauge %s not found", name) + return 0 +} diff --git a/proxy/internal/metrics/metrics.go b/proxy/internal/metrics/metrics.go index 5fd23d934..d7b1797a1 100644 --- a/proxy/internal/metrics/metrics.go +++ b/proxy/internal/metrics/metrics.go @@ -196,6 +196,21 @@ func (m *Metrics) RecordAddPeerDuration(d time.Duration, err error) { )) } +// RegisterClientObserver reports the number of embedded clients as a gauge. +// clientCount runs on every collection cycle, so it must stay cheap. +func (m *Metrics) RegisterClientObserver(clientCount func() int) error { + _, err := m.meter.Int64ObservableGauge( + "proxy.clients.count", + metric.WithUnit("1"), + metric.WithDescription("Current number of embedded NetBird clients running on the netbird proxy"), + metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error { + o.Observe(int64(clientCount())) + return nil + }), + ) + return err +} + func (m *Metrics) initL4Metrics(meter metric.Meter) error { var err error diff --git a/proxy/server.go b/proxy/server.go index 5b652e61c..762ead9b8 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -362,6 +362,13 @@ func (s *Server) Start(ctx context.Context) error { return err } + startupOK := false + defer func() { + if !startupOK { + s.cleanupFailedStart() + } + }() + // Management client must be initialised BEFORE the middleware manager — // initMiddlewareManager passes s.mgmtClient into the builtin FactoryContext // that the limit-check / limit-record middlewares pull from. Reversed @@ -374,7 +381,9 @@ func (s *Server) Start(ctx context.Context) error { runCtx, runCancel := context.WithCancel(ctx) s.runCancel = runCancel - s.initNetBirdClient() + if err := s.initNetBirdClient(); err != nil { + return err + } // Create health checker before the mapping worker so it can track // management connectivity from the first stream connection. s.healthChecker = health.NewChecker(s.Logger, s.netbird) @@ -395,18 +404,6 @@ func (s *Server) Start(ctx context.Context) error { return err } - startupOK := false - defer func() { - if startupOK { - return - } - if s.geoRaw != nil { - if closeErr := s.geoRaw.Close(); closeErr != nil { - s.Logger.Debugf("close geolocation on startup failure: %v", closeErr) - } - } - }() - s.auth = auth.NewMiddleware(s.Logger, s.mgmtClient, s.geo) s.accessLog = accesslog.NewLogger(s.mgmtClient, s.Logger, s.TrustedProxies) @@ -475,14 +472,7 @@ func (s *Server) Stop(ctx context.Context) error { go func() { defer close(done) s.gracefulShutdown() - if s.runCancel != nil { - s.runCancel() - } - if s.mgmtConn != nil { - if err := s.mgmtConn.Close(); err != nil { - s.Logger.Debugf("management connection close: %v", err) - } - } + s.releaseRunResources() }() select { @@ -497,6 +487,27 @@ func (s *Server) Stop(ctx context.Context) error { return s.runErr } +// cleanupFailedStart releases what a failed Start already brought up. It +// skips the drain and pre-stop delay because nothing has served yet, and +// consumes stopOnce so a later Stop stays a no-op. +func (s *Server) cleanupFailedStart() { + s.stopOnce.Do(func() { + s.shutdownServices() + s.releaseRunResources() + }) +} + +func (s *Server) releaseRunResources() { + if s.runCancel != nil { + s.runCancel() + } + if s.mgmtConn != nil { + if err := s.mgmtConn.Close(); err != nil { + s.Logger.Debugf("management connection close: %v", err) + } + } +} + // waitAndStop blocks until ctx is cancelled or a background goroutine // reports a fatal error, then drains and stops. Used by ListenAndServe. func (s *Server) waitAndStop(ctx context.Context) error { @@ -568,7 +579,7 @@ func (s *Server) initManagementClient() error { // initNetBirdClient builds the multi-tenant embedded NetBird client used // for outbound RoundTripping and (when --private is on) per-account // inbound listeners. -func (s *Server) initNetBirdClient() { +func (s *Server) initNetBirdClient() error { s.netbird = roundtrip.NewNetBird(s.ctx, s.ID, s.ProxyURL, roundtrip.ClientConfig{ MgmtAddr: s.ManagementAddress, WGPort: s.WireguardPort, @@ -581,6 +592,10 @@ func (s *Server) initNetBirdClient() { BlockInbound: !s.Private, }, s.Logger, s, s.mgmtClient) s.netbird.OnAddPeer = s.meter.RecordAddPeerDuration + if err := s.meter.RegisterClientObserver(s.netbird.ClientCount); err != nil { + return fmt.Errorf("register client metrics: %w", err) + } + return nil } // initReverseProxy builds the meter-instrumented reverse proxy. MultiTransport diff --git a/proxy/server_test.go b/proxy/server_test.go index 9cef63b95..cf583985f 100644 --- a/proxy/server_test.go +++ b/proxy/server_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/require" "go.opentelemetry.io/otel/metric/noop" "google.golang.org/grpc" + "google.golang.org/grpc/connectivity" "github.com/netbirdio/netbird/proxy/internal/auth" proxymetrics "github.com/netbirdio/netbird/proxy/internal/metrics" @@ -106,6 +107,25 @@ func TestStartFailsWithoutManagement(t *testing.T) { assert.Contains(t, err.Error(), "already started", "error must explain why the call was rejected") } +func TestStartFailureReleasesManagementConnection(t *testing.T) { + srv := New(t.Context(), Config{ + Logger: quietLifecycleLogger(), + ListenAddr: "127.0.0.1:0", + ManagementAddress: "https://127.0.0.1:1", + CertificateDirectory: t.TempDir(), + CertificateFile: "missing.crt", + CertificateKeyFile: "missing.key", + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := srv.Start(ctx) + require.Error(t, err, "Start must fail on the missing certificate") + require.NotNil(t, srv.mgmtConn, "the management connection is created before the certificate step") + assert.Equal(t, connectivity.Shutdown, srv.mgmtConn.GetState(), "a failed Start must close the management connection it opened") +} + func TestStopIsIdempotent(t *testing.T) { srv := &Server{ Logger: quietLifecycleLogger(), diff --git a/shared/lifecycle/stop_handlers.go b/shared/lifecycle/stop_handlers.go new file mode 100644 index 000000000..f6ec2688b --- /dev/null +++ b/shared/lifecycle/stop_handlers.go @@ -0,0 +1,57 @@ +package lifecycle + +import ( + "runtime/debug" + "sync" + + log "github.com/sirupsen/logrus" +) + +// StopHandlers collects functions to run once when their owner exits. Embed it +// in a server type to expose OnStop and RunStopHandlers. +type StopHandlers struct { + mu sync.Mutex + stopped bool + handlers []func() +} + +// OnStop registers fn to run once when the owner stops. Handlers run in +// reverse registration order. A handler registered after the owner has +// stopped runs immediately. +func (h *StopHandlers) OnStop(fn func()) { + h.mu.Lock() + stopped := h.stopped + if !stopped { + h.handlers = append(h.handlers, fn) + } + h.mu.Unlock() + + if stopped { + runStopHandler(fn) + } +} + +// RunStopHandlers runs every registered handler once, last registered first. +// Later calls are no-ops, so it can be wired to several exit paths at once. +func (h *StopHandlers) RunStopHandlers() { + h.mu.Lock() + handlers := h.handlers + h.handlers = nil + h.stopped = true + h.mu.Unlock() + + for i := len(handlers) - 1; i >= 0; i-- { + runStopHandler(handlers[i]) + } +} + +// runStopHandler keeps one panicking handler from skipping the ones still +// pending; on the shutdown path there is no second chance to run them. +func runStopHandler(fn func()) { + defer func() { + if r := recover(); r != nil { + log.Errorf("stop handler panicked: %v\n%s", r, debug.Stack()) + } + }() + fn() +} diff --git a/shared/lifecycle/stop_handlers_test.go b/shared/lifecycle/stop_handlers_test.go new file mode 100644 index 000000000..787f39e6d --- /dev/null +++ b/shared/lifecycle/stop_handlers_test.go @@ -0,0 +1,43 @@ +package lifecycle + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStopHandlers_RunOnceInReverseOrder(t *testing.T) { + var h StopHandlers + var order []string + h.OnStop(func() { order = append(order, "first") }) + h.OnStop(func() { order = append(order, "second") }) + + h.RunStopHandlers() + h.RunStopHandlers() + + assert.Equal(t, []string{"second", "first"}, order, "handlers must run once, last registered first") +} + +func TestStopHandlers_PanicDoesNotSkipRemainingHandlers(t *testing.T) { + var h StopHandlers + var order []string + h.OnStop(func() { order = append(order, "first") }) + h.OnStop(func() { panic("boom") }) + h.OnStop(func() { order = append(order, "third") }) + + h.RunStopHandlers() + + assert.Equal(t, []string{"third", "first"}, order, "handlers around a panicking one must still run") +} + +func TestStopHandlers_LateRegistrationRunsImmediately(t *testing.T) { + var h StopHandlers + h.RunStopHandlers() + + runs := 0 + h.OnStop(func() { runs++ }) + assert.Equal(t, 1, runs, "a handler registered after the stop must run right away") + + h.RunStopHandlers() + assert.Equal(t, 1, runs, "later runs must stay no-ops and must not repeat the handler") +} diff --git a/shared/profiling/profiling.go b/shared/profiling/profiling.go new file mode 100644 index 000000000..1d893048a --- /dev/null +++ b/shared/profiling/profiling.go @@ -0,0 +1,127 @@ +package profiling + +import ( + "errors" + "fmt" + "net/netip" + "net/url" + "os" + "strings" + "sync/atomic" + + "github.com/caarlos0/env/v11" + "github.com/grafana/pyroscope-go" + log "github.com/sirupsen/logrus" +) + +var errNotConfigured = errors.New("pyroscope not configured") + +var started atomic.Bool + +type config struct { + Address string `env:"NB_PYROSCOPE_ADDRESS"` + User string `env:"NB_PYROSCOPE_USER,notEmpty"` + Password string `env:"NB_PYROSCOPE_PASSWORD,notEmpty"` +} + +func Start(applicationName string) func() { + noop := func() {} + + cfg, err := loadConfig() + switch { + case errors.Is(err, errNotConfigured): + log.Info("pyroscope not configured, continuous profiling disabled") + return noop + case err != nil: + log.Errorf("failed to load pyroscope config: %v", err) + return noop + } + + // pprof allows one CPU profile per process, so a second profiler (e.g. the + // signal server inside the combined binary) would only log errors. + if !started.CompareAndSwap(false, true) { + log.Warnf("continuous profiling already running in this process, not starting it for %s", applicationName) + return noop + } + + tags := map[string]string{} + if hostname, err := os.Hostname(); err == nil { + tags["instance"] = hostname + } else { + log.Warnf("failed to resolve hostname for profile tags: %v", err) + } + + profiler, err := pyroscope.Start(pyroscope.Config{ + ApplicationName: applicationName, + ServerAddress: cfg.Address, + BasicAuthUser: cfg.User, + BasicAuthPassword: cfg.Password, + Logger: log.StandardLogger(), + Tags: tags, + ProfileTypes: []pyroscope.ProfileType{ + pyroscope.ProfileCPU, + pyroscope.ProfileAllocObjects, + pyroscope.ProfileAllocSpace, + pyroscope.ProfileInuseObjects, + pyroscope.ProfileInuseSpace, + }, + }) + if err != nil { + started.Store(false) + log.Errorf("failed to start continuous profiling: %v", err) + return noop + } + + return func() { + _ = profiler.Stop() + started.Store(false) + } +} + +func loadConfig() (config, error) { + var cfg config + if err := env.Parse(&cfg); err != nil { + if cfg.Address == "" { + return cfg, errNotConfigured + } + return cfg, fmt.Errorf("failed to parse pyroscope config: %w", err) + } + + if cfg.Address == "" { + return cfg, errNotConfigured + } + if err := validateAddress(cfg.Address); err != nil { + return cfg, err + } + + return cfg, nil +} + +// validateAddress refuses to send the basic-auth credentials in plaintext to +// anything but a loopback or private endpoint. +func validateAddress(address string) error { + u, err := url.Parse(address) + if err != nil { + return fmt.Errorf("invalid pyroscope address %q: %w", address, err) + } + + switch u.Scheme { + case "https": + return nil + case "http": + if isLocalOrPrivate(u.Hostname()) { + return nil + } + return fmt.Errorf("insecure pyroscope address %q: use https for non-local endpoints", address) + default: + return fmt.Errorf("pyroscope address %q must use http or https", address) + } +} + +func isLocalOrPrivate(host string) bool { + if host == "localhost" || strings.HasSuffix(host, ".localhost") { + return true + } + ip, err := netip.ParseAddr(host) + return err == nil && (ip.IsLoopback() || ip.IsPrivate()) +} diff --git a/shared/profiling/profiling_test.go b/shared/profiling/profiling_test.go new file mode 100644 index 000000000..68e56bb4c --- /dev/null +++ b/shared/profiling/profiling_test.go @@ -0,0 +1,202 @@ +package profiling + +import ( + "os" + "testing" + + log "github.com/sirupsen/logrus" + logtest "github.com/sirupsen/logrus/hooks/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStartSkipsSecondProfilerInProcess(t *testing.T) { + clearEnv(t) + t.Setenv("NB_PYROSCOPE_ADDRESS", "http://127.0.0.1:1") + t.Setenv("NB_PYROSCOPE_USER", "user") + t.Setenv("NB_PYROSCOPE_PASSWORD", "token") + + started.Store(true) + t.Cleanup(func() { started.Store(false) }) + hook := logtest.NewGlobal() + t.Cleanup(hook.Reset) + + stop := Start("netbird-second") + stop() + + assert.True(t, started.Load(), "the running profiler must stay marked as started") + entry := hook.LastEntry() + require.NotNil(t, entry, "the skipped start must be logged") + assert.Equal(t, log.WarnLevel, entry.Level) + assert.Contains(t, entry.Message, "already running") +} + +func TestLoadConfig(t *testing.T) { + tests := []struct { + name string + env map[string]string + expected config + errIs error + wantErr bool + }{ + { + name: "address unset disables profiling", + errIs: errNotConfigured, + }, + { + name: "empty address disables profiling", + env: map[string]string{"NB_PYROSCOPE_ADDRESS": ""}, + errIs: errNotConfigured, + }, + { + name: "credentials without address disable profiling", + env: map[string]string{ + "NB_PYROSCOPE_USER": "123456", + "NB_PYROSCOPE_PASSWORD": "token", + }, + errIs: errNotConfigured, + }, + { + name: "address without credentials fails", + env: map[string]string{ + "NB_PYROSCOPE_ADDRESS": "https://profiles-prod-001.grafana.net", + }, + wantErr: true, + }, + { + name: "address with empty credentials fails", + env: map[string]string{ + "NB_PYROSCOPE_ADDRESS": "https://profiles-prod-001.grafana.net", + "NB_PYROSCOPE_USER": "", + "NB_PYROSCOPE_PASSWORD": "", + }, + wantErr: true, + }, + { + name: "address without password fails", + env: map[string]string{ + "NB_PYROSCOPE_ADDRESS": "https://profiles-prod-001.grafana.net", + "NB_PYROSCOPE_USER": "123456", + }, + wantErr: true, + }, + { + name: "full configuration", + env: map[string]string{ + "NB_PYROSCOPE_ADDRESS": "https://profiles-prod-001.grafana.net", + "NB_PYROSCOPE_USER": "123456", + "NB_PYROSCOPE_PASSWORD": "token", + }, + expected: config{ + Address: "https://profiles-prod-001.grafana.net", + User: "123456", + Password: "token", + }, + }, + { + name: "http to loopback is allowed", + env: map[string]string{ + "NB_PYROSCOPE_ADDRESS": "http://127.0.0.1:4040", + "NB_PYROSCOPE_USER": "123456", + "NB_PYROSCOPE_PASSWORD": "token", + }, + expected: config{ + Address: "http://127.0.0.1:4040", + User: "123456", + Password: "token", + }, + }, + { + name: "http to localhost is allowed", + env: map[string]string{ + "NB_PYROSCOPE_ADDRESS": "http://localhost:4040", + "NB_PYROSCOPE_USER": "123456", + "NB_PYROSCOPE_PASSWORD": "token", + }, + expected: config{ + Address: "http://localhost:4040", + User: "123456", + Password: "token", + }, + }, + { + name: "http to private network is allowed", + env: map[string]string{ + "NB_PYROSCOPE_ADDRESS": "http://10.0.0.5:4040", + "NB_PYROSCOPE_USER": "123456", + "NB_PYROSCOPE_PASSWORD": "token", + }, + expected: config{ + Address: "http://10.0.0.5:4040", + User: "123456", + Password: "token", + }, + }, + { + name: "http to public host is rejected", + env: map[string]string{ + "NB_PYROSCOPE_ADDRESS": "http://pyroscope.example.com", + "NB_PYROSCOPE_USER": "123456", + "NB_PYROSCOPE_PASSWORD": "token", + }, + wantErr: true, + }, + { + name: "http to public address is rejected", + env: map[string]string{ + "NB_PYROSCOPE_ADDRESS": "http://203.0.113.10:4040", + "NB_PYROSCOPE_USER": "123456", + "NB_PYROSCOPE_PASSWORD": "token", + }, + wantErr: true, + }, + { + name: "address without scheme is rejected", + env: map[string]string{ + "NB_PYROSCOPE_ADDRESS": "pyroscope.example.com:4040", + "NB_PYROSCOPE_USER": "123456", + "NB_PYROSCOPE_PASSWORD": "token", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearEnv(t) + for k, v := range tt.env { + t.Setenv(k, v) + } + + cfg, err := loadConfig() + + switch { + case tt.errIs != nil: + require.ErrorIs(t, err, tt.errIs) + case tt.wantErr: + require.Error(t, err) + require.NotErrorIs(t, err, errNotConfigured) + default: + require.NoError(t, err) + assert.Equal(t, tt.expected, cfg) + } + }) + } +} + +func TestStartWithoutConfigurationIsNoop(t *testing.T) { + clearEnv(t) + + stop := Start("netbird-test") + require.NotNil(t, stop) + stop() +} + +func clearEnv(t *testing.T) { + t.Helper() + + for _, k := range []string{"NB_PYROSCOPE_ADDRESS", "NB_PYROSCOPE_USER", "NB_PYROSCOPE_PASSWORD"} { + t.Setenv(k, "") + require.NoError(t, os.Unsetenv(k)) + } +} diff --git a/signal/cmd/run.go b/signal/cmd/run.go index a36623c6b..42b7d2505 100644 --- a/signal/cmd/run.go +++ b/signal/cmd/run.go @@ -119,6 +119,7 @@ var ( if err != nil { return fmt.Errorf("creating signal server: %v", err) } + defer srv.Stop() proto.RegisterSignalExchangeServer(grpcServer, srv) grpcRootHandler := grpcHandlerFunc(grpcServer, metricsServer.Meter) diff --git a/signal/server/signal.go b/signal/server/signal.go index 7edbb4d34..f991b5d81 100644 --- a/signal/server/signal.go +++ b/signal/server/signal.go @@ -17,6 +17,8 @@ import ( "github.com/netbirdio/signal-dispatcher/dispatcher" + "github.com/netbirdio/netbird/shared/lifecycle" + "github.com/netbirdio/netbird/shared/profiling" "github.com/netbirdio/netbird/shared/signal/proto" "github.com/netbirdio/netbird/signal/metrics" "github.com/netbirdio/netbird/signal/peer" @@ -43,6 +45,8 @@ const ( labelRegistrationNotFound = "not_found" sendTimeout = 10 * time.Second + + applicationName = "signal" ) var ( @@ -51,6 +55,7 @@ var ( // Server an instance of a Signal server type Server struct { + lifecycle.StopHandlers registry *peer.Registry proto.UnimplementedSignalExchangeServer dispatcher *dispatcher.Dispatcher @@ -88,9 +93,17 @@ func NewServer(ctx context.Context, meter metric.Meter, metricsPrefix ...string) sendTimeout: sTimeout, } + stopProfiling := profiling.Start(applicationName) + s.OnStop(stopProfiling) + return s, nil } +// Stop runs the handlers registered with OnStop. +func (s *Server) Stop() { + s.RunStopHandlers() +} + // Send forwards a message to the signal peer func (s *Server) Send(ctx context.Context, msg *proto.EncryptedMessage) (*proto.EncryptedMessage, error) { log.Tracef("received a new message to send from peer [%s] to peer [%s]", msg.Key, msg.RemoteKey) From 6e17f50040dcf3203178f5536020850c711091ab Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:47:48 +0200 Subject: [PATCH 04/33] [client] Validate the saved service parameters and pin the netsh lookup (#7584) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [client] Export the only-owner-writable path check from elevate Pure refactor, no behavior change: the existing checkOnlyOwnerWritable gets a thin exported wrapper so callers outside the elevation path can reuse it. No call site changes here. * [client] Validate the saved service parameters before applying them The install reads /service.json and applies it to the service it then registers: its arguments, its config path and its environment. The restricted ACL that saveServiceParams puts on the state directory is applied when the file is written, which is not necessarily before the file is first read, so the install now checks the file rather than assuming it. A file whose ownership or permissions are not the ones saveServiceParams produces is treated as absent, and the install proceeds with its defaults. The check covers the directories above the file as well, so what is checked is what is read. * [client] Restrict which environment variables the service is registered with --service-env, and the service.json it persists to, accepted any name. A small set of them decides how a process resolves the executables and libraries it loads, and the daemon needs none of those: it now refuses them when they are passed explicitly, and drops them with a warning when they come back from a service.json written by an older version, so an upgrade does not fail over a variable nobody needs. * [client] Resolve netsh by absolute path The lookup consulted PATH first and fell back to System32, in both the copy the userspace firewall uses and the one that tears the interface down. It now asks Windows for the system directory, so the resolution no longer depends on the environment the service happens to be started with. * [client] Move the System32 lookup into a package both callers share Pure refactor, no behavior change: client/iface and client/firewall/uspfilter carried a copy each of the same function, and neither imports the other, so the body moves to client/internal/wincmd — alongside winregistry, which is where the client's other Windows-only helper already lives. Both call sites now read wincmd.System32("netsh"). * [client] Cover the System32 lookup with a test Asserts what the previous commits changed: the lookup is absolute, and neither PATH nor %SystemRoot% moves it. * [client] Refuse the loader environment families by prefix Review follow-up on the previous commit: - LD_* and DYLD_* are now refused whole rather than name by name. Their members differ per platform and libc and grow with new OS releases, so a list of them is out of date as soon as it is written — DYLD_FALLBACK_LIBRARY_PATH and DYLD_FALLBACK_FRAMEWORK_PATH were already missing from it. - The names are folded to upper case only on Windows, where a variable is the same one however it is spelled. Elsewhere the environment is case-sensitive, so Path and PATH are two variables and only the exact spelling is the one that is read; the fold refused the wrong one. - TEMP and TMP stay in the denylist, but the rationale and the message now say what they actually decide: where the service writes, not what it loads. --- client/cmd/service.go | 50 ++++++++++++++++ client/cmd/service_params.go | 56 ++++++++++++++++-- client/cmd/service_params_test.go | 54 ++++++++++++++++++ client/cmd/service_params_trust_test.go | 57 +++++++++++++++++++ .../uspfilter/interface_allower_windows.go | 20 ++----- client/iface/iface_destroy_windows.go | 17 +----- client/internal/elevate/trusted.go | 11 ++++ client/internal/wincmd/system32_windows.go | 30 ++++++++++ .../internal/wincmd/system32_windows_test.go | 31 ++++++++++ 9 files changed, 289 insertions(+), 37 deletions(-) create mode 100644 client/cmd/service_params_trust_test.go create mode 100644 client/internal/wincmd/system32_windows.go create mode 100644 client/internal/wincmd/system32_windows_test.go diff --git a/client/cmd/service.go b/client/cmd/service.go index 7410d60ea..2a558e6d5 100644 --- a/client/cmd/service.go +++ b/client/cmd/service.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "runtime" + "slices" "strings" "sync" @@ -25,6 +26,30 @@ var serviceCmd = &cobra.Command{ const defaultJSONSocket = "unix:///var/run/netbird-http.sock" +// forbiddenServiceEnvVars are the environment variables the service is never +// registered with, keyed in upper case since these are Windows names. Each one +// decides where the daemon resolves something it then uses with the privileges +// of the account it runs under — LocalSystem on Windows, root elsewhere: the +// executables it runs (PATH, PATHEXT, COMSPEC, SystemRoot, windir) or the +// directory it writes temporary files in (TEMP, TMP). The daemon needs none of +// them, and the utilities it shells out to are resolved by absolute path. +var forbiddenServiceEnvVars = map[string]struct{}{ + "PATH": {}, + "PATHEXT": {}, + "SYSTEMROOT": {}, + "WINDIR": {}, + "COMSPEC": {}, + "TEMP": {}, + "TMP": {}, +} + +// forbiddenServiceEnvPrefixes are the dynamic-loader families, refused whole +// rather than by name: LD_PRELOAD, DYLD_INSERT_LIBRARIES and their siblings all +// reach the loader of the process, the set differs per platform and libc, and +// new members arrive with new OS releases. Listing them one by one is a list +// that is wrong the moment it is written. +var forbiddenServiceEnvPrefixes = []string{"LD_", "DYLD_"} + var ( serviceName string serviceEnvVars []string @@ -127,8 +152,33 @@ func parseServiceEnvVars(envVars []string) (map[string]string, error) { return nil, fmt.Errorf("empty environment variable key in: %s", env) } + if isForbiddenServiceEnvVar(key) { + return nil, fmt.Errorf("environment variable %s cannot be set on the service: it decides where the service resolves the executables, libraries or temporary files it uses", key) + } + envMap[key] = value } return envMap, nil } + +// isForbiddenServiceEnvVar reports whether name is one the service must not be +// registered with. +// +// The names are matched case-insensitively only on Windows, where they are the +// same variable however they are spelled. Elsewhere the environment is +// case-sensitive, so Path and PATH are two different variables and only the +// exact spelling is the one the loader reads. +func isForbiddenServiceEnvVar(name string) bool { + if runtime.GOOS == "windows" { + name = strings.ToUpper(name) + } + + if _, forbidden := forbiddenServiceEnvVars[name]; forbidden { + return true + } + + return slices.ContainsFunc(forbiddenServiceEnvPrefixes, func(prefix string) bool { + return strings.HasPrefix(name, prefix) + }) +} diff --git a/client/cmd/service_params.go b/client/cmd/service_params.go index 750b22ae6..6e2dbec40 100644 --- a/client/cmd/service_params.go +++ b/client/cmd/service_params.go @@ -14,6 +14,7 @@ import ( "github.com/netbirdio/netbird/client/configs" "github.com/netbirdio/netbird/client/internal/daemonaddr" + "github.com/netbirdio/netbird/client/internal/elevate" "github.com/netbirdio/netbird/util" ) @@ -43,10 +44,33 @@ func serviceParamsPath() string { // loadServiceParams reads saved service parameters from disk. // Returns nil with no error if the file does not exist. +// +// The file is read by an elevated install and decides the arguments and the +// environment of the service it then registers, so it is used only when its +// ownership and permissions are the ones saveServiceParams leaves behind. That +// restricted ACL is applied when the file is written, which is not necessarily +// before it is first read, so this is checked rather than assumed. A file that +// fails the check is treated as absent, and the install proceeds with its +// defaults. func loadServiceParams() (*serviceParams, error) { path := serviceParamsPath() - data, err := os.ReadFile(path) + // Resolve links first so the checks apply to the file that is actually read. + // Since the check covers every directory above it as well, nobody who fails + // it can swap the file between here and the read below. + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil //nolint:nilnil + } + return nil, fmt.Errorf("resolve service params %s: %w", path, err) + } + + if err := elevate.CheckOnlyOwnerWritable(resolved); err != nil { + return nil, fmt.Errorf("refusing to read service params from %s: %w", resolved, err) + } + + data, err := os.ReadFile(resolved) if err != nil { if os.IsNotExist(err) { return nil, nil //nolint:nilnil @@ -182,10 +206,16 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) { // If --service-env was explicitly set to empty, all saved env vars are cleared. // If --service-env was not set, saved env vars are used entirely. func applyServiceEnvParams(cmd *cobra.Command, params *serviceParams) { + // A forbidden name explicitly passed on the command line is an error the + // operator is told about, but one restored from a file written by an older + // version is dropped: an install that refuses to run would leave the host + // without a daemon over a variable nobody is asking for any more. + saved := dropForbiddenServiceEnvVars(cmd, params.ServiceEnvVars) + if !cmd.Flags().Changed("service-env") { - if len(params.ServiceEnvVars) > 0 { + if len(saved) > 0 { // No explicit env vars: rebuild serviceEnvVars from saved params. - serviceEnvVars = envMapToSlice(params.ServiceEnvVars) + serviceEnvVars = envMapToSlice(saved) } return } @@ -204,13 +234,13 @@ func applyServiceEnvParams(cmd *cobra.Command, params *serviceParams) { return } - if len(params.ServiceEnvVars) == 0 { + if len(saved) == 0 { return } // Merge saved values underneath explicit ones. - merged := make(map[string]string, len(params.ServiceEnvVars)+len(explicit)) - maps.Copy(merged, params.ServiceEnvVars) + merged := make(map[string]string, len(saved)+len(explicit)) + maps.Copy(merged, saved) maps.Copy(merged, explicit) // explicit wins on conflict serviceEnvVars = envMapToSlice(merged) } @@ -233,6 +263,20 @@ var resetParamsCmd = &cobra.Command{ }, } +// dropForbiddenServiceEnvVars returns the saved entries that may still be +// registered on the service, reporting every one it leaves behind. +func dropForbiddenServiceEnvVars(cmd *cobra.Command, saved map[string]string) map[string]string { + kept := make(map[string]string, len(saved)) + for key, value := range saved { + if isForbiddenServiceEnvVar(key) { + cmd.PrintErrf("Warning: ignoring saved service environment variable %s: it decides where the service resolves the executables, libraries or temporary files it uses\n", key) + continue + } + kept[key] = value + } + return kept +} + // envMapToSlice converts a map of env vars to a KEY=VALUE slice. func envMapToSlice(m map[string]string) []string { s := make([]string, 0, len(m)) diff --git a/client/cmd/service_params_test.go b/client/cmd/service_params_test.go index 94f98a0ce..1f83374cb 100644 --- a/client/cmd/service_params_test.go +++ b/client/cmd/service_params_test.go @@ -9,6 +9,7 @@ import ( "go/token" "os" "path/filepath" + "runtime" "strings" "testing" @@ -353,6 +354,59 @@ func TestApplyServiceEnvParams_NotChanged(t *testing.T) { assert.Equal(t, map[string]string{"FROM_SAVED": "val"}, result) } +func TestParseServiceEnvVars_RejectsForbiddenNames(t *testing.T) { + for _, env := range []string{"PATH=C:\\somewhere", "LD_PRELOAD=/tmp/lib.so", "DYLD_FALLBACK_LIBRARY_PATH=/tmp"} { + _, err := parseServiceEnvVars([]string{"KEEP=me", env}) + require.Errorf(t, err, "%s selects what the service resolves and must be refused", env) + } +} + +func TestIsForbiddenServiceEnvVar(t *testing.T) { + // The loader families are matched by prefix, so a name nobody has heard of + // yet is refused too. + for _, name := range []string{ + "PATH", "PATHEXT", "COMSPEC", "SYSTEMROOT", "WINDIR", "TEMP", "TMP", + "LD_PRELOAD", "LD_AUDIT", "DYLD_INSERT_LIBRARIES", "DYLD_FALLBACK_FRAMEWORK_PATH", + } { + assert.Truef(t, isForbiddenServiceEnvVar(name), "%s must be refused", name) + } + + // The prefix must not swallow names that merely start with the same letters. + for _, name := range []string{"NB_LOG_LEVEL", "NB_WG_DEBUG", "HTTPS_PROXY", "LDAP_URL", "DYLDX"} { + assert.Falsef(t, isForbiddenServiceEnvVar(name), "%s has no reason to be refused", name) + } + + // On Windows a variable is the same one however it is spelled; elsewhere + // Path and PATH are two variables and only the exact one is read. + if runtime.GOOS == "windows" { + assert.True(t, isForbiddenServiceEnvVar("Path")) + assert.True(t, isForbiddenServiceEnvVar("ld_preload")) + } else { + assert.False(t, isForbiddenServiceEnvVar("Path")) + assert.False(t, isForbiddenServiceEnvVar("ld_preload")) + } +} + +func TestApplyServiceEnvParams_DropsForbiddenSavedNames(t *testing.T) { + origServiceEnvVars := serviceEnvVars + t.Cleanup(func() { serviceEnvVars = origServiceEnvVars }) + + serviceEnvVars = nil + + cmd := &cobra.Command{} + cmd.Flags().StringSlice("service-env", nil, "") + + saved := &serviceParams{ + ServiceEnvVars: map[string]string{"PATH": "C:\\attacker", "NB_LOG_FORMAT": "json"}, + } + + applyServiceEnvParams(cmd, saved) + + result, err := parseServiceEnvVars(serviceEnvVars) + require.NoError(t, err, "a saved PATH must be dropped rather than fail the install") + assert.Equal(t, map[string]string{"NB_LOG_FORMAT": "json"}, result) +} + func TestApplyServiceEnvParams_ExplicitEmptyClears(t *testing.T) { origServiceEnvVars := serviceEnvVars t.Cleanup(func() { serviceEnvVars = origServiceEnvVars }) diff --git a/client/cmd/service_params_trust_test.go b/client/cmd/service_params_trust_test.go new file mode 100644 index 000000000..1cf564445 --- /dev/null +++ b/client/cmd/service_params_trust_test.go @@ -0,0 +1,57 @@ +//go:build !windows && !ios && !android + +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/configs" +) + +// The Windows equivalent of this is the ACL check in +// elevate.CheckOnlyOwnerWritable, covered by that package's own tests; here the +// point is that loadServiceParams asks the question at all. +func TestLoadServiceParams_RefusesWorldWritableFile(t *testing.T) { + tmpDir := t.TempDir() + + original := configs.StateDir + t.Cleanup(func() { configs.StateDir = original }) + configs.StateDir = tmpDir + + path := filepath.Join(tmpDir, serviceParamsFile) + require.NoError(t, os.WriteFile(path, []byte(`{"log_level":"debug"}`), 0o666)) + // WriteFile is subject to the umask, so set the bits that matter explicitly. + require.NoError(t, os.Chmod(path, 0o666)) + + params, err := loadServiceParams() + require.Error(t, err, "a service.json anyone can rewrite must not be trusted") + assert.Nil(t, params) + + require.NoError(t, os.Chmod(path, 0o600)) + params, err = loadServiceParams() + require.NoError(t, err) + require.NotNil(t, params) + assert.Equal(t, "debug", params.LogLevel) +} + +func TestLoadServiceParams_RefusesWorldWritableDirectory(t *testing.T) { + tmpDir := t.TempDir() + stateDir := filepath.Join(tmpDir, "state") + require.NoError(t, os.Mkdir(stateDir, 0o777)) + require.NoError(t, os.Chmod(stateDir, 0o777)) + + original := configs.StateDir + t.Cleanup(func() { configs.StateDir = original }) + configs.StateDir = stateDir + + require.NoError(t, os.WriteFile(filepath.Join(stateDir, serviceParamsFile), []byte(`{}`), 0o600)) + + params, err := loadServiceParams() + require.Error(t, err, "a service.json in a directory anyone can replace entries in must not be trusted") + assert.Nil(t, params) +} diff --git a/client/firewall/uspfilter/interface_allower_windows.go b/client/firewall/uspfilter/interface_allower_windows.go index 7f525e28c..4cd0fe969 100644 --- a/client/firewall/uspfilter/interface_allower_windows.go +++ b/client/firewall/uspfilter/interface_allower_windows.go @@ -9,6 +9,7 @@ import ( log "github.com/sirupsen/logrus" nberrors "github.com/netbirdio/netbird/client/errors" + "github.com/netbirdio/netbird/client/internal/wincmd" ) type action string @@ -91,7 +92,7 @@ func manageFirewallRule(ruleName string, action action, extraArgs ...string) err if action == addRule { args = append(args, extraArgs...) } - netshCmd := GetSystem32Command("netsh") + netshCmd := wincmd.System32("netsh") cmd := exec.Command(netshCmd, args...) cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} return cmd.Run() @@ -100,7 +101,7 @@ func manageFirewallRule(ruleName string, action action, extraArgs ...string) err func isWindowsFirewallReachable() bool { args := []string{"advfirewall", "show", "allprofiles", "state"} - netshCmd := GetSystem32Command("netsh") + netshCmd := wincmd.System32("netsh") cmd := exec.Command(netshCmd, args...) cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} @@ -117,23 +118,10 @@ func isWindowsFirewallReachable() bool { func isFirewallRuleActive(ruleName string) bool { args := []string{"advfirewall", "firewall", "show", "rule", "name=" + ruleName} - netshCmd := GetSystem32Command("netsh") + netshCmd := wincmd.System32("netsh") cmd := exec.Command(netshCmd, args...) cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} _, err := cmd.Output() return err == nil } - -// GetSystem32Command checks if a command can be found in the system path and returns it. In case it can't find it -// in the path it will return the full path of a command assuming C:\windows\system32 as the base path. -func GetSystem32Command(command string) string { - _, err := exec.LookPath(command) - if err == nil { - return command - } - - log.Tracef("Command %s not found in PATH, using C:\\windows\\system32\\%s.exe path", command, command) - - return "C:\\windows\\system32\\" + command + ".exe" -} diff --git a/client/iface/iface_destroy_windows.go b/client/iface/iface_destroy_windows.go index 0bfa4e211..54c0014c4 100644 --- a/client/iface/iface_destroy_windows.go +++ b/client/iface/iface_destroy_windows.go @@ -6,27 +6,14 @@ import ( "fmt" "os/exec" - log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/client/internal/wincmd" ) func (w *WGIface) Destroy() error { - netshCmd := GetSystem32Command("netsh") + netshCmd := wincmd.System32("netsh") out, err := exec.Command(netshCmd, "interface", "set", "interface", w.Name(), "admin=disable").CombinedOutput() if err != nil { return fmt.Errorf("failed to remove interface %s: %w - %s", w.Name(), err, out) } return nil } - -// GetSystem32Command checks if a command can be found in the system path and returns it. In case it can't find it -// in the path it will return the full path of a command assuming C:\windows\system32 as the base path. -func GetSystem32Command(command string) string { - _, err := exec.LookPath(command) - if err == nil { - return command - } - - log.Tracef("Command %s not found in PATH, using C:\\windows\\system32\\%s.exe path", command, command) - - return "C:\\windows\\system32\\" + command + ".exe" -} diff --git a/client/internal/elevate/trusted.go b/client/internal/elevate/trusted.go index c11054c45..98e05fde5 100644 --- a/client/internal/elevate/trusted.go +++ b/client/internal/elevate/trusted.go @@ -6,6 +6,17 @@ import ( "path/filepath" ) +// CheckOnlyOwnerWritable reports an error unless path, and every directory +// leading to it, is owned by an account that can already act with the privileges +// the caller holds, and is writable by nobody else. +// +// Exported for callers outside elevation that read a file while privileged and +// then act on what it says: the same question this package asks of an +// executable, asked of a configuration file. +func CheckOnlyOwnerWritable(path string) error { + return checkOnlyOwnerWritable(path) +} + // trustedSelf returns the path of this executable, provided it is one we are // willing to have run as root. // diff --git a/client/internal/wincmd/system32_windows.go b/client/internal/wincmd/system32_windows.go new file mode 100644 index 000000000..36aa258b5 --- /dev/null +++ b/client/internal/wincmd/system32_windows.go @@ -0,0 +1,30 @@ +// Package wincmd locates the Windows utilities the client shells out to. +package wincmd + +import ( + "path/filepath" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" +) + +// defaultSystem32Dir is where the system directory is on every supported +// install, used only when the API that reports it fails. +const defaultSystem32Dir = `C:\Windows\System32` + +// System32 returns the full path of a Windows utility under the system +// directory. +// +// PATH is deliberately not consulted. The daemon runs as LocalSystem with an +// environment of its own, so whoever can place an entry in that PATH chooses +// which binary runs with those privileges. The system directory is read from +// the API rather than from %SystemRoot% for the same reason. +func System32(command string) string { + sysDir, err := windows.GetSystemDirectory() + if err != nil { + log.Warnf("Failed to locate the Windows system directory, falling back to %s: %v", defaultSystem32Dir, err) + sysDir = defaultSystem32Dir + } + + return filepath.Join(sysDir, command+".exe") +} diff --git a/client/internal/wincmd/system32_windows_test.go b/client/internal/wincmd/system32_windows_test.go new file mode 100644 index 000000000..0d31d7ee7 --- /dev/null +++ b/client/internal/wincmd/system32_windows_test.go @@ -0,0 +1,31 @@ +package wincmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSystem32IgnoresPATH(t *testing.T) { + // A directory holding something that would win a PATH lookup, in front of + // everything else: the daemon runs as LocalSystem, so a PATH entry must not + // be able to decide what it executes. + planted := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(planted, "netsh.exe"), []byte("not really netsh"), 0o600)) + t.Setenv("PATH", planted+string(os.PathListSeparator)+os.Getenv("PATH")) + + got := System32("netsh") + + assert.True(t, filepath.IsAbs(got), "the path must be absolute, got %q", got) + assert.NotContains(t, got, planted, "a PATH entry must not be consulted") + assert.True(t, strings.EqualFold(filepath.Base(got), "netsh.exe"), "unexpected file name in %q", got) + + // The system directory is what Windows reports it to be, not %SystemRoot%, + // which the same caller could have set alongside PATH. + t.Setenv("SystemRoot", planted) + assert.Equal(t, got, System32("netsh"), "%SystemRoot% must not move the lookup") +} From 4c19226342256694c3092682720c45d9f1e51a1f Mon Sep 17 00:00:00 2001 From: Theodor Midtlien Date: Thu, 24 Sep 2026 10:23:50 +0200 Subject: [PATCH 05/33] [client] Use POSIX style file read/write of json for windows (#7631) * Use POSIX-like file read/write of json for windows + tests: allow renaming an open file. --- .../profilemanager/active_state_test.go | 76 ++++++++++++ util/file.go | 6 +- util/file_nonwindows.go | 16 +++ util/file_read_test.go | 58 +++++++++ util/file_windows.go | 79 ++++++++++++ util/file_windows_test.go | 116 ++++++++++++++++++ 6 files changed, 348 insertions(+), 3 deletions(-) create mode 100644 client/internal/profilemanager/active_state_test.go create mode 100644 util/file_nonwindows.go create mode 100644 util/file_read_test.go create mode 100644 util/file_windows.go create mode 100644 util/file_windows_test.go diff --git a/client/internal/profilemanager/active_state_test.go b/client/internal/profilemanager/active_state_test.go new file mode 100644 index 000000000..3b7fcd29c --- /dev/null +++ b/client/internal/profilemanager/active_state_test.go @@ -0,0 +1,76 @@ +package profilemanager + +import ( + "fmt" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Regression test: a concurrent Get and Set of the ActiveProfileState will +// fail on Windows since the write is a temp file renamed over an open file. +// Windows will refuse to replace a file another handle holds open by default. +func TestActiveProfileState_ReadsDoNotBreakAConcurrentWrite(t *testing.T) { + withTempConfigDir(t, func(configDir string) { + withPatchedGlobals(t, configDir, func() { + sm := &ServiceManager{} + require.NoError(t, sm.CreateDefaultProfile()) + require.NoError(t, sm.SetActiveProfileStateToDefault()) + + const switched = ID("0123456789abcdef0123456789abcdef") + const rounds = 50 + + var wg sync.WaitGroup + errs := make(chan error, 128) + + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for r := 0; r < rounds; r++ { + state, err := sm.GetActiveProfileState() + if err != nil { + errs <- fmt.Errorf("read: %w", err) + return + } + if state.ID != defaultProfileName && state.ID != switched { + errs <- fmt.Errorf("read: active profile is %q, which no writer wrote", state.ID) + return + } + } + }() + } + + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for r := 0; r < rounds; r++ { + id := switched + if r%2 == 0 { + id = defaultProfileName + } + if err := sm.SetActiveProfileState(&ActiveProfileState{ID: id, Username: "testuser"}); err != nil { + errs <- fmt.Errorf("switch: %w", err) + return + } + } + }() + } + + wg.Wait() + close(errs) + + for err := range errs { + assert.NoError(t, err, "a switch and a read of the active profile state must not collide") + } + + state, err := sm.GetActiveProfileState() + require.NoError(t, err) + assert.Contains(t, []ID{defaultProfileName, switched}, state.ID, + "the file holds whichever switch landed last, not a mix of the two") + }) + }) +} diff --git a/util/file.go b/util/file.go index 52eb91c0f..4eb2f3ece 100644 --- a/util/file.go +++ b/util/file.go @@ -162,7 +162,7 @@ func writeBytes(ctx context.Context, file string, configDir string, configFileNa return fmt.Errorf("after temp file: %w", ctx.Err()) } - if err = os.Rename(tempFileName, file); err != nil { + if err = renameFile(tempFileName, file); err != nil { return fmt.Errorf("move %s to %s: %w", tempFileName, file, err) } @@ -195,7 +195,7 @@ func openOrCreateFile(file string) (*os.File, error) { // ReadJson reads JSON config file and maps to a provided interface func ReadJson(file string, res interface{}) (interface{}, error) { - f, err := os.Open(file) + f, err := openRead(file) if err != nil { return nil, err } @@ -248,7 +248,7 @@ func ListFiles(dir, pattern string) ([]string, error) { func ReadJsonWithEnvSub(file string, res interface{}) (interface{}, error) { envVars := getEnvMap() - f, err := os.Open(file) + f, err := openRead(file) if err != nil { return nil, err } diff --git a/util/file_nonwindows.go b/util/file_nonwindows.go new file mode 100644 index 000000000..c1db10244 --- /dev/null +++ b/util/file_nonwindows.go @@ -0,0 +1,16 @@ +//go:build !windows + +package util + +import "os" + +// openRead opens path for reading. Only Windows needs more than this: there a +// plain open holds the file against the rename that replaces it. +func openRead(path string) (*os.File, error) { + return os.Open(path) +} + +// renameFile replaces newpath with oldpath. +func renameFile(oldpath, newpath string) error { + return os.Rename(oldpath, newpath) +} diff --git a/util/file_read_test.go b/util/file_read_test.go new file mode 100644 index 000000000..d7276798f --- /dev/null +++ b/util/file_read_test.go @@ -0,0 +1,58 @@ +package util + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadJson_ReadsTheFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + require.NoError(t, os.WriteFile(path, []byte(`{"SomeField": 7}`), 0o600)) + + var got TestConfig + _, err := ReadJson(path, &got) + + require.NoError(t, err) + assert.Equal(t, 7, got.SomeField, "the decoded value") +} + +// Callers tell a missing file from a broken one so they can seed a default in +// its place. The Windows path opens through a root and rebuilds the error, so +// the mapping has to survive that. +func TestReadJson_MissingFileIsErrNotExist(t *testing.T) { + dir := t.TempDir() + + for _, tc := range []struct { + name string + path string + }{ + {"missing file", filepath.Join(dir, "absent.json")}, + {"missing directory", filepath.Join(dir, "absent", "absent.json")}, + } { + t.Run(tc.name, func(t *testing.T) { + var got TestConfig + _, err := ReadJson(tc.path, &got) + + require.Error(t, err) + assert.ErrorIs(t, err, os.ErrNotExist) + assert.Contains(t, err.Error(), tc.path, "the error names the file the caller asked for") + }) + } +} + +func TestReadJson_MalformedFileIsNotErrNotExist(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + require.NoError(t, os.WriteFile(path, []byte("{not json"), 0o600)) + + var got TestConfig + _, err := ReadJson(path, &got) + + require.Error(t, err) + assert.False(t, errors.Is(err, os.ErrNotExist), + "a file that is there but unreadable must not be seeded over: %v", err) +} diff --git a/util/file_windows.go b/util/file_windows.go new file mode 100644 index 000000000..6abf6e308 --- /dev/null +++ b/util/file_windows.go @@ -0,0 +1,79 @@ +package util + +import ( + "errors" + "io/fs" + "os" + "path/filepath" +) + +// openRead opens path for reading without holding it against a rename. +// +// os.Open does not set FILE_SHARE_DELETE on Windows, so you cannot rename an +// open file like on UNIX. This caused concurrency issues with active state +// config file. +// +// os.Root opens through NtCreateFile with delete sharing, which is the +// behaviour Unix has. +// https://cs.opensource.google/go/go/+/refs/tags/go1.27.1:src/os/root_windows.go;drc=a4f5d9bbdbdf42da7e2d7e976ac85753c4db5d75;l=176 +func openRead(path string) (*os.File, error) { + root, err := os.OpenRoot(filepath.Dir(path)) + if err != nil { + // Names the file the caller asked for, not the directory the root + // failed on, so a missing directory reads like a missing file. + return nil, pathError("open", path, err) + } + defer func() { _ = root.Close() }() + + // The file outlives the root: closing a Root closes the directory handle it + // holds, not the files opened through it. + f, err := root.Open(filepath.Base(path)) + if err != nil { + return nil, pathError("open", path, err) + } + return f, nil +} + +// renameFile replaces newpath with oldpath, including while something holds +// newpath open for reading. +// +// os.Root.Rename asks for POSIX semantics, which unlink the destination +// immediately and leave open handles reading the version they opened. +// https://cs.opensource.google/go/go/+/master:src/internal/syscall/windows/at_windows.go;drc=a4f5d9bbdbdf42da7e2d7e976ac85753c4db5d75;l=384 +func renameFile(oldpath, newpath string) error { + dir := filepath.Dir(newpath) + if filepath.Dir(oldpath) != dir { + return os.Rename(oldpath, newpath) + } + + root, err := os.OpenRoot(dir) + if err != nil { + return os.Rename(oldpath, newpath) + } + defer func() { _ = root.Close() }() + + if err := root.Rename(filepath.Base(oldpath), filepath.Base(newpath)); err != nil { + return linkError("rename", oldpath, newpath, err) + } + return nil +} + +// pathError restores the full path on an error from a root, which names the +// file by the base name it was opened with. +func pathError(op, path string, err error) error { + var perr *fs.PathError + if errors.As(err, &perr) { + err = perr.Err + } + return &fs.PathError{Op: op, Path: path, Err: err} +} + +// linkError does the same as pathError for a rename, which reports both files +// by their base names. +func linkError(op, oldpath, newpath string, err error) error { + var lerr *os.LinkError + if errors.As(err, &lerr) { + err = lerr.Err + } + return &os.LinkError{Op: op, Old: oldpath, New: newpath, Err: err} +} diff --git a/util/file_windows_test.go b/util/file_windows_test.go new file mode 100644 index 000000000..eb7ba344f --- /dev/null +++ b/util/file_windows_test.go @@ -0,0 +1,116 @@ +package util + +import ( + "context" + "io" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// seedReplace lays out a write as writeBytes leaves it: the destination that +// exists and the temp file that is to take its place. +func seedReplace(t *testing.T) (src, dst string) { + t.Helper() + dir := t.TempDir() + src = filepath.Join(dir, ".tmpstate.json") + dst = filepath.Join(dir, "state.json") + require.NoError(t, os.WriteFile(src, []byte(`{"SomeField": 2}`), 0o600)) + require.NoError(t, os.WriteFile(dst, []byte(`{"SomeField": 1}`), 0o600)) + return src, dst +} + +// The reader has to share the file for delete, or the rename cannot take +// delete access on it. Regression test. +func TestRenameFile_ReplacesAFileBeingRead(t *testing.T) { + t.Run("a reader that shares delete", func(t *testing.T) { + src, dst := seedReplace(t) + + f, err := openRead(dst) + require.NoError(t, err) + defer f.Close() + + require.Error(t, os.Rename(src, dst), + "delete sharing alone has to be too little, or this test proves nothing") + require.NoError(t, renameFile(src, dst), "POSIX semantics have to get the replace through") + + // The handle stays on the file it opened, so a read in flight finishes + // on that version instead of seeing the replacement. + held, err := io.ReadAll(f) + require.NoError(t, err) + assert.JSONEq(t, `{"SomeField": 1}`, string(held), "the version the reader opened") + + landed, err := os.ReadFile(dst) + require.NoError(t, err) + assert.JSONEq(t, `{"SomeField": 2}`, string(landed), "the version the writer put there") + }) + + t.Run("a reader that does not", func(t *testing.T) { + src, dst := seedReplace(t) + + f, err := os.Open(dst) + require.NoError(t, err) + defer f.Close() + + require.Error(t, renameFile(src, dst), + "a plain read still holds the file, and the caller is owed that error") + }) + + t.Run("no readers at all", func(t *testing.T) { + src, dst := seedReplace(t) + + require.NoError(t, renameFile(src, dst)) + + landed, err := os.ReadFile(dst) + require.NoError(t, err) + assert.JSONEq(t, `{"SomeField": 2}`, string(landed), "the destination holds what replaced it") + }) +} + +// A config rewritten while it is being read, which is the daemon reading the +// active profile against a profile switch writing it. +func TestReadJsonWriteJson_Concurrently(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + require.NoError(t, WriteJson(context.Background(), path, &TestConfig{SomeField: 1})) + + var wg sync.WaitGroup + errs := make(chan error, 128) + + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for r := 0; r < 50; r++ { + var got TestConfig + if _, err := ReadJson(path, &got); err != nil { + errs <- err + return + } + } + }() + } + + for i := 0; i < 2; i++ { + wg.Add(1) + go func(writer int) { + defer wg.Done() + for r := 0; r < 50; r++ { + if err := WriteJson(context.Background(), path, &TestConfig{SomeField: writer}); err != nil { + errs <- err + return + } + } + }(i) + } + + wg.Wait() + close(errs) + + for err := range errs { + assert.NoError(t, err, "a read and a write of the same config must not collide") + } +} From 507415f870fdc0638288cec4088b14d5f92911e3 Mon Sep 17 00:00:00 2001 From: Misha Bragin Date: Thu, 24 Sep 2026 11:06:16 +0200 Subject: [PATCH 06/33] [client] Fix RPM metadata for Red Hat certification (#7614) Goreleaser's RPM build is split into one nfpm entry per architecture, each pinned to a single-arch build, with the version substituted from the release job. The deb package, archives, and container images are unaffected. Also fixes rpmlint: incoherent-version-in-changelog, found while investigating: nfpm writes the changelog title straight from semver and never appends the release, so entries read 0.79.0 against a 0.79.0-1 package. --- .github/workflows/release.yml | 11 ++++-- .gitignore | 3 ++ .goreleaser.yaml | 65 +++++++++++++++++++++++++++++++--- release_files/rpm-changelog.sh | 35 ++++++++++++++++++ release_files/rpm-provides.sh | 46 ++++++++++++++++++++++++ 5 files changed, 153 insertions(+), 7 deletions(-) create mode 100644 release_files/rpm-provides.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 51426a7ce..37c6fed6c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -191,6 +191,9 @@ jobs: # requires a changelog. Generated, not committed (see .gitignore). # chglog is a go.mod tool directive, so go.sum pins it and its deps. run: bash release_files/rpm-changelog.sh + - name: Fill the RPM ISA provide version + # nfpm cannot emit rpmbuild's ISA provide and GoReleaser cannot template it. + run: bash release_files/rpm-provides.sh - name: Set up QEMU uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 #v4.1.0 - name: Set up Docker Buildx @@ -230,14 +233,18 @@ jobs: uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 with: version: ${{ env.GORELEASER_VER }} - args: release --clean ${{ env.flags }} + args: release --config .goreleaser.generated.yaml --clean ${{ env.flags }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} UPLOAD_DEBIAN_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }} UPLOAD_YUM_SECRET: ${{ secrets.PKG_UPLOAD_SECRET }} GPG_RPM_KEY_FILE: ${{ env.GPG_RPM_KEY_FILE }} - NFPM_NETBIRD_RPM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }} + # One per nfpm id: GoReleaser looks the passphrase up as NFPM__PASSPHRASE. + NFPM_NETBIRD_RPM_AMD64_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }} + NFPM_NETBIRD_RPM_ARM64_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }} + NFPM_NETBIRD_RPM_ARM_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }} + NFPM_NETBIRD_RPM_386_PASSPHRASE: ${{ secrets.GPG_RPM_PASSPHRASE }} SKIP_PUBLISH: ${{ env.SKIP_PUBLISH }} SKIP_DOCKER_PUSH: ${{ env.SKIP_DOCKER_PUSH }} - name: Verify RPM signatures diff --git a/.gitignore b/.gitignore index dd7eea76f..5c01f6e60 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,7 @@ management/server/types/testdata/ # generated by chglog in the release workflow, embedded into the RPM changelog.yml + +# generated by rpm-provides.sh, the config GoReleaser actually runs +.goreleaser.generated.yaml .chglog.yml diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 19528f88e..b6c563968 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -40,6 +40,32 @@ builds: tags: - load_wgnt_from_rsrc + # Single-arch builds: nfpm provides is not templated, so the RPM splits per arch. + - &netbird_rpm_build + id: netbird-rpm-amd64 + dir: client + binary: netbird + env: [CGO_ENABLED=0] + goos: [linux] + goarch: [amd64] + ldflags: + - -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser + mod_timestamp: "{{ .CommitTimestamp }}" + tags: + - load_wgnt_from_rsrc + + - <<: *netbird_rpm_build + id: netbird-rpm-arm64 + goarch: [arm64] + + - <<: *netbird_rpm_build + id: netbird-rpm-arm + goarch: [arm] + + - <<: *netbird_rpm_build + id: netbird-rpm-386 + goarch: [386] + - id: netbird-static dir: client binary: netbird @@ -223,17 +249,22 @@ nfpms: postinstall: "release_files/post_install.sh" preremove: "release_files/pre_remove.sh" - - maintainer: Netbird + - &netbird_rpm + maintainer: Netbird description: Netbird client. homepage: https://netbird.io/ license: BSD-3-Clause vendor: NetBird - id: netbird_rpm + id: netbird_rpm_amd64 bindir: /usr/bin - builds: - - netbird + ids: + - netbird-rpm-amd64 formats: - rpm + # Red Hat certification (RPM Version Handling) requires rpmbuild's ISA + # provide, which nfpm does not emit. The version is filled in by the release job. + provides: + - "netbird(x86-64) = @RPM_EVR@" # The client verifies TLS to management and signal against the system trust # store. Red Hat software certification (RPM Dependency Tracking) also # rejects packages that declare no dependencies at all. @@ -263,6 +294,27 @@ nfpms: packager: NetBird signature: key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}' + + - <<: *netbird_rpm + id: netbird_rpm_arm64 + ids: + - netbird-rpm-arm64 + provides: + - "netbird(aarch-64) = @RPM_EVR@" + + - <<: *netbird_rpm + id: netbird_rpm_arm + ids: + - netbird-rpm-arm + provides: + - "netbird(armv6hl-32) = @RPM_EVR@" + + - <<: *netbird_rpm + id: netbird_rpm_386 + ids: + - netbird-rpm-386 + provides: + - "netbird(x86-32) = @RPM_EVR@" dockers_v2: - id: netbird disable: "{{ .Env.SKIP_DOCKER_PUSH }}" @@ -513,7 +565,10 @@ uploads: - name: yum skip: "{{ .Env.SKIP_PUBLISH }}" ids: - - netbird_rpm + - netbird_rpm_amd64 + - netbird_rpm_arm64 + - netbird_rpm_arm + - netbird_rpm_386 mode: archive target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }} username: dev@wiretrustee.com diff --git a/release_files/rpm-changelog.sh b/release_files/rpm-changelog.sh index 20af2d415..d9150399e 100755 --- a/release_files/rpm-changelog.sh +++ b/release_files/rpm-changelog.sh @@ -7,6 +7,12 @@ # template headings, review checklists, HTML comments and Co-authored-by # trailers. None of that belongs in a package on Red Hat's catalog, and it is # most of the changelog's size. Keep the subject line and drop the rest. +# +# chglog also records the bare tag as each entry's version, while nfpm writes +# that string into the changelog header verbatim and never appends the release. +# rpmlint then reports incoherent-version-in-changelog, because the entry reads +# 0.79.0 while the package is 0.79.0-1. Rewrite each version the way nfpm +# renders the package EVR. set -eu @@ -20,14 +26,29 @@ path = sys.argv[1] lines = open(path, encoding="utf-8").read().split("\n") NOTE = re.compile(r"^ note: (.*)$") +SEMVER = re.compile(r"^- semver: (.*)$") BLOCK = {"|", "|-", "|+", ">", ">-", ">+"} +# nfpm defaults the RPM release to 1 and the packaging sets no other value. +RELEASE = "1" + def quote(text): """Render text as a YAML single-quoted scalar.""" return " note: '{}'".format(text.replace("'", "''")) +def evr(version): + """Render a semver tag the way nfpm renders the package EVR.""" + version, _, metadata = version.partition("+") + core, _, prerelease = version.partition("-") + if prerelease: + core += "~" + prerelease.replace("-", "_") + if metadata: + core += "+" + metadata + return "{}-{}".format(core, RELEASE) + + def first_line_of_double_quoted(value): """Text of a double-quoted scalar up to its first \\n escape.""" out = [] @@ -52,6 +73,13 @@ seen = 0 i = 0 while i < len(lines): line = lines[i] + + m = SEMVER.match(line) + if m: + out.append("- semver: '{}'".format(evr(m.group(1)))) + i += 1 + continue + m = NOTE.match(line) if not m: out.append(line) @@ -107,4 +135,11 @@ if grep -nE '^ note: ".*\\n' changelog.yml; then exit 1 fi +# Every entry must carry the release, or rpmlint reports the changelog version +# as incoherent with the package again. +if grep -nE "^- semver: " changelog.yml | grep -vE -- "-[0-9]+'$"; then + echo "changelog entries without the RPM release survived the rewrite" >&2 + exit 1 +fi + test -s changelog.yml diff --git a/release_files/rpm-provides.sh b/release_files/rpm-provides.sh new file mode 100644 index 000000000..b1332c18b --- /dev/null +++ b/release_files/rpm-provides.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# +# Write .goreleaser.generated.yaml with the @RPM_EVR@ placeholder filled in. +# +# Red Hat certification (RPM Version Handling) expects rpmbuild's ISA provide, +# netbird(x86-64) = . nfpm does not emit it and GoReleaser does not template +# the provides field, so the version is substituted before GoReleaser runs. +# +# The value has to match what nfpm derives from the same tag: a semver +# prerelease becomes a tilde suffix, and the release defaults to 1. + +set -eu + +OUT=.goreleaser.generated.yaml + +TAG="${GITHUB_REF#refs/tags/}" +case "$TAG" in +v*) ;; +*) TAG=$(git describe --tags --abbrev=0) ;; +esac + +EVR=$(python3 - "$TAG" <<'PYEOF' +import sys + +version = sys.argv[1].lstrip("v") +version, _, metadata = version.partition("+") +core, _, prerelease = version.partition("-") +if prerelease: + core += "~" + prerelease.replace("-", "_") +if metadata: + core += "+" + metadata +print("{}-1".format(core)) +PYEOF +) + +# Written to a separate, ignored file: GoReleaser refuses to release from a +# dirty tree, so .goreleaser.yaml itself must stay untouched. +sed "s/@RPM_EVR@/${EVR}/g" .goreleaser.yaml > "$OUT" + +# A surviving placeholder means the provides entries moved or were renamed. +if grep -n "@RPM_EVR@" "$OUT"; then + echo "unsubstituted @RPM_EVR@ left in $OUT" >&2 + exit 1 +fi + +echo "rpm provides version: ${EVR} -> ${OUT}" From 94106b57ab3958f433cae47a4182521fca973016 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Thu, 24 Sep 2026 14:51:01 +0200 Subject: [PATCH 07/33] [misc] Bump workflow actions off the retired Node 20 runtime (#7644) GitHub Actions runners no longer ship Node 20 for JavaScript actions, and the ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION opt-out is gone, so any action whose own action.yml declares `runs.using: node20` now fails to start. Eleven call sites across three workflows were still on such actions: actions/setup-node (five), pnpm/action-setup (four), actions/cache and actions/setup-go (one each). Every target was verified by reading `runs.using` out of the pinned ref's action.yml rather than inferred from its version number. Pin style is preserved per call site: SHA-pinned refs stay SHA-pinned with a corrected `# vX.Y.Z` comment, tag-pinned refs stay tag-pinned. cache and setup-go go to v6 rather than the newest release so the stragglers join the versions the rest of this repo already runs. Breaking changes were checked and none apply. The setup-node v5/v6 automatic dependency caching never triggers: it resolves package.json from the repo root, which does not exist here, and the frontend caches the pnpm store itself. v7 drops the dummy NODE_AUTH_TOKEN export and adds cache outputs, neither of which any workflow reads. setup-go v6 reworks toolchain selection, but the one straggler passes the same go-version-file and `cache: false` as the 22 setup-go v6.5.0 pins already in CI. The v5/v6 runner floor is met because every job runs on GitHub-hosted runners. pnpm/action-setup v4 added a hard error when the `version:` input disagrees with package.json's `packageManager`. It stays dormant here only because the action looks for package.json at the repo root and swallows the resulting ENOENT; all four sites pass `version: 11` while client/ui/frontend/package.json says pnpm@11.4.0. Left alone to keep this change to the runtime bump, but adding a root package.json or setting `package_json_file` would make all four fail. git-town/action is knowingly left on node20. Every release through the latest v1.3.3, and main HEAD, still declares `runs.using: node20`, so there is nothing to bump to. That job will break when the runtime is retired and needs its own decision: drop it, fork the action onto node24, or file upstream. node-version stays at 22. That is the Node toolchain used to build the frontend, not an action runtime, so this retirement does not touch it, and the build cannot be validated on this host because the binding generator needs Linux-only GTK4 and WebKitGTK dev packages. It belongs in a separately verified change. --- .github/workflows/frontend-ui.yml | 6 +++--- .github/workflows/release.yml | 14 +++++++------- .github/workflows/ui-translations.yml | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/frontend-ui.yml b/.github/workflows/frontend-ui.yml index 014c5c2ae..2ad43c581 100644 --- a/.github/workflows/frontend-ui.yml +++ b/.github/workflows/frontend-ui.yml @@ -38,12 +38,12 @@ jobs: persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: "22" - name: Set up pnpm - uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 11 @@ -79,7 +79,7 @@ jobs: run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - name: Cache pnpm store - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ steps.pnpm-store.outputs.path }} key: ${{ runner.os }}-pnpm-${{ hashFiles('client/ui/frontend/pnpm-lock.yaml') }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37c6fed6c..8e51890db 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -424,12 +424,12 @@ jobs: run: git --no-pager diff --exit-code - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' - name: Set up pnpm - uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 11 @@ -561,12 +561,12 @@ jobs: run: git --no-pager diff --exit-code - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '22' - name: Set up pnpm - uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 11 @@ -658,11 +658,11 @@ jobs: - name: check git status run: git --no-pager diff --exit-code - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' - name: Set up pnpm - uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0 + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 11 - name: Install wails3 CLI @@ -781,7 +781,7 @@ jobs: run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/ShellExecAsUser_amd64-Unicode.7z" - name: Set up Go for wails3 CLI - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: "go.mod" cache: false diff --git a/.github/workflows/ui-translations.yml b/.github/workflows/ui-translations.yml index 7d3b12f2d..24b7c9de2 100644 --- a/.github/workflows/ui-translations.yml +++ b/.github/workflows/ui-translations.yml @@ -32,7 +32,7 @@ jobs: persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: "22" From 0a128cea6fae8bfdda618797e4a9c251118a07c0 Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Thu, 24 Sep 2026 17:34:58 +0300 Subject: [PATCH 08/33] [misc] Add upload URL signing and rate limiting (#7502) --- .../server/http/middleware/rate_limiter.go | 31 +++- .../http/middleware/rate_limiter_test.go | 49 +++++- upload-server/server/local.go | 51 ++++-- upload-server/server/local_test.go | 46 ++++-- upload-server/server/ratelimit.go | 31 ++++ upload-server/server/ratelimit_test.go | 60 +++++++ upload-server/server/s3.go | 6 +- upload-server/server/s3_test.go | 2 +- upload-server/server/server.go | 21 ++- upload-server/server/signing.go | 90 +++++++++++ upload-server/server/signing_test.go | 148 ++++++++++++++++++ 11 files changed, 488 insertions(+), 47 deletions(-) create mode 100644 upload-server/server/ratelimit.go create mode 100644 upload-server/server/ratelimit_test.go create mode 100644 upload-server/server/signing.go create mode 100644 upload-server/server/signing_test.go diff --git a/management/server/http/middleware/rate_limiter.go b/management/server/http/middleware/rate_limiter.go index bfd44afee..6995e71bb 100644 --- a/management/server/http/middleware/rate_limiter.go +++ b/management/server/http/middleware/rate_limiter.go @@ -14,12 +14,14 @@ import ( "golang.org/x/time/rate" "github.com/netbirdio/netbird/shared/management/http/util" + "github.com/netbirdio/netbird/trustedproxy" ) const ( - RateLimitingEnabledEnv = "NB_API_RATE_LIMITING_ENABLED" - RateLimitingBurstEnv = "NB_API_RATE_LIMITING_BURST" - RateLimitingRPMEnv = "NB_API_RATE_LIMITING_RPM" + RateLimitingEnabledEnv = "NB_API_RATE_LIMITING_ENABLED" + RateLimitingBurstEnv = "NB_API_RATE_LIMITING_BURST" + RateLimitingRPMEnv = "NB_API_RATE_LIMITING_RPM" + RateLimitingTrustedProxiesEnv = "NB_API_RATE_LIMITING_TRUSTED_PROXIES" defaultAPIRPM = 6 defaultAPIBurst = 500 @@ -35,6 +37,9 @@ type RateLimiterConfig struct { CleanupInterval time.Duration // LimiterTTL defines how long a limiter should be kept after last use (age threshold for removal) LimiterTTL time.Duration + // TrustedProxies lists the upstream proxies whose forwarding headers may be + // believed. Empty means requests are keyed by their direct peer address. + TrustedProxies *trustedproxy.List } // DefaultRateLimiterConfig returns a default configuration @@ -76,11 +81,18 @@ func RateLimiterConfigFromEnv() (cfg *RateLimiterConfig, enabled bool) { burst = defaultAPIBurst } + trusted, err := trustedproxy.Parse(os.Getenv(RateLimitingTrustedProxiesEnv)) + if err != nil { + log.Warnf("parsing %s env var: %v, trusting no proxies", RateLimitingTrustedProxiesEnv, err) + trusted = nil + } + return &RateLimiterConfig{ RequestsPerMinute: float64(rpm), Burst: burst, CleanupInterval: 6 * time.Hour, LimiterTTL: 24 * time.Hour, + TrustedProxies: trusted, }, os.Getenv(RateLimitingEnabledEnv) == "true" } @@ -250,7 +262,7 @@ func (rl *APIRateLimiter) Middleware(next http.Handler) http.Handler { next.ServeHTTP(w, r) return } - clientIP := getClientIP(r) + clientIP := getClientIP(r, rl.config.TrustedProxies) if !rl.Allow(clientIP) { util.WriteErrorResponse("rate limit exceeded, please try again later", http.StatusTooManyRequests, w) return @@ -259,8 +271,15 @@ func (rl *APIRateLimiter) Middleware(next http.Handler) http.Handler { }) } -// getClientIP extracts the client IP address from the request. -func getClientIP(r *http.Request) string { +// getClientIP extracts the client IP address from the request. Forwarding headers +// are used only when the request arrives from a trusted proxy. +func getClientIP(r *http.Request, trusted *trustedproxy.List) string { + if !trusted.Empty() { + if addr := trusted.ResolveClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For")); addr.IsValid() { + return addr.String() + } + } + ip, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr diff --git a/management/server/http/middleware/rate_limiter_test.go b/management/server/http/middleware/rate_limiter_test.go index 4b97d1874..c647030c7 100644 --- a/management/server/http/middleware/rate_limiter_test.go +++ b/management/server/http/middleware/rate_limiter_test.go @@ -9,6 +9,9 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/trustedproxy" ) func TestAPIRateLimiter_Allow(t *testing.T) { @@ -134,7 +137,7 @@ func TestGetClientIP(t *testing.T) { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/test", nil) req.RemoteAddr = tc.remoteAddr - assert.Equal(t, tc.expected, getClientIP(req)) + assert.Equal(t, tc.expected, getClientIP(req, nil)) }) } } @@ -327,3 +330,47 @@ func TestRateLimiterConfigFromEnv(t *testing.T) { assert.Equal(t, float64(defaultAPIRPM), cfg.RequestsPerMinute, "non-positive rpm must fall back to default") assert.Equal(t, defaultAPIBurst, cfg.Burst, "non-positive burst must fall back to default") } + +func TestGetClientIP_TrustedProxies(t *testing.T) { + trusted, err := trustedproxy.Parse("10.0.0.0/8") + require.NoError(t, err) + + tests := []struct { + name string + list *trustedproxy.List + remoteAddr string + xff string + expected string + }{ + { + name: "no trusted proxies ignores the header", + remoteAddr: "10.0.0.1:5555", + xff: "1.1.1.1, 2.2.2.2", + expected: "10.0.0.1", + }, + { + name: "behind a trusted proxy uses the right-most untrusted hop", + list: trusted, + remoteAddr: "10.0.0.1:5555", + xff: "1.1.1.1, 2.2.2.2", + expected: "2.2.2.2", + }, + { + name: "a caller reaching us directly cannot forge the header", + list: trusted, + remoteAddr: "203.0.113.5:5555", + xff: "1.1.1.1", + expected: "203.0.113.5", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.RemoteAddr = tc.remoteAddr + req.Header.Set("X-Forwarded-For", tc.xff) + + assert.Equal(t, tc.expected, getClientIP(req, tc.list)) + }) + } +} diff --git a/upload-server/server/local.go b/upload-server/server/local.go index f7ca50011..7db2740f9 100644 --- a/upload-server/server/local.go +++ b/upload-server/server/local.go @@ -8,9 +8,12 @@ import ( "os" "path/filepath" "strings" + "time" log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/management/server/http/middleware" + "github.com/netbirdio/netbird/upload-server/types" ) @@ -20,11 +23,12 @@ const ( ) type local struct { - url string - dir string + url string + dir string + signer *signer } -func configureLocalHandlers(mux *http.ServeMux) error { +func configureLocalHandlers(mux *http.ServeMux, limiter *middleware.APIRateLimiter) error { envURL, ok := os.LookupEnv("SERVER_URL") if !ok { return fmt.Errorf("SERVER_URL environment variable is required") @@ -44,11 +48,17 @@ func configureLocalHandlers(mux *http.ServeMux) error { dir = envDir } - l := &local{ - url: envURL, - dir: dir, + uploadSigner, err := newSigner() + if err != nil { + return err } - mux.HandleFunc(types.GetURLPath, l.handlerGetUploadURL) + + l := &local{ + url: envURL, + dir: dir, + signer: uploadSigner, + } + mux.Handle(types.GetURLPath, limiter.Middleware(http.HandlerFunc(l.handlerGetUploadURL))) mux.HandleFunc(putURLPath+putHandler, l.handlePutRequest) return nil @@ -80,10 +90,11 @@ func (l *local) getUploadURL(objectKey string) (string, error) { return "", fmt.Errorf("failed to parse upload URL: %w", err) } newURL := parsedUploadURL.JoinPath(parsedUploadURL.Path, putURLPath, objectKey) + newURL.RawQuery = l.signer.sign(objectKey, time.Now()).Encode() return newURL.String(), nil } -const maxUploadSize = 150 << 20 +const maxUploadSize = 50 << 20 func (l *local) handlePutRequest(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { @@ -91,13 +102,6 @@ func (l *local) handlePutRequest(w http.ResponseWriter, r *http.Request) { return } - r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, "request body too large or failed to read", http.StatusRequestEntityTooLarge) - return - } - uploadDir := r.PathValue("dir") if uploadDir == "" { http.Error(w, "missing dir path", http.StatusBadRequest) @@ -109,6 +113,19 @@ func (l *local) handlePutRequest(w http.ResponseWriter, r *http.Request) { return } + if err := l.signer.verify(uploadDir+"/"+uploadFile, r.URL.Query(), time.Now()); err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + log.Warnf("Rejected upload of %s/%s: %v", uploadDir, uploadFile, err) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "request body too large or failed to read", http.StatusRequestEntityTooLarge) + return + } + cleanBase := filepath.Clean(l.dir) + string(filepath.Separator) dirPath := filepath.Clean(filepath.Join(l.dir, uploadDir)) @@ -125,14 +142,14 @@ func (l *local) handlePutRequest(w http.ResponseWriter, r *http.Request) { return } - if err = os.MkdirAll(dirPath, 0750); err != nil { + if err = os.MkdirAll(dirPath, 0o750); err != nil { http.Error(w, "failed to create upload dir", http.StatusInternalServerError) log.Errorf("Failed to create upload dir: %v", err) return } flags := os.O_WRONLY | os.O_CREATE | os.O_EXCL - f, err := os.OpenFile(filePath, flags, 0600) + f, err := os.OpenFile(filePath, flags, 0o600) if err != nil { if os.IsExist(err) { http.Error(w, "file already exists", http.StatusConflict) diff --git a/upload-server/server/local_test.go b/upload-server/server/local_test.go index 64b8fd228..3504087e9 100644 --- a/upload-server/server/local_test.go +++ b/upload-server/server/local_test.go @@ -8,19 +8,28 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/upload-server/types" ) +const testSigningKey = "test-signing-key-with-enough-length" + +func signedQuery(t *testing.T, objectKey string) string { + t.Helper() + s := &signer{key: []byte(testSigningKey)} + return s.sign(objectKey, time.Now()).Encode() +} + func Test_LocalHandlerGetUploadURL(t *testing.T) { mockURL := "http://localhost:8080" t.Setenv("SERVER_URL", mockURL) t.Setenv("STORE_DIR", t.TempDir()) mux := http.NewServeMux() - err := configureLocalHandlers(mux) + err := configureLocalHandlers(mux, newTestRateLimiter(t)) require.NoError(t, err) req := httptest.NewRequest(http.MethodGet, types.GetURLPath+"?id=test-file", nil) @@ -37,7 +46,6 @@ func Test_LocalHandlerGetUploadURL(t *testing.T) { require.Contains(t, response.URL, "test-file/") require.NotEmpty(t, response.Key) require.Contains(t, response.Key, "test-file/") - } func Test_LocalHandlePutRequest(t *testing.T) { @@ -45,13 +53,15 @@ func Test_LocalHandlePutRequest(t *testing.T) { mockURL := "http://localhost:8080" t.Setenv("SERVER_URL", mockURL) t.Setenv("STORE_DIR", mockDir) + t.Setenv(signingKeyVar, testSigningKey) mux := http.NewServeMux() - err := configureLocalHandlers(mux) + err := configureLocalHandlers(mux, newTestRateLimiter(t)) require.NoError(t, err) fileContent := []byte("test file content") - req := httptest.NewRequest(http.MethodPut, putURLPath+"/uploads/test.txt", bytes.NewReader(fileContent)) + req := httptest.NewRequest(http.MethodPut, + putURLPath+"/uploads/test.txt?"+signedQuery(t, "uploads/test.txt"), bytes.NewReader(fileContent)) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) @@ -69,13 +79,16 @@ func Test_LocalHandlePutRequest_PathTraversal(t *testing.T) { mockURL := "http://localhost:8080" t.Setenv("SERVER_URL", mockURL) t.Setenv("STORE_DIR", mockDir) + t.Setenv(signingKeyVar, testSigningKey) mux := http.NewServeMux() - err := configureLocalHandlers(mux) + err := configureLocalHandlers(mux, newTestRateLimiter(t)) require.NoError(t, err) fileContent := []byte("malicious content") - req := httptest.NewRequest(http.MethodPut, putURLPath+"/uploads/%2e%2e%2f%2e%2e%2fetc%2fpasswd", bytes.NewReader(fileContent)) + req := httptest.NewRequest(http.MethodPut, + putURLPath+"/uploads/%2e%2e%2f%2e%2e%2fetc%2fpasswd?"+signedQuery(t, "uploads/../../etc/passwd"), + bytes.NewReader(fileContent)) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) @@ -90,11 +103,13 @@ func Test_LocalHandlePutRequest_DirTraversal(t *testing.T) { mockDir := t.TempDir() t.Setenv("SERVER_URL", "http://localhost:8080") t.Setenv("STORE_DIR", mockDir) + t.Setenv(signingKeyVar, testSigningKey) - l := &local{url: "http://localhost:8080", dir: mockDir} + l := &local{url: "http://localhost:8080", dir: mockDir, signer: &signer{key: []byte(testSigningKey)}} body := bytes.NewReader([]byte("bad")) - req := httptest.NewRequest(http.MethodPut, putURLPath+"/x/evil.txt", body) + req := httptest.NewRequest(http.MethodPut, + putURLPath+"/x/evil.txt?"+signedQuery(t, "../../../tmp/evil.txt"), body) req.SetPathValue("dir", "../../../tmp") req.SetPathValue("file", "evil.txt") @@ -111,17 +126,20 @@ func Test_LocalHandlePutRequest_DuplicateFile(t *testing.T) { mockDir := t.TempDir() t.Setenv("SERVER_URL", "http://localhost:8080") t.Setenv("STORE_DIR", mockDir) + t.Setenv(signingKeyVar, testSigningKey) mux := http.NewServeMux() - err := configureLocalHandlers(mux) + err := configureLocalHandlers(mux, newTestRateLimiter(t)) require.NoError(t, err) - req := httptest.NewRequest(http.MethodPut, putURLPath+"/dir/dup.txt", bytes.NewReader([]byte("first"))) + req := httptest.NewRequest(http.MethodPut, + putURLPath+"/dir/dup.txt?"+signedQuery(t, "dir/dup.txt"), bytes.NewReader([]byte("first"))) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) - req = httptest.NewRequest(http.MethodPut, putURLPath+"/dir/dup.txt", bytes.NewReader([]byte("second"))) + req = httptest.NewRequest(http.MethodPut, + putURLPath+"/dir/dup.txt?"+signedQuery(t, "dir/dup.txt"), bytes.NewReader([]byte("second"))) rec = httptest.NewRecorder() mux.ServeHTTP(rec, req) require.Equal(t, http.StatusConflict, rec.Code) @@ -135,13 +153,15 @@ func Test_LocalHandlePutRequest_BodyTooLarge(t *testing.T) { mockDir := t.TempDir() t.Setenv("SERVER_URL", "http://localhost:8080") t.Setenv("STORE_DIR", mockDir) + t.Setenv(signingKeyVar, testSigningKey) mux := http.NewServeMux() - err := configureLocalHandlers(mux) + err := configureLocalHandlers(mux, newTestRateLimiter(t)) require.NoError(t, err) largeBody := make([]byte, maxUploadSize+1) - req := httptest.NewRequest(http.MethodPut, putURLPath+"/dir/big.txt", bytes.NewReader(largeBody)) + req := httptest.NewRequest(http.MethodPut, + putURLPath+"/dir/big.txt?"+signedQuery(t, "dir/big.txt"), bytes.NewReader(largeBody)) rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) diff --git a/upload-server/server/ratelimit.go b/upload-server/server/ratelimit.go new file mode 100644 index 000000000..081cf89c1 --- /dev/null +++ b/upload-server/server/ratelimit.go @@ -0,0 +1,31 @@ +package server + +import ( + "os" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/management/server/http/middleware" +) + +const defaultUploadBurst = 100 + +func newRateLimiter() *middleware.APIRateLimiter { + cfg, enabled := middleware.RateLimiterConfigFromEnv() + if os.Getenv(middleware.RateLimitingBurstEnv) == "" { + cfg.Burst = defaultUploadBurst + } + + // Rate limiting is enabled by default unless explicitly disabled + if os.Getenv(middleware.RateLimitingEnabledEnv) == "" { + enabled = true + } + + limiter := middleware.NewAPIRateLimiter(cfg) + limiter.SetEnabled(enabled) + + log.Infof("Upload URL rate limiting: enabled=%t rate=%.0f/min burst=%d trusted_proxies=%q", + limiter.Enabled(), cfg.RequestsPerMinute, cfg.Burst, os.Getenv(middleware.RateLimitingTrustedProxiesEnv)) + + return limiter +} diff --git a/upload-server/server/ratelimit_test.go b/upload-server/server/ratelimit_test.go new file mode 100644 index 000000000..8414a1c09 --- /dev/null +++ b/upload-server/server/ratelimit_test.go @@ -0,0 +1,60 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/http/middleware" + "github.com/netbirdio/netbird/upload-server/types" +) + +func newTestRateLimiter(t *testing.T) *middleware.APIRateLimiter { + t.Helper() + + limiter := newRateLimiter() + t.Cleanup(limiter.Stop) + + return limiter +} + +func getUploadURL(t *testing.T, mux *http.ServeMux) int { + t.Helper() + + req := httptest.NewRequest(http.MethodGet, types.GetURLPath+"?id=test-file", nil) + req.Header.Set(types.ClientHeader, types.ClientHeaderValue) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + return rec.Code +} + +func Test_GetUploadURLIsRateLimited(t *testing.T) { + t.Setenv(middleware.RateLimitingBurstEnv, "2") + t.Setenv(middleware.RateLimitingRPMEnv, "1") + mux, _ := newLocalMux(t) + + require.Equal(t, http.StatusOK, getUploadURL(t, mux)) + require.Equal(t, http.StatusOK, getUploadURL(t, mux)) + require.Equal(t, http.StatusTooManyRequests, getUploadURL(t, mux)) +} + +func Test_RateLimitingIsOnByDefault(t *testing.T) { + t.Setenv(middleware.RateLimitingEnabledEnv, "") + t.Setenv(middleware.RateLimitingBurstEnv, "1") + mux, _ := newLocalMux(t) + + require.Equal(t, http.StatusOK, getUploadURL(t, mux)) + require.Equal(t, http.StatusTooManyRequests, getUploadURL(t, mux)) +} + +func Test_RateLimitingCanBeDisabled(t *testing.T) { + t.Setenv(middleware.RateLimitingEnabledEnv, "false") + t.Setenv(middleware.RateLimitingBurstEnv, "1") + mux, _ := newLocalMux(t) + + require.Equal(t, http.StatusOK, getUploadURL(t, mux)) + require.Equal(t, http.StatusOK, getUploadURL(t, mux)) +} diff --git a/upload-server/server/s3.go b/upload-server/server/s3.go index c0976acb5..ffc5df01f 100644 --- a/upload-server/server/s3.go +++ b/upload-server/server/s3.go @@ -12,6 +12,8 @@ import ( "github.com/aws/aws-sdk-go-v2/service/s3" log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/management/server/http/middleware" + "github.com/netbirdio/netbird/upload-server/types" ) @@ -21,7 +23,7 @@ type sThree struct { presignClient *s3.PresignClient } -func configureS3Handlers(mux *http.ServeMux) error { +func configureS3Handlers(mux *http.ServeMux, limiter *middleware.APIRateLimiter) error { bucket := os.Getenv(bucketVar) region, ok := os.LookupEnv("AWS_REGION") if !ok { @@ -40,7 +42,7 @@ func configureS3Handlers(mux *http.ServeMux) error { bucket: bucket, presignClient: s3.NewPresignClient(client), } - mux.HandleFunc(types.GetURLPath, handler.handlerGetUploadURL) + mux.Handle(types.GetURLPath, limiter.Middleware(http.HandlerFunc(handler.handlerGetUploadURL))) return nil } diff --git a/upload-server/server/s3_test.go b/upload-server/server/s3_test.go index 110b1b780..cba65c8c8 100644 --- a/upload-server/server/s3_test.go +++ b/upload-server/server/s3_test.go @@ -90,7 +90,7 @@ func Test_S3HandlerGetUploadURL(t *testing.T) { t.Setenv(bucketVar, bucketName) mux := http.NewServeMux() - err = configureS3Handlers(mux) + err = configureS3Handlers(mux, newTestRateLimiter(t)) require.NoError(t, err) req := httptest.NewRequest(http.MethodGet, types.GetURLPath+"?id=test-file", nil) diff --git a/upload-server/server/server.go b/upload-server/server/server.go index 29ef72732..607c5bdad 100644 --- a/upload-server/server/server.go +++ b/upload-server/server/server.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" log "github.com/sirupsen/logrus" + "github.com/netbirdio/netbird/management/server/http/middleware" "github.com/netbirdio/netbird/upload-server/types" ) @@ -19,7 +20,8 @@ const ( ) type Server struct { - srv *http.Server + srv *http.Server + limiter *middleware.APIRateLimiter } func NewServer() *Server { @@ -29,7 +31,7 @@ func NewServer() *Server { address = "0.0.0.0:8080" } mux := http.NewServeMux() - err := configureMux(mux) + limiter, err := configureMux(mux) if err != nil { log.Fatalf("Failed to configure server: %v", err) } @@ -38,7 +40,8 @@ func NewServer() *Server { }) return &Server{ - srv: &http.Server{Addr: address, Handler: mux}, + srv: &http.Server{Addr: address, Handler: mux}, + limiter: limiter, } } @@ -48,6 +51,9 @@ func (s *Server) Start() error { } func (s *Server) Stop() error { + if s.limiter != nil { + s.limiter.Stop() + } if s.srv != nil { log.Infof("Stopping upload server on %s", s.srv.Addr) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -57,13 +63,14 @@ func (s *Server) Stop() error { return nil } -func configureMux(mux *http.ServeMux) error { +func configureMux(mux *http.ServeMux) (*middleware.APIRateLimiter, error) { + limiter := newRateLimiter() + _, ok := os.LookupEnv(bucketVar) if ok { - return configureS3Handlers(mux) - } else { - return configureLocalHandlers(mux) + return limiter, configureS3Handlers(mux, limiter) } + return limiter, configureLocalHandlers(mux, limiter) } func getObjectKey(w http.ResponseWriter, r *http.Request) string { diff --git a/upload-server/server/signing.go b/upload-server/server/signing.go new file mode 100644 index 000000000..86ee785b2 --- /dev/null +++ b/upload-server/server/signing.go @@ -0,0 +1,90 @@ +package server + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/url" + "os" + "strconv" + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + signingKeyVar = "NB_UPLOAD_SIGNING_KEY" + + // signatureTTL matches the expiry the S3 backend puts on its presigned URLs. + signatureTTL = 15 * time.Minute + + expiryParam = "exp" + signatureParam = "sig" + + minSigningKeyLen = 32 +) + +type signer struct { + key []byte +} + +func newSigner() (*signer, error) { + if env, ok := os.LookupEnv(signingKeyVar); ok { + if env == "" { + return nil, fmt.Errorf("%s is set but empty", signingKeyVar) + } + if len(env) < minSigningKeyLen { + return nil, fmt.Errorf("%s must be at least %d bytes", signingKeyVar, minSigningKeyLen) + } + return &signer{key: []byte(env)}, nil + } + + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return nil, fmt.Errorf("generate signing key: %w", err) + } + log.Infof("%s not set, generated an ephemeral upload signing key", signingKeyVar) + + return &signer{key: key}, nil +} + +// sign returns the query parameters that authorize an upload of objectKey. +func (s *signer) sign(objectKey string, now time.Time) url.Values { + exp := now.Add(signatureTTL).Unix() + + v := url.Values{} + v.Set(expiryParam, strconv.FormatInt(exp, 10)) + v.Set(signatureParam, hex.EncodeToString(s.signature(objectKey, exp))) + + return v +} + +// verify reports whether query carries a still-valid signature over objectKey. +func (s *signer) verify(objectKey string, query url.Values, now time.Time) error { + exp, err := strconv.ParseInt(query.Get(expiryParam), 10, 64) + if err != nil { + return fmt.Errorf("malformed %s parameter", expiryParam) + } + + got, err := hex.DecodeString(query.Get(signatureParam)) + if err != nil { + return fmt.Errorf("malformed %s parameter", signatureParam) + } + + if !hmac.Equal(got, s.signature(objectKey, exp)) { + return fmt.Errorf("signature mismatch") + } + if now.Unix() >= exp { + return fmt.Errorf("upload URL expired") + } + + return nil +} + +func (s *signer) signature(objectKey string, exp int64) []byte { + mac := hmac.New(sha256.New, s.key) + fmt.Fprintf(mac, "%s\n%d", objectKey, exp) + return mac.Sum(nil) +} diff --git a/upload-server/server/signing_test.go b/upload-server/server/signing_test.go new file mode 100644 index 000000000..2fdd4aa3f --- /dev/null +++ b/upload-server/server/signing_test.go @@ -0,0 +1,148 @@ +package server + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/upload-server/types" +) + +func newLocalMux(t *testing.T) (*http.ServeMux, string) { + t.Helper() + + mockDir := t.TempDir() + t.Setenv("SERVER_URL", "http://localhost:8080") + t.Setenv("STORE_DIR", mockDir) + t.Setenv(signingKeyVar, testSigningKey) + + mux := http.NewServeMux() + require.NoError(t, configureLocalHandlers(mux, newTestRateLimiter(t))) + + return mux, mockDir +} + +func Test_LocalUploadURLRoundTrip(t *testing.T) { + mux, mockDir := newLocalMux(t) + + getReq := httptest.NewRequest(http.MethodGet, types.GetURLPath+"?id=test-file", nil) + getReq.Header.Set(types.ClientHeader, types.ClientHeaderValue) + getRec := httptest.NewRecorder() + mux.ServeHTTP(getRec, getReq) + require.Equal(t, http.StatusOK, getRec.Code) + + var response types.GetURLResponse + require.NoError(t, json.Unmarshal(getRec.Body.Bytes(), &response)) + + minted, err := url.Parse(response.URL) + require.NoError(t, err) + require.NotEmpty(t, minted.Query().Get(signatureParam)) + + content := []byte("bundle") + putRec := httptest.NewRecorder() + mux.ServeHTTP(putRec, httptest.NewRequest(http.MethodPut, minted.RequestURI(), bytes.NewReader(content))) + require.Equal(t, http.StatusOK, putRec.Code) + + written, err := os.ReadFile(filepath.Join(mockDir, response.Key)) + require.NoError(t, err) + require.Equal(t, content, written) +} + +func Test_LocalHandlePutRequest_RejectsUnauthorized(t *testing.T) { + expired := &signer{key: []byte(testSigningKey)} + + tests := []struct { + name string + query string + }{ + { + name: "no signature", + query: "", + }, + { + name: "tampered signature", + query: "exp=99999999999&sig=deadbeef", + }, + { + name: "malformed signature", + query: "exp=99999999999&sig=not-hex", + }, + { + // A signature is only good for the key it was minted for, so a URL + // handed out for one bundle cannot be replayed against another. + name: "signature for a different object", + query: signedQuery(t, "dir/other.txt"), + }, + { + name: "signature expiring this second", + query: expired.sign("dir/file.txt", time.Now().Add(-signatureTTL)).Encode(), + }, + { + name: "expired signature", + query: expired.sign("dir/file.txt", time.Now().Add(-signatureTTL-time.Minute)).Encode(), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mux, mockDir := newLocalMux(t) + + target := putURLPath + "/dir/file.txt" + if tc.query != "" { + target += "?" + tc.query + } + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodPut, target, bytes.NewReader([]byte("payload")))) + + require.Equal(t, http.StatusUnauthorized, rec.Code) + + _, err := os.Stat(filepath.Join(mockDir, "dir", "file.txt")) + require.True(t, os.IsNotExist(err), "unauthorized upload should not be written") + }) + } +} + +func Test_SignerRejectsForeignKey(t *testing.T) { + minted := (&signer{key: []byte("one key")}).sign("dir/file.txt", time.Now()) + + err := (&signer{key: []byte("another key")}).verify("dir/file.txt", minted, time.Now()) + require.Error(t, err) +} + +func Test_NewSignerGeneratesEphemeralKey(t *testing.T) { + // Registers the restore hook, then clears the value for this test only. + t.Setenv(signingKeyVar, "") + os.Unsetenv(signingKeyVar) + + first, err := newSigner() + require.NoError(t, err) + second, err := newSigner() + require.NoError(t, err) + + require.NotEqual(t, first.key, second.key) + require.Len(t, first.key, 32) +} + +func Test_NewSignerRejectsEmptyKey(t *testing.T) { + t.Setenv(signingKeyVar, "") + + _, err := newSigner() + require.Error(t, err) +} + +func Test_NewSignerRejectsShortKey(t *testing.T) { + t.Setenv(signingKeyVar, strings.Repeat("a", minSigningKeyLen-1)) + + _, err := newSigner() + require.Error(t, err) +} From 306f642ad3269bf5bd692644dfd54fb282a53fc9 Mon Sep 17 00:00:00 2001 From: Philippe Vaucher Date: Thu, 24 Sep 2026 16:53:30 +0200 Subject: [PATCH 09/33] [misc] Use the Silo image for the S3 upload test (#7619) --- upload-server/server/s3_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/upload-server/server/s3_test.go b/upload-server/server/s3_test.go index cba65c8c8..6c946d8d0 100644 --- a/upload-server/server/s3_test.go +++ b/upload-server/server/s3_test.go @@ -29,7 +29,7 @@ func Test_S3HandlerGetUploadURL(t *testing.T) { ctx := context.Background() c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: testcontainers.ContainerRequest{ - Image: "quay.io/minio/minio:RELEASE.2025-04-22T22-12-26Z", + Image: "pgsty/silo:RELEASE.2026-09-16T00-00-00Z", ExposedPorts: []string{"9000/tcp"}, Env: map[string]string{ "MINIO_ROOT_USER": "minioadmin", From 15a13bc99b2a58a03bb82744af42eef5dc4b6faa Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:58:37 +0200 Subject: [PATCH 10/33] [management] split store by table (#7646) --- management/server/store/sql_store.go | 6276 +---------------- .../server/store/sql_store_access_log.go | 149 + management/server/store/sql_store_account.go | 1148 +++ .../store/sql_store_account_onboarding.go | 70 + .../sql_store_account_onboarding_test.go | 68 + .../server/store/sql_store_account_test.go | 965 +++ .../sql_store_agent_network_access_log.go | 268 + .../store/sql_store_agent_network_usage.go | 91 + .../server/store/sql_store_custom_domain.go | 146 + .../server/store/sql_store_dns_record.go | 109 + .../server/store/sql_store_dns_record_test.go | 260 + management/server/store/sql_store_group.go | 356 + .../server/store/sql_store_group_peer.go | 229 + .../server/store/sql_store_group_peer_test.go | 210 + .../server/store/sql_store_group_test.go | 289 + .../server/store/sql_store_installation.go | 29 + management/server/store/sql_store_job.go | 103 + .../store/sql_store_name_server_group.go | 124 + .../store/sql_store_name_server_group_test.go | 143 + management/server/store/sql_store_network.go | 91 + .../store/sql_store_network_resource.go | 167 + .../store/sql_store_network_resource_test.go | 161 + .../server/store/sql_store_network_router.go | 208 + .../store/sql_store_network_router_test.go | 161 + .../server/store/sql_store_network_test.go | 128 + management/server/store/sql_store_peer.go | 781 ++ .../server/store/sql_store_peer_test.go | 901 +++ .../store/sql_store_personal_access_token.go | 178 + .../sql_store_personal_access_token_test.go | 186 + management/server/store/sql_store_policy.go | 151 + .../server/store/sql_store_policy_rule.go | 88 + .../server/store/sql_store_policy_test.go | 152 + .../server/store/sql_store_posture_checks.go | 137 + .../store/sql_store_posture_checks_test.go | 188 + management/server/store/sql_store_proxy.go | 467 ++ .../store/sql_store_proxy_access_token.go | 127 + management/server/store/sql_store_route.go | 152 + .../server/store/sql_store_route_test.go | 165 + management/server/store/sql_store_service.go | 452 ++ .../server/store/sql_store_service_target.go | 163 + .../server/store/sql_store_setup_key.go | 219 + .../server/store/sql_store_setup_key_test.go | 103 + management/server/store/sql_store_test.go | 4440 ------------ management/server/store/sql_store_user.go | 272 + .../server/store/sql_store_user_invite.go | 139 + .../server/store/sql_store_user_test.go | 343 + management/server/store/sql_store_zone.go | 98 + .../server/store/sql_store_zone_test.go | 238 + 48 files changed, 11374 insertions(+), 10715 deletions(-) create mode 100644 management/server/store/sql_store_access_log.go create mode 100644 management/server/store/sql_store_account.go create mode 100644 management/server/store/sql_store_account_onboarding.go create mode 100644 management/server/store/sql_store_account_onboarding_test.go create mode 100644 management/server/store/sql_store_account_test.go create mode 100644 management/server/store/sql_store_agent_network_access_log.go create mode 100644 management/server/store/sql_store_agent_network_usage.go create mode 100644 management/server/store/sql_store_custom_domain.go create mode 100644 management/server/store/sql_store_dns_record.go create mode 100644 management/server/store/sql_store_dns_record_test.go create mode 100644 management/server/store/sql_store_group.go create mode 100644 management/server/store/sql_store_group_peer.go create mode 100644 management/server/store/sql_store_group_peer_test.go create mode 100644 management/server/store/sql_store_group_test.go create mode 100644 management/server/store/sql_store_installation.go create mode 100644 management/server/store/sql_store_job.go create mode 100644 management/server/store/sql_store_name_server_group.go create mode 100644 management/server/store/sql_store_name_server_group_test.go create mode 100644 management/server/store/sql_store_network.go create mode 100644 management/server/store/sql_store_network_resource.go create mode 100644 management/server/store/sql_store_network_resource_test.go create mode 100644 management/server/store/sql_store_network_router.go create mode 100644 management/server/store/sql_store_network_router_test.go create mode 100644 management/server/store/sql_store_network_test.go create mode 100644 management/server/store/sql_store_peer.go create mode 100644 management/server/store/sql_store_peer_test.go create mode 100644 management/server/store/sql_store_personal_access_token.go create mode 100644 management/server/store/sql_store_personal_access_token_test.go create mode 100644 management/server/store/sql_store_policy.go create mode 100644 management/server/store/sql_store_policy_rule.go create mode 100644 management/server/store/sql_store_policy_test.go create mode 100644 management/server/store/sql_store_posture_checks.go create mode 100644 management/server/store/sql_store_posture_checks_test.go create mode 100644 management/server/store/sql_store_proxy.go create mode 100644 management/server/store/sql_store_proxy_access_token.go create mode 100644 management/server/store/sql_store_route.go create mode 100644 management/server/store/sql_store_route_test.go create mode 100644 management/server/store/sql_store_service.go create mode 100644 management/server/store/sql_store_service_target.go create mode 100644 management/server/store/sql_store_setup_key.go create mode 100644 management/server/store/sql_store_setup_key_test.go create mode 100644 management/server/store/sql_store_user.go create mode 100644 management/server/store/sql_store_user_invite.go create mode 100644 management/server/store/sql_store_user_test.go create mode 100644 management/server/store/sql_store_zone.go create mode 100644 management/server/store/sql_store_zone_test.go diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index b32be5af9..425c1a0cb 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -2,13 +2,8 @@ package store import ( "context" - "database/sql" - "encoding/json" "errors" "fmt" - "math" - "net" - "net/netip" "net/url" "os" "path/filepath" @@ -19,22 +14,18 @@ import ( "sync" "time" - "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" - "github.com/rs/xid" log "github.com/sirupsen/logrus" "gorm.io/driver/mysql" "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" - "gorm.io/gorm/clause" "gorm.io/gorm/logger" nbdns "github.com/netbirdio/netbird/dns" + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" - - agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/internals/modules/zones" @@ -46,9 +37,7 @@ import ( "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/management/server/telemetry" "github.com/netbirdio/netbird/management/server/types" - "github.com/netbirdio/netbird/management/server/util" "github.com/netbirdio/netbird/route" - "github.com/netbirdio/netbird/shared/management/status" "github.com/netbirdio/netbird/util/crypt" ) @@ -82,11 +71,6 @@ type SqlStore struct { transactionTimeout time.Duration } -type installation struct { - ID uint `gorm:"primaryKey"` - InstallationIDValue string -} - type migrationFunc func(*gorm.DB) error // NewSqlStore creates a new SqlStore instance. @@ -162,96 +146,6 @@ func GetKeyQueryCondition(s *SqlStore) string { return keyQueryCondition } -// SaveJob persists a job in DB -func (s *SqlStore) CreatePeerJob(ctx context.Context, job *types.Job) error { - result := s.db.Create(job) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to create job in store: %s", result.Error) - return status.Errorf(status.Internal, "failed to create job in store") - } - return nil -} - -func (s *SqlStore) CompletePeerJob(ctx context.Context, job *types.Job) error { - result := s.db. - Model(&types.Job{}). - Where(idQueryCondition, job.ID). - Updates(job) - - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to update job in store: %s", result.Error) - return status.Errorf(status.Internal, "failed to update job in store") - } - return nil -} - -// job was pending for too long and has been cancelled -func (s *SqlStore) MarkPendingJobsAsFailed(ctx context.Context, accountID, peerID, jobID, reason string) error { - now := time.Now().UTC() - result := s.db. - Model(&types.Job{}). - Where(accountAndPeerIDQueryCondition+" AND id = ?"+" AND status = ?", accountID, peerID, jobID, types.JobStatusPending). - Updates(types.Job{ - Status: types.JobStatusFailed, - FailedReason: reason, - CompletedAt: &now, - }) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to mark pending jobs as Failed job in store: %s", result.Error) - return status.Errorf(status.Internal, "failed to mark pending job as Failed in store") - } - return nil -} - -// job was pending for too long and has been cancelled -func (s *SqlStore) MarkAllPendingJobsAsFailed(ctx context.Context, accountID, peerID, reason string) error { - now := time.Now().UTC() - result := s.db. - Model(&types.Job{}). - Where(accountAndPeerIDQueryCondition+" AND status = ?", accountID, peerID, types.JobStatusPending). - Updates(types.Job{ - Status: types.JobStatusFailed, - FailedReason: reason, - CompletedAt: &now, - }) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to mark pending jobs as Failed job in store: %s", result.Error) - return status.Errorf(status.Internal, "failed to mark pending job as Failed in store") - } - return nil -} - -// GetJobByID fetches job by ID -func (s *SqlStore) GetPeerJobByID(ctx context.Context, accountID, jobID string) (*types.Job, error) { - var job types.Job - err := s.db. - Where(accountAndIDQueryCondition, accountID, jobID). - First(&job).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, status.Errorf(status.NotFound, "job %s not found", jobID) - } - if err != nil { - log.WithContext(ctx).Errorf("failed to fetch job from store: %s", err) - return nil, err - } - return &job, nil -} - -// get all jobs -func (s *SqlStore) GetPeerJobs(ctx context.Context, accountID, peerID string) ([]*types.Job, error) { - var jobs []*types.Job - err := s.db. - Where(accountAndPeerIDQueryCondition, accountID, peerID). - Order("created_at DESC"). - Find(&jobs).Error - if err != nil { - log.WithContext(ctx).Errorf("failed to fetch jobs from store: %s", err) - return nil, err - } - - return jobs, nil -} - // AcquireGlobalLock acquires global lock across all the accounts and returns a function that releases the lock func (s *SqlStore) AcquireGlobalLock(ctx context.Context) (unlock func()) { log.WithContext(ctx).Tracef("acquiring global lock") @@ -272,2779 +166,6 @@ func (s *SqlStore) AcquireGlobalLock(ctx context.Context) (unlock func()) { return unlock } -// Deprecated: Full -// account operations are no longer supported -func (s *SqlStore) SaveAccount(ctx context.Context, account *types.Account) error { - start := time.Now() - defer func() { - elapsed := time.Since(start) - if elapsed > 1*time.Second { - log.WithContext(ctx).Tracef("SaveAccount for account %s exceeded 1s, took: %v", account.Id, elapsed) - } - }() - - // todo: remove this check after the issue is resolved - s.checkAccountDomainBeforeSave(ctx, account.Id, account.Domain) - - generateAccountSQLTypes(account) - - // Encrypt sensitive user data before saving - for i := range account.UsersG { - if err := account.UsersG[i].EncryptSensitiveData(s.fieldEncrypt); err != nil { - return fmt.Errorf("encrypt user: %w", err) - } - } - - for _, group := range account.GroupsG { - group.StoreGroupPeers() - } - - err := s.transaction(func(tx *gorm.DB) error { - result := tx.Select(clause.Associations).Delete(account.Policies, "account_id = ?", account.Id) - if result.Error != nil { - return result.Error - } - - result = tx.Select(clause.Associations).Delete(account.UsersG, "account_id = ?", account.Id) - if result.Error != nil { - return result.Error - } - - result = tx.Select(clause.Associations).Delete(account) - if result.Error != nil { - return result.Error - } - - result = tx. - Session(&gorm.Session{FullSaveAssociations: true}). - Clauses(clause.OnConflict{UpdateAll: true}). - Create(account) - if result.Error != nil { - return result.Error - } - return nil - }) - - took := time.Since(start) - if s.metrics != nil { - s.metrics.StoreMetrics().CountPersistenceDuration(took) - } - log.WithContext(ctx).Debugf("took %d ms to persist an account to the store", took.Milliseconds()) - - return err -} - -// generateAccountSQLTypes generates the GORM compatible types for the account -func generateAccountSQLTypes(account *types.Account) { - for _, key := range account.SetupKeys { - account.SetupKeysG = append(account.SetupKeysG, *key) - } - - if len(account.SetupKeys) != len(account.SetupKeysG) { - log.Warnf("SetupKeysG length mismatch for account %s", account.Id) - } - - for id, peer := range account.Peers { - peer.ID = id - account.PeersG = append(account.PeersG, *peer) - } - - for id, user := range account.Users { - user.Id = id - for id, pat := range user.PATs { - pat.ID = id - user.PATsG = append(user.PATsG, *pat) - } - account.UsersG = append(account.UsersG, *user) - } - - for id, group := range account.Groups { - group.ID = id - group.AccountID = account.Id - account.GroupsG = append(account.GroupsG, group) - } - - for id, route := range account.Routes { - route.ID = id - account.RoutesG = append(account.RoutesG, *route) - } - - for id, ns := range account.NameServerGroups { - ns.ID = id - account.NameServerGroupsG = append(account.NameServerGroupsG, *ns) - } -} - -// checkAccountDomainBeforeSave temporary method to troubleshoot an issue with domains getting blank -func (s *SqlStore) checkAccountDomainBeforeSave(ctx context.Context, accountID, newDomain string) { - var acc types.Account - var domain string - result := s.db.Model(&acc).Select("domain").Where(idQueryCondition, accountID).Take(&domain) - if result.Error != nil { - if !errors.Is(result.Error, gorm.ErrRecordNotFound) { - log.WithContext(ctx).Errorf("error when getting account %s from the store to check domain: %s", accountID, result.Error) - } - return - } - if domain != "" && newDomain == "" { - log.WithContext(ctx).Warnf("saving an account with empty domain when there was a domain set. Previous domain %s, Account ID: %s, Trace: %s", domain, accountID, debug.Stack()) - } -} - -func (s *SqlStore) DeleteAccount(ctx context.Context, account *types.Account) error { - start := time.Now() - - err := s.transaction(func(tx *gorm.DB) error { - result := tx.Select(clause.Associations).Delete(account.Policies, "account_id = ?", account.Id) - if result.Error != nil { - return result.Error - } - - result = tx.Select(clause.Associations).Delete(account.UsersG, "account_id = ?", account.Id) - if result.Error != nil { - return result.Error - } - - result = tx.Select(clause.Associations).Delete(account.Services, "account_id = ?", account.Id) - if result.Error != nil { - return result.Error - } - - result = tx.Select(clause.Associations).Delete(account) - if result.Error != nil { - return result.Error - } - - return nil - }) - - took := time.Since(start) - if s.metrics != nil { - s.metrics.StoreMetrics().CountPersistenceDuration(took) - } - log.WithContext(ctx).Tracef("took %d ms to delete an account to the store", took.Milliseconds()) - - return err -} - -func (s *SqlStore) SaveInstallationID(_ context.Context, ID string) error { - installation := installation{InstallationIDValue: ID} - installation.ID = uint(s.installationPK) - - return s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&installation).Error -} - -func (s *SqlStore) GetInstallationID() string { - var installation installation - - if result := s.db.Take(&installation, idQueryCondition, s.installationPK); result.Error != nil { - return "" - } - - return installation.InstallationIDValue -} - -func (s *SqlStore) SavePeer(ctx context.Context, accountID string, peer *nbpeer.Peer) error { - // To maintain data integrity, we create a copy of the peer's to prevent unintended updates to other fields. - peerCopy := peer.Copy() - peerCopy.AccountID = accountID - - err := s.transaction(func(tx *gorm.DB) error { - // check if peer exists before saving - var peerID string - result := tx.Model(&nbpeer.Peer{}).Select("id").Take(&peerID, accountAndIDQueryCondition, accountID, peer.ID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return status.Errorf(status.NotFound, peerNotFoundFMT, peer.ID) - } - return result.Error - } - - if peerID == "" { - return status.Errorf(status.NotFound, peerNotFoundFMT, peer.ID) - } - - result = tx.Model(&nbpeer.Peer{}).Where(accountAndIDQueryCondition, accountID, peer.ID).Save(peerCopy) - if result.Error != nil { - return status.Errorf(status.Internal, "failed to save peer to store: %v", result.Error) - } - - return nil - }) - if err != nil { - return err - } - - return nil -} - -func (s *SqlStore) UpdateAccountDomainAttributes(ctx context.Context, accountID string, domain string, category string, isPrimaryDomain bool) error { - accountCopy := types.Account{ - Domain: domain, - DomainCategory: category, - IsDomainPrimaryAccount: isPrimaryDomain, - } - - fieldsToUpdate := []string{"domain", "domain_category", "is_domain_primary_account"} - result := s.db.Model(&types.Account{}). - Select(fieldsToUpdate). - Where(idQueryCondition, accountID). - Updates(&accountCopy) - if result.Error != nil { - return status.Errorf(status.Internal, "failed to update account domain attributes to store: %v", result.Error) - } - - if result.RowsAffected == 0 { - return status.Errorf(status.NotFound, "account %s", accountID) - } - - return nil -} - -func (s *SqlStore) SavePeerStatus(ctx context.Context, accountID, peerID string, peerStatus nbpeer.PeerStatus) error { - var peerCopy nbpeer.Peer - peerCopy.Status = &peerStatus - - fieldsToUpdate := []string{ - "peer_status_last_seen", "peer_status_session_started_at", - "peer_status_connected", "peer_status_login_expired", - "peer_status_requires_approval", - } - result := s.db.Model(&nbpeer.Peer{}). - Select(fieldsToUpdate). - Where(accountAndIDQueryCondition, accountID, peerID). - Updates(&peerCopy) - if result.Error != nil { - return status.Errorf(status.Internal, "failed to save peer status to store: %v", result.Error) - } - - if result.RowsAffected == 0 { - return status.Errorf(status.NotFound, peerNotFoundFMT, peerID) - } - - return nil -} - -// MarkPeerConnectedIfNewerSession is an atomic optimistic-locked update. -// The peer is marked connected with the given session token only when -// the stored SessionStartedAt is strictly smaller than the incoming -// one — equivalently, when no newer stream has already taken ownership. -// The sentinel zero (set on peer creation or after a disconnect) counts -// as the smallest possible token. This is the write half of the -// fencing protocol described on PeerStatus.SessionStartedAt. -// -// The post-write side effects in the caller — geo lookup, -// schedulePeerLoginExpiration, checkAndSchedulePeerInactivityExpiration, -// OnPeersUpdated — all run AFTER this method returns and are deliberately -// outside the database write so they cannot extend the row-lock window. -// -// LastSeen is set to the database's clock (CURRENT_TIMESTAMP) at the -// moment the row is written. The caller never supplies LastSeen because -// the value would otherwise drift under lock contention — a Go-side -// time.Now() taken before the write can land minutes later than the -// actual UPDATE under load, which previously caused real ordering bugs. -func (s *SqlStore) MarkPeerConnectedIfNewerSession(ctx context.Context, accountID, peerID string, newSessionStartedAt int64) (bool, error) { - result := s.db.WithContext(ctx). - Model(&nbpeer.Peer{}). - Where(accountAndIDQueryCondition, accountID, peerID). - Where("peer_status_session_started_at < ?", newSessionStartedAt). - Updates(map[string]any{ - "peer_status_connected": true, - "peer_status_last_seen": gorm.Expr("CURRENT_TIMESTAMP"), - "peer_status_session_started_at": newSessionStartedAt, - "peer_status_login_expired": false, - }) - if result.Error != nil { - return false, status.Errorf(status.Internal, "mark peer connected: %v", result.Error) - } - return result.RowsAffected > 0, nil -} - -// MarkPeerDisconnectedIfSameSession is an atomic optimistic-locked update. -// The peer is marked disconnected only when the stored SessionStartedAt -// matches the incoming token — meaning the stream that owns the current -// session is the one ending. If a newer stream has already replaced the -// session, the update is skipped. LastSeen is set to CURRENT_TIMESTAMP at -// write time; see MarkPeerConnectedIfNewerSession for the rationale. -// -// A zero sessionStartedAt is rejected at the call site; the underlying -// WHERE on equality would otherwise match every never-connected peer. -func (s *SqlStore) MarkPeerDisconnectedIfSameSession(ctx context.Context, accountID, peerID string, sessionStartedAt int64) (bool, error) { - if sessionStartedAt == 0 { - return false, nil - } - result := s.db.WithContext(ctx). - Model(&nbpeer.Peer{}). - Where(accountAndIDQueryCondition, accountID, peerID). - Where("peer_status_session_started_at = ?", sessionStartedAt). - Updates(map[string]any{ - "peer_status_connected": false, - "peer_status_last_seen": gorm.Expr("CURRENT_TIMESTAMP"), - "peer_status_session_started_at": int64(0), - }) - if result.Error != nil { - return false, status.Errorf(status.Internal, "mark peer disconnected: %v", result.Error) - } - return result.RowsAffected > 0, nil -} - -// ApproveAccountPeers marks all peers that currently require approval in the given account as approved. -func (s *SqlStore) ApproveAccountPeers(ctx context.Context, accountID string) (int, error) { - result := s.db.Model(&nbpeer.Peer{}). - Where("account_id = ? AND peer_status_requires_approval = ?", accountID, true). - Update("peer_status_requires_approval", false) - if result.Error != nil { - return 0, status.Errorf(status.Internal, "failed to approve pending account peers: %v", result.Error) - } - - return int(result.RowsAffected), nil -} - -// RefreshPeerLastSeen updates only peer_status_last_seen. Every other status -// column is left untouched: peer_status_connected and -// peer_status_session_started_at belong to the sync stream that owns the -// session, and a blind write here would corrupt the fencing -// MarkPeerConnectedIfNewerSession relies on. -// -// LastSeen comes from the database clock for the same reason it does there: a -// Go-side timestamp is taken before the write and can land after a connect that -// used CURRENT_TIMESTAMP, dragging the column backwards. -// -// staleBefore carries the caller's throttle into the same statement, so -// concurrent requests for one peer collapse into a single write instead of -// each racing on its own stale read. The column is nullable — Status is an -// embedded pointer, so a peer stored without one leaves it NULL — and NULL -// loses every comparison, hence the explicit branch for a peer never seen. -func (s *SqlStore) RefreshPeerLastSeen(ctx context.Context, accountID, peerID string, staleBefore time.Time) (bool, error) { - result := s.db.WithContext(ctx). - Model(&nbpeer.Peer{}). - Where(accountAndIDQueryCondition, accountID, peerID). - Where("(peer_status_last_seen IS NULL OR peer_status_last_seen < ?)", staleBefore). - Update("peer_status_last_seen", gorm.Expr("CURRENT_TIMESTAMP")) - if result.Error != nil { - return false, status.Errorf(status.Internal, "refresh peer last seen: %v", result.Error) - } - - return result.RowsAffected > 0, nil -} - -// SaveUsers saves the given list of users to the database. -func (s *SqlStore) SaveUsers(ctx context.Context, users []*types.User) error { - if len(users) == 0 { - return nil - } - - usersCopy := make([]*types.User, len(users)) - for i, user := range users { - userCopy := user.Copy() - userCopy.Email = user.Email - userCopy.Name = user.Name - if err := userCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil { - return fmt.Errorf("encrypt user: %w", err) - } - usersCopy[i] = userCopy - } - - result := s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&usersCopy) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to save users to store: %s", result.Error) - return status.Errorf(status.Internal, "failed to save users to store") - } - return nil -} - -// SaveUser saves the given user to the database. -func (s *SqlStore) SaveUser(ctx context.Context, user *types.User) error { - userCopy := user.Copy() - userCopy.Email = user.Email - userCopy.Name = user.Name - - if err := userCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil { - return fmt.Errorf("encrypt user: %w", err) - } - - result := s.db.Save(userCopy) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to save user to store: %s", result.Error) - return status.Errorf(status.Internal, "failed to save user to store") - } - return nil -} - -// CreateGroups creates the given list of groups to the database. -// groupUpsertColumns is the explicit allowlist of columns that get updated when -// CreateGroups / UpdateGroups hit a PK conflict. public_id is intentionally -// omitted so a caller passing an entity with the zero value (e.g. an HTTP -// handler-built struct) cannot reset the persisted public_id during an upsert. -// Keep this in sync with the Group schema in management/server/types/group.go. -func groupUpsertColumns() clause.Set { - return clause.AssignmentColumns([]string{ - "account_id", - "name", - "issued", - "integration_ref_id", - "integration_ref_integration_type", - "resources", - }) -} - -func (s *SqlStore) CreateGroups(ctx context.Context, accountID string, groups []*types.Group) error { - if len(groups) == 0 { - return nil - } - - return s.db.Transaction(func(tx *gorm.DB) error { - result := tx. - Clauses( - clause.OnConflict{ - Columns: []clause.Column{{Name: "id"}}, - Where: clause.Where{Exprs: []clause.Expression{clause.Eq{Column: "groups.account_id", Value: accountID}}}, - DoUpdates: groupUpsertColumns(), - }, - ). - Omit(clause.Associations). - Create(&groups) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to save groups to store: %v", result.Error) - return status.Errorf(status.Internal, "failed to save groups to store") - } - - return nil - }) -} - -// UpdateGroups updates the given list of groups to the database. -func (s *SqlStore) UpdateGroups(ctx context.Context, accountID string, groups []*types.Group) error { - if len(groups) == 0 { - return nil - } - - return s.db.Transaction(func(tx *gorm.DB) error { - result := tx. - Clauses( - clause.OnConflict{ - Columns: []clause.Column{{Name: "id"}}, - Where: clause.Where{Exprs: []clause.Expression{clause.Eq{Column: "groups.account_id", Value: accountID}}}, - DoUpdates: groupUpsertColumns(), - }, - ). - Omit(clause.Associations). - Create(&groups) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to save groups to store: %v", result.Error) - return status.Errorf(status.Internal, "failed to save groups to store") - } - - return nil - }) -} - -// DeleteHashedPAT2TokenIDIndex is noop in SqlStore -func (s *SqlStore) DeleteHashedPAT2TokenIDIndex(hashedToken string) error { - return nil -} - -// DeleteTokenID2UserIDIndex is noop in SqlStore -func (s *SqlStore) DeleteTokenID2UserIDIndex(tokenID string) error { - return nil -} - -func (s *SqlStore) GetAccountByPrivateDomain(ctx context.Context, domain string) (*types.Account, error) { - accountID, err := s.GetAccountIDByPrivateDomain(ctx, LockingStrengthNone, domain) - if err != nil { - return nil, err - } - - // TODO: rework to not call GetAccount - return s.GetAccount(ctx, accountID) -} - -func (s *SqlStore) GetAccountIDByPrivateDomain(ctx context.Context, lockStrength LockingStrength, domain string) (string, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var accountID string - result := tx.Model(&types.Account{}).Select("id"). - Where("domain = ? and is_domain_primary_account = ? and domain_category = ?", - strings.ToLower(domain), true, types.PrivateCategory, - ).Take(&accountID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return "", status.Errorf(status.NotFound, "account not found: provided domain is not registered or is not private") - } - log.WithContext(ctx).Errorf("error when getting account from the store: %s", result.Error) - return "", status.NewGetAccountFromStoreError(result.Error) - } - - return accountID, nil -} - -func (s *SqlStore) GetAccountBySetupKey(ctx context.Context, setupKey string) (*types.Account, error) { - var key types.SetupKey - result := s.db.Select("account_id").Take(&key, GetKeyQueryCondition(s), setupKey) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.NewSetupKeyNotFoundError(setupKey) - } - log.WithContext(ctx).Errorf("failed to get account by setup key from store: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to get account by setup key from store") - } - - if key.AccountID == "" { - return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") - } - - return s.GetAccount(ctx, key.AccountID) -} - -func (s *SqlStore) GetTokenIDByHashedToken(ctx context.Context, hashedToken string) (string, error) { - var token types.PersonalAccessToken - result := s.db.Take(&token, "hashed_token = ?", hashedToken) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return "", status.Errorf(status.NotFound, "account not found: index lookup failed") - } - log.WithContext(ctx).Errorf("error when getting token from the store: %s", result.Error) - return "", status.NewGetAccountFromStoreError(result.Error) - } - - return token.ID, nil -} - -func (s *SqlStore) GetUserByPATID(ctx context.Context, lockStrength LockingStrength, patID string) (*types.User, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var user types.User - result := tx. - Joins("JOIN personal_access_tokens ON personal_access_tokens.user_id = users.id"). - Where("personal_access_tokens.id = ?", patID).Take(&user) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.NewPATNotFoundError(patID) - } - log.WithContext(ctx).Errorf("failed to get token user from the store: %s", result.Error) - return nil, status.NewGetUserFromStoreError() - } - - if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil { - return nil, fmt.Errorf("decrypt user: %w", err) - } - - return &user, nil -} - -func (s *SqlStore) GetUserByUserID(ctx context.Context, lockStrength LockingStrength, userID string) (*types.User, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var user types.User - result := tx.Take(&user, idQueryCondition, userID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.NewUserNotFoundError(userID) - } - return nil, status.NewGetUserFromStoreError() - } - - if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil { - return nil, fmt.Errorf("decrypt user: %w", err) - } - - return &user, nil -} - -func (s *SqlStore) DeleteUser(ctx context.Context, accountID, userID string) error { - err := s.transaction(func(tx *gorm.DB) error { - result := tx.Delete(&types.PersonalAccessToken{}, "user_id = ?", userID) - if result.Error != nil { - return result.Error - } - - return tx.Delete(&types.User{}, accountAndIDQueryCondition, accountID, userID).Error - }) - if err != nil { - log.WithContext(ctx).Errorf("failed to delete user from the store: %s", err) - return status.Errorf(status.Internal, "failed to delete user from store") - } - - return nil -} - -func (s *SqlStore) GetAccountUsers(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.User, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var users []*types.User - result := tx.Find(&users, accountIDCondition, accountID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.Errorf(status.NotFound, "accountID not found: index lookup failed") - } - log.WithContext(ctx).Errorf("error when getting users from the store: %s", result.Error) - return nil, status.Errorf(status.Internal, "issue getting users from store") - } - - for _, user := range users { - if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil { - return nil, fmt.Errorf("decrypt user: %w", err) - } - } - - return users, nil -} - -func (s *SqlStore) GetAccountOwner(ctx context.Context, lockStrength LockingStrength, accountID string) (*types.User, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var user types.User - result := tx.Take(&user, "account_id = ? AND role = ?", accountID, types.UserRoleOwner) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.Errorf(status.NotFound, "account owner not found: index lookup failed") - } - return nil, status.Errorf(status.Internal, "failed to get account owner from the store") - } - - if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil { - return nil, fmt.Errorf("decrypt user: %w", err) - } - - return &user, nil -} - -// SaveUserInvite saves a user invite to the database -func (s *SqlStore) SaveUserInvite(ctx context.Context, invite *types.UserInviteRecord) error { - inviteCopy := invite.Copy() - if err := inviteCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil { - return fmt.Errorf("encrypt invite: %w", err) - } - - result := s.db.Save(inviteCopy) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to save user invite to store: %s", result.Error) - return status.Errorf(status.Internal, "failed to save user invite to store") - } - return nil -} - -// GetUserInviteByID retrieves a user invite by its ID and account ID -func (s *SqlStore) GetUserInviteByID(ctx context.Context, lockStrength LockingStrength, accountID, inviteID string) (*types.UserInviteRecord, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var invite types.UserInviteRecord - result := tx.Where("account_id = ?", accountID).Take(&invite, idQueryCondition, inviteID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.Errorf(status.NotFound, "user invite not found") - } - log.WithContext(ctx).Errorf("failed to get user invite from store: %s", result.Error) - return nil, status.Errorf(status.Internal, "failed to get user invite from store") - } - - if err := invite.DecryptSensitiveData(s.fieldEncrypt); err != nil { - return nil, fmt.Errorf("decrypt invite: %w", err) - } - - return &invite, nil -} - -// GetUserInviteByHashedToken retrieves a user invite by its hashed token -func (s *SqlStore) GetUserInviteByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken string) (*types.UserInviteRecord, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var invite types.UserInviteRecord - result := tx.Take(&invite, "hashed_token = ?", hashedToken) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.Errorf(status.NotFound, "user invite not found") - } - log.WithContext(ctx).Errorf("failed to get user invite from store: %s", result.Error) - return nil, status.Errorf(status.Internal, "failed to get user invite from store") - } - - if err := invite.DecryptSensitiveData(s.fieldEncrypt); err != nil { - return nil, fmt.Errorf("decrypt invite: %w", err) - } - - return &invite, nil -} - -// GetUserInviteByEmail retrieves a user invite by account ID and email. -// Since email is encrypted with random IVs, we fetch all invites for the account -// and compare emails in memory after decryption. -func (s *SqlStore) GetUserInviteByEmail(ctx context.Context, lockStrength LockingStrength, accountID, email string) (*types.UserInviteRecord, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var invites []*types.UserInviteRecord - result := tx.Find(&invites, "account_id = ?", accountID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get user invites from store: %s", result.Error) - return nil, status.Errorf(status.Internal, "failed to get user invites from store") - } - - for _, invite := range invites { - if err := invite.DecryptSensitiveData(s.fieldEncrypt); err != nil { - return nil, fmt.Errorf("decrypt invite: %w", err) - } - if strings.EqualFold(invite.Email, email) { - return invite, nil - } - } - - return nil, status.Errorf(status.NotFound, "user invite not found for email") -} - -// GetAccountUserInvites retrieves all user invites for an account -func (s *SqlStore) GetAccountUserInvites(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.UserInviteRecord, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var invites []*types.UserInviteRecord - result := tx.Find(&invites, "account_id = ?", accountID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get user invites from store: %s", result.Error) - return nil, status.Errorf(status.Internal, "failed to get user invites from store") - } - - for _, invite := range invites { - if err := invite.DecryptSensitiveData(s.fieldEncrypt); err != nil { - return nil, fmt.Errorf("decrypt invite: %w", err) - } - } - - return invites, nil -} - -// DeleteUserInvite deletes a user invite by its ID -func (s *SqlStore) DeleteUserInvite(ctx context.Context, inviteID string) error { - result := s.db.Delete(&types.UserInviteRecord{}, idQueryCondition, inviteID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to delete user invite from store: %s", result.Error) - return status.Errorf(status.Internal, "failed to delete user invite from store") - } - return nil -} - -func (s *SqlStore) GetAccountGroups(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.Group, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var groups []*types.Group - result := tx.Preload(clause.Associations).Find(&groups, accountIDCondition, accountID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.Errorf(status.NotFound, "accountID not found: index lookup failed") - } - log.WithContext(ctx).Errorf("failed to get account groups from the store: %s", result.Error) - return nil, status.Errorf(status.Internal, "failed to get account groups from the store") - } - - for _, g := range groups { - g.LoadGroupPeers() - } - - return groups, nil -} - -func (s *SqlStore) GetResourceGroups(ctx context.Context, lockStrength LockingStrength, accountID, resourceID string) ([]*types.Group, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var groups []*types.Group - - likePattern := `%"ID":"` + resourceID + `"%` - - result := tx. - Preload(clause.Associations). - Where("resources LIKE ?", likePattern). - Find(&groups) - - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, nil - } - return nil, result.Error - } - - for _, g := range groups { - g.LoadGroupPeers() - } - - return groups, nil -} - -func (s *SqlStore) GetAccountsCounter(ctx context.Context) (int64, error) { - var count int64 - result := s.db.Model(&types.Account{}).Count(&count) - if result.Error != nil { - return 0, fmt.Errorf("failed to get all accounts counter: %w", result.Error) - } - - return count, nil -} - -// GetCustomDomainsCounts returns the total and validated custom domain counts. -func (s *SqlStore) GetCustomDomainsCounts(ctx context.Context) (int64, int64, error) { - var total, validated int64 - if err := s.db.Model(&domain.Domain{}).Count(&total).Error; err != nil { - return 0, 0, err - } - if err := s.db.Model(&domain.Domain{}).Where("validated = ?", true).Count(&validated).Error; err != nil { - return 0, 0, err - } - return total, validated, nil -} - -// GetProxyMetrics aggregates per-cluster + per-proxy counts for the -// self-hosted telemetry payload. Single round-trip via conditional -// aggregations so a large proxies table doesn't fan out into multiple -// queries. -func (s *SqlStore) GetProxyMetrics(ctx context.Context) (ProxyMetrics, error) { - var m ProxyMetrics - activeCutoff := time.Now().Add(-proxyActiveThreshold) - - // COUNT(DISTINCT ... CASE WHEN ...) is portable across sqlite/postgres - // (MySQL too) and keeps the round-trip to one. proxy.StatusConnected - // is the same string the cluster-capability queries use; the active - // window matches the cluster-capability semantics (only proxies - // heartbeating within ~2 * heartbeat interval count as connected). - row := s.db.WithContext(ctx). - Model(&proxy.Proxy{}). - Select( - "COUNT(DISTINCT cluster_address) AS clusters, "+ - "COUNT(DISTINCT CASE WHEN account_id IS NOT NULL THEN cluster_address END) AS clusters_byop, "+ - "COUNT(DISTINCT CASE WHEN private = ? THEN cluster_address END) AS clusters_private, "+ - "COUNT(*) AS proxies, "+ - "COUNT(CASE WHEN status = ? AND last_seen > ? THEN 1 END) AS proxies_connected", - true, - proxy.StatusConnected, - activeCutoff, - ). - Row() - if err := row.Scan(&m.Clusters, &m.ClustersBYOP, &m.ClustersPrivate, &m.Proxies, &m.ProxiesConnected); err != nil { - return ProxyMetrics{}, fmt.Errorf("scan proxy metrics: %w", err) - } - return m, nil -} - -func (s *SqlStore) GetAllAccounts(ctx context.Context) (all []*types.Account) { - var accounts []types.Account - result := s.db.Find(&accounts) - if result.Error != nil { - return all - } - - for _, account := range accounts { - if acc, err := s.GetAccount(ctx, account.Id); err == nil { - all = append(all, acc) - } - } - - return all -} - -func (s *SqlStore) GetAccountMeta(ctx context.Context, lockStrength LockingStrength, accountID string) (*types.AccountMeta, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var accountMeta types.AccountMeta - result := tx.Model(&types.Account{}). - Take(&accountMeta, idQueryCondition, accountID) - if result.Error != nil { - log.WithContext(ctx).Errorf("error when getting account meta %s from the store: %s", accountID, result.Error) - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.NewAccountNotFoundError(accountID) - } - return nil, status.NewGetAccountFromStoreError(result.Error) - } - - return &accountMeta, nil -} - -// GetAccountOnboarding retrieves the onboarding information for a specific account. -func (s *SqlStore) GetAccountOnboarding(ctx context.Context, accountID string) (*types.AccountOnboarding, error) { - var accountOnboarding types.AccountOnboarding - result := s.db.Model(&accountOnboarding).Take(&accountOnboarding, accountIDCondition, accountID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.NewAccountOnboardingNotFoundError(accountID) - } - log.WithContext(ctx).Errorf("error when getting account onboarding %s from the store: %s", accountID, result.Error) - return nil, status.NewGetAccountFromStoreError(result.Error) - } - - return &accountOnboarding, nil -} - -// SaveAccountOnboarding updates the onboarding information for a specific account. -func (s *SqlStore) SaveAccountOnboarding(ctx context.Context, onboarding *types.AccountOnboarding) error { - result := s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(onboarding) - if result.Error != nil { - log.WithContext(ctx).Errorf("error when saving account onboarding %s in the store: %s", onboarding.AccountID, result.Error) - return status.Errorf(status.Internal, "error when saving account onboarding %s in the store: %s", onboarding.AccountID, result.Error) - } - - return nil -} - -func (s *SqlStore) GetAccount(ctx context.Context, accountID string) (*types.Account, error) { - if s.pool != nil { - return s.getAccountPgx(ctx, accountID) - } - return s.getAccountGorm(ctx, accountID) -} - -func (s *SqlStore) getAccountGorm(ctx context.Context, accountID string) (*types.Account, error) { - start := time.Now() - defer func() { - elapsed := time.Since(start) - if elapsed > 1*time.Second { - log.WithContext(ctx).Tracef("GetAccount for account %s exceeded 1s, took: %v", accountID, elapsed) - } - }() - - var account types.Account - result := s.db.Model(&account). - Preload("UsersG.PATsG"). // have to be specified as this is nested reference - Preload("Policies.Rules"). - Preload("SetupKeysG"). - Preload("PeersG"). - Preload("UsersG"). - Preload("GroupsG.GroupPeers"). - Preload("RoutesG"). - Preload("NameServerGroupsG"). - Preload("PostureChecks"). - Preload("Networks"). - Preload("NetworkRouters"). - Preload("NetworkResources"). - Preload("Onboarding"). - Preload("Services.Targets"). - Preload("Domains"). - Take(&account, idQueryCondition, accountID) - if result.Error != nil { - log.WithContext(ctx).Errorf("error when getting account %s from the store: %s", accountID, result.Error) - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.NewAccountNotFoundError(accountID) - } - return nil, status.NewGetAccountFromStoreError(result.Error) - } - - account.SetupKeys = make(map[string]*types.SetupKey, len(account.SetupKeysG)) - for _, key := range account.SetupKeysG { - if key.UpdatedAt.IsZero() { - key.UpdatedAt = key.CreatedAt - } - if key.AutoGroups == nil { - key.AutoGroups = []string{} - } - account.SetupKeys[key.Key] = &key - } - account.SetupKeysG = nil - - account.Peers = make(map[string]*nbpeer.Peer, len(account.PeersG)) - for _, peer := range account.PeersG { - account.Peers[peer.ID] = &peer - } - account.PeersG = nil - account.Users = make(map[string]*types.User, len(account.UsersG)) - for _, user := range account.UsersG { - user.PATs = make(map[string]*types.PersonalAccessToken, len(user.PATs)) - for _, pat := range user.PATsG { - pat.UserID = "" - user.PATs[pat.ID] = &pat - } - if user.AutoGroups == nil { - user.AutoGroups = []string{} - } - if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil { - return nil, fmt.Errorf("decrypt user: %w", err) - } - account.Users[user.Id] = &user - user.PATsG = nil - } - account.UsersG = nil - account.Groups = make(map[string]*types.Group, len(account.GroupsG)) - for _, group := range account.GroupsG { - group.Peers = make([]string, len(group.GroupPeers)) - for i, gp := range group.GroupPeers { - group.Peers[i] = gp.PeerID - } - if group.Resources == nil { - group.Resources = []types.Resource{} - } - account.Groups[group.ID] = group - } - account.GroupsG = nil - - account.Routes = make(map[route.ID]*route.Route, len(account.RoutesG)) - for _, route := range account.RoutesG { - account.Routes[route.ID] = &route - } - account.RoutesG = nil - account.NameServerGroups = make(map[string]*nbdns.NameServerGroup, len(account.NameServerGroupsG)) - for _, ns := range account.NameServerGroupsG { - ns.AccountID = "" - if ns.NameServers == nil { - ns.NameServers = []nbdns.NameServer{} - } - if ns.Groups == nil { - ns.Groups = []string{} - } - if ns.Domains == nil { - ns.Domains = []string{} - } - account.NameServerGroups[ns.ID] = &ns - } - account.NameServerGroupsG = nil - return &account, nil -} - -func (s *SqlStore) getAccountPgx(ctx context.Context, accountID string) (*types.Account, error) { - account, err := s.getAccount(ctx, accountID) - if err != nil { - return nil, err - } - - var wg sync.WaitGroup - errChan := make(chan error, 16) - - wg.Add(1) - go func() { - defer wg.Done() - keys, err := s.getSetupKeys(ctx, accountID) - if err != nil { - errChan <- err - return - } - account.SetupKeysG = keys - }() - - wg.Add(1) - go func() { - defer wg.Done() - peers, err := s.getPeers(ctx, accountID) - if err != nil { - errChan <- err - return - } - account.PeersG = peers - }() - - wg.Add(1) - go func() { - defer wg.Done() - users, err := s.getUsers(ctx, accountID) - if err != nil { - errChan <- err - return - } - account.UsersG = users - }() - - wg.Add(1) - go func() { - defer wg.Done() - groups, err := s.getGroups(ctx, accountID) - if err != nil { - errChan <- err - return - } - account.GroupsG = groups - }() - - wg.Add(1) - go func() { - defer wg.Done() - policies, err := s.getPolicies(ctx, accountID) - if err != nil { - errChan <- err - return - } - account.Policies = policies - }() - - wg.Add(1) - go func() { - defer wg.Done() - routes, err := s.getRoutes(ctx, accountID) - if err != nil { - errChan <- err - return - } - account.RoutesG = routes - }() - - wg.Add(1) - go func() { - defer wg.Done() - nsgs, err := s.getNameServerGroups(ctx, accountID) - if err != nil { - errChan <- err - return - } - account.NameServerGroupsG = nsgs - }() - - wg.Add(1) - go func() { - defer wg.Done() - checks, err := s.getPostureChecks(ctx, accountID) - if err != nil { - errChan <- err - return - } - account.PostureChecks = checks - }() - - wg.Add(1) - go func() { - defer wg.Done() - services, err := s.getServices(ctx, accountID) - if err != nil { - errChan <- err - return - } - account.Services = services - }() - - wg.Add(1) - go func() { - defer wg.Done() - domains, err := s.ListCustomDomains(ctx, accountID) - if err != nil { - errChan <- err - return - } - account.Domains = domains - }() - - wg.Add(1) - go func() { - defer wg.Done() - networks, err := s.getNetworks(ctx, accountID) - if err != nil { - errChan <- err - return - } - account.Networks = networks - }() - - wg.Add(1) - go func() { - defer wg.Done() - routers, err := s.getNetworkRouters(ctx, accountID) - if err != nil { - errChan <- err - return - } - account.NetworkRouters = routers - }() - - wg.Add(1) - go func() { - defer wg.Done() - resources, err := s.getNetworkResources(ctx, accountID) - if err != nil { - errChan <- err - return - } - account.NetworkResources = resources - }() - - wg.Add(1) - go func() { - defer wg.Done() - err := s.getAccountOnboarding(ctx, accountID, account) - if err != nil { - errChan <- err - return - } - }() - - wg.Wait() - close(errChan) - for e := range errChan { - if e != nil { - return nil, e - } - } - - var userIDs []string - for _, u := range account.UsersG { - userIDs = append(userIDs, u.Id) - } - var policyIDs []string - for _, p := range account.Policies { - policyIDs = append(policyIDs, p.ID) - } - var groupIDs []string - for _, g := range account.GroupsG { - groupIDs = append(groupIDs, g.ID) - } - - wg.Add(3) - errChan = make(chan error, 3) - - var pats []types.PersonalAccessToken - go func() { - defer wg.Done() - var err error - pats, err = s.getPersonalAccessTokens(ctx, userIDs) - if err != nil { - errChan <- err - } - }() - - var rules []*types.PolicyRule - go func() { - defer wg.Done() - var err error - rules, err = s.getPolicyRules(ctx, policyIDs) - if err != nil { - errChan <- err - } - }() - - var groupPeers []types.GroupPeer - go func() { - defer wg.Done() - var err error - groupPeers, err = s.getGroupPeers(ctx, groupIDs) - if err != nil { - errChan <- err - } - }() - - wg.Wait() - close(errChan) - for e := range errChan { - if e != nil { - return nil, e - } - } - - patsByUserID := make(map[string][]*types.PersonalAccessToken) - for i := range pats { - pat := &pats[i] - patsByUserID[pat.UserID] = append(patsByUserID[pat.UserID], pat) - pat.UserID = "" - } - - rulesByPolicyID := make(map[string][]*types.PolicyRule) - for _, rule := range rules { - rulesByPolicyID[rule.PolicyID] = append(rulesByPolicyID[rule.PolicyID], rule) - } - - peersByGroupID := make(map[string][]string) - for _, gp := range groupPeers { - peersByGroupID[gp.GroupID] = append(peersByGroupID[gp.GroupID], gp.PeerID) - } - - account.SetupKeys = make(map[string]*types.SetupKey, len(account.SetupKeysG)) - for i := range account.SetupKeysG { - key := &account.SetupKeysG[i] - account.SetupKeys[key.Key] = key - } - - account.Peers = make(map[string]*nbpeer.Peer, len(account.PeersG)) - for i := range account.PeersG { - peer := &account.PeersG[i] - account.Peers[peer.ID] = peer - } - - account.Users = make(map[string]*types.User, len(account.UsersG)) - for i := range account.UsersG { - user := &account.UsersG[i] - if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil { - return nil, fmt.Errorf("decrypt user: %w", err) - } - user.PATs = make(map[string]*types.PersonalAccessToken) - if userPats, ok := patsByUserID[user.Id]; ok { - for j := range userPats { - pat := userPats[j] - user.PATs[pat.ID] = pat - } - } - account.Users[user.Id] = user - } - - for i := range account.Policies { - policy := account.Policies[i] - if policyRules, ok := rulesByPolicyID[policy.ID]; ok { - policy.Rules = policyRules - } - } - - account.Groups = make(map[string]*types.Group, len(account.GroupsG)) - for i := range account.GroupsG { - group := account.GroupsG[i] - if peerIDs, ok := peersByGroupID[group.ID]; ok { - group.Peers = peerIDs - } - account.Groups[group.ID] = group - } - - account.Routes = make(map[route.ID]*route.Route, len(account.RoutesG)) - for i := range account.RoutesG { - route := &account.RoutesG[i] - account.Routes[route.ID] = route - } - - account.NameServerGroups = make(map[string]*nbdns.NameServerGroup, len(account.NameServerGroupsG)) - for i := range account.NameServerGroupsG { - nsg := &account.NameServerGroupsG[i] - nsg.AccountID = "" - account.NameServerGroups[nsg.ID] = nsg - } - - account.SetupKeysG = nil - account.PeersG = nil - account.UsersG = nil - account.GroupsG = nil - account.RoutesG = nil - account.NameServerGroupsG = nil - - return account, nil -} - -func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Account, error) { - var account types.Account - account.Network = &types.Network{} - const accountQuery = ` - SELECT - id, created_by, created_at, domain, domain_category, is_domain_primary_account, - -- Embedded Network - network_identifier, network_net, network_net_v6, network_dns, network_serial, - -- Embedded DNSSettings - dns_settings_disabled_management_groups, - -- Embedded Settings - settings_peer_login_expiration_enabled, settings_peer_login_expiration, - settings_peer_inactivity_expiration_enabled, settings_peer_inactivity_expiration, - settings_regular_users_view_blocked, settings_groups_propagation_enabled, - settings_jwt_groups_enabled, settings_jwt_groups_claim_name, settings_jwt_allow_groups, - settings_routing_peer_dns_resolution_enabled, settings_dns_domain, settings_network_range, - settings_network_range_v6, settings_ipv6_enabled_groups, settings_lazy_connection_enabled, - settings_local_mfa_enabled, settings_metrics_push_enabled, settings_agent_network_only, - settings_dashboard_features, settings_auto_update_version, settings_auto_update_always, - settings_peer_expose_enabled, settings_peer_expose_groups, - -- Embedded ExtraSettings - settings_extra_peer_approval_enabled, settings_extra_user_approval_required, - settings_extra_integrated_validator, settings_extra_integrated_validator_groups - FROM accounts WHERE id = $1` - - var ( - sPeerLoginExpirationEnabled sql.NullBool - sPeerLoginExpiration sql.NullInt64 - sPeerInactivityExpirationEnabled sql.NullBool - sPeerInactivityExpiration sql.NullInt64 - sRegularUsersViewBlocked sql.NullBool - sGroupsPropagationEnabled sql.NullBool - sJWTGroupsEnabled sql.NullBool - sJWTGroupsClaimName sql.NullString - sJWTAllowGroups sql.NullString - sRoutingPeerDNSResolutionEnabled sql.NullBool - sDNSDomain sql.NullString - sNetworkRange sql.NullString - sNetworkRangeV6 sql.NullString - sIPv6EnabledGroups sql.NullString - sLazyConnectionEnabled sql.NullBool - sLocalMFAEnabled sql.NullBool - sMetricsPushEnabled sql.NullBool - sAgentNetworkOnly sql.NullBool - sDashboardFeatures sql.NullString - autoUpdateVersion sql.NullString - autoUpdateAlways sql.NullBool - peerExposeEnabled sql.NullBool - peerExposeGroups sql.NullString - sExtraPeerApprovalEnabled sql.NullBool - sExtraUserApprovalRequired sql.NullBool - sExtraIntegratedValidator sql.NullString - sExtraIntegratedValidatorGroups sql.NullString - networkNet sql.NullString - networkNetV6 sql.NullString - dnsSettingsDisabledGroups sql.NullString - networkIdentifier sql.NullString - networkDns sql.NullString - networkSerial sql.NullInt64 - createdAt sql.NullTime - ) - err := s.pool.QueryRow(ctx, accountQuery, accountID).Scan( - &account.Id, &account.CreatedBy, &createdAt, &account.Domain, &account.DomainCategory, &account.IsDomainPrimaryAccount, - &networkIdentifier, &networkNet, &networkNetV6, &networkDns, &networkSerial, - &dnsSettingsDisabledGroups, - &sPeerLoginExpirationEnabled, &sPeerLoginExpiration, - &sPeerInactivityExpirationEnabled, &sPeerInactivityExpiration, - &sRegularUsersViewBlocked, &sGroupsPropagationEnabled, - &sJWTGroupsEnabled, &sJWTGroupsClaimName, &sJWTAllowGroups, - &sRoutingPeerDNSResolutionEnabled, &sDNSDomain, &sNetworkRange, - &sNetworkRangeV6, &sIPv6EnabledGroups, &sLazyConnectionEnabled, - &sLocalMFAEnabled, &sMetricsPushEnabled, &sAgentNetworkOnly, - &sDashboardFeatures, &autoUpdateVersion, &autoUpdateAlways, - &peerExposeEnabled, &peerExposeGroups, - &sExtraPeerApprovalEnabled, &sExtraUserApprovalRequired, - &sExtraIntegratedValidator, &sExtraIntegratedValidatorGroups, - ) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return nil, status.NewAccountNotFoundError(accountID) - } - return nil, status.NewGetAccountFromStoreError(err) - } - - account.Settings = &types.Settings{Extra: &types.ExtraSettings{}} - if networkNet.Valid { - _ = json.Unmarshal([]byte(networkNet.String), &account.Network.Net) - } - if createdAt.Valid { - account.CreatedAt = createdAt.Time - } - if dnsSettingsDisabledGroups.Valid { - _ = json.Unmarshal([]byte(dnsSettingsDisabledGroups.String), &account.DNSSettings.DisabledManagementGroups) - } - if networkIdentifier.Valid { - account.Network.Identifier = networkIdentifier.String - } - if networkDns.Valid { - account.Network.Dns = networkDns.String - } - if networkSerial.Valid { - account.Network.Serial = uint64(networkSerial.Int64) - } - if sPeerLoginExpirationEnabled.Valid { - account.Settings.PeerLoginExpirationEnabled = sPeerLoginExpirationEnabled.Bool - } - if sPeerLoginExpiration.Valid { - account.Settings.PeerLoginExpiration = time.Duration(sPeerLoginExpiration.Int64) - } - if sPeerInactivityExpirationEnabled.Valid { - account.Settings.PeerInactivityExpirationEnabled = sPeerInactivityExpirationEnabled.Bool - } - if sPeerInactivityExpiration.Valid { - account.Settings.PeerInactivityExpiration = time.Duration(sPeerInactivityExpiration.Int64) - } - if sRegularUsersViewBlocked.Valid { - account.Settings.RegularUsersViewBlocked = sRegularUsersViewBlocked.Bool - } - if sGroupsPropagationEnabled.Valid { - account.Settings.GroupsPropagationEnabled = sGroupsPropagationEnabled.Bool - } - if sJWTGroupsEnabled.Valid { - account.Settings.JWTGroupsEnabled = sJWTGroupsEnabled.Bool - } - if sJWTGroupsClaimName.Valid { - account.Settings.JWTGroupsClaimName = sJWTGroupsClaimName.String - } - if sRoutingPeerDNSResolutionEnabled.Valid { - account.Settings.RoutingPeerDNSResolutionEnabled = sRoutingPeerDNSResolutionEnabled.Bool - } - if sDNSDomain.Valid { - account.Settings.DNSDomain = sDNSDomain.String - } - if sLazyConnectionEnabled.Valid { - account.Settings.LazyConnectionEnabled = sLazyConnectionEnabled.Bool - } - if sLocalMFAEnabled.Valid { - account.Settings.LocalMfaEnabled = sLocalMFAEnabled.Bool - } - if sMetricsPushEnabled.Valid { - account.Settings.MetricsPushEnabled = sMetricsPushEnabled.Bool - } - if sAgentNetworkOnly.Valid { - account.Settings.AgentNetworkOnly = sAgentNetworkOnly.Bool - } - if sDashboardFeatures.Valid && sDashboardFeatures.String != "" { - if err := json.Unmarshal([]byte(sDashboardFeatures.String), &account.Settings.DashboardFeatures); err != nil { - log.WithContext(ctx).Warnf("failed to unmarshal dashboard features for account %s: %v", accountID, err) - } - } - if sJWTAllowGroups.Valid { - _ = json.Unmarshal([]byte(sJWTAllowGroups.String), &account.Settings.JWTAllowGroups) - } - if sNetworkRange.Valid { - _ = json.Unmarshal([]byte(sNetworkRange.String), &account.Settings.NetworkRange) - } - if networkNetV6.Valid { - _ = json.Unmarshal([]byte(networkNetV6.String), &account.Network.NetV6) - } - if sNetworkRangeV6.Valid { - _ = json.Unmarshal([]byte(sNetworkRangeV6.String), &account.Settings.NetworkRangeV6) - } - if sIPv6EnabledGroups.Valid { - _ = json.Unmarshal([]byte(sIPv6EnabledGroups.String), &account.Settings.IPv6EnabledGroups) - } - if autoUpdateAlways.Valid { - account.Settings.AutoUpdateAlways = autoUpdateAlways.Bool - } - if autoUpdateVersion.Valid { - account.Settings.AutoUpdateVersion = autoUpdateVersion.String - } - if peerExposeEnabled.Valid { - account.Settings.PeerExposeEnabled = peerExposeEnabled.Bool - } - if peerExposeGroups.Valid { - _ = json.Unmarshal([]byte(peerExposeGroups.String), &account.Settings.PeerExposeGroups) - } - - if sExtraPeerApprovalEnabled.Valid { - account.Settings.Extra.PeerApprovalEnabled = sExtraPeerApprovalEnabled.Bool - } - if sExtraUserApprovalRequired.Valid { - account.Settings.Extra.UserApprovalRequired = sExtraUserApprovalRequired.Bool - } - if sExtraIntegratedValidator.Valid { - account.Settings.Extra.IntegratedValidator = sExtraIntegratedValidator.String - } - if sExtraIntegratedValidatorGroups.Valid { - _ = json.Unmarshal([]byte(sExtraIntegratedValidatorGroups.String), &account.Settings.Extra.IntegratedValidatorGroups) - } - return &account, nil -} - -func (s *SqlStore) getSetupKeys(ctx context.Context, accountID string) ([]types.SetupKey, error) { - const query = `SELECT id, account_id, key, key_secret, name, type, created_at, expires_at, updated_at, - revoked, used_times, last_used, auto_groups, usage_limit, ephemeral, allow_extra_dns_labels FROM setup_keys WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) - if err != nil { - return nil, err - } - - keys, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (types.SetupKey, error) { - var sk types.SetupKey - var autoGroups []byte - var skCreatedAt, expiresAt, updatedAt, lastUsed sql.NullTime - var revoked, ephemeral, allowExtraDNSLabels sql.NullBool - var usedTimes, usageLimit sql.NullInt64 - - err := row.Scan(&sk.Id, &sk.AccountID, &sk.Key, &sk.KeySecret, &sk.Name, &sk.Type, &skCreatedAt, - &expiresAt, &updatedAt, &revoked, &usedTimes, &lastUsed, &autoGroups, &usageLimit, &ephemeral, &allowExtraDNSLabels) - - if err == nil { - if expiresAt.Valid { - sk.ExpiresAt = &expiresAt.Time - } - if skCreatedAt.Valid { - sk.CreatedAt = skCreatedAt.Time - } - if updatedAt.Valid { - sk.UpdatedAt = updatedAt.Time - if sk.UpdatedAt.IsZero() { - sk.UpdatedAt = sk.CreatedAt - } - } - if lastUsed.Valid { - sk.LastUsed = &lastUsed.Time - } - if revoked.Valid { - sk.Revoked = revoked.Bool - } - if usedTimes.Valid { - sk.UsedTimes = int(usedTimes.Int64) - } - if usageLimit.Valid { - sk.UsageLimit = int(usageLimit.Int64) - } - if ephemeral.Valid { - sk.Ephemeral = ephemeral.Bool - } - if allowExtraDNSLabels.Valid { - sk.AllowExtraDNSLabels = allowExtraDNSLabels.Bool - } - if autoGroups != nil { - _ = json.Unmarshal(autoGroups, &sk.AutoGroups) - } else { - sk.AutoGroups = []string{} - } - } - return sk, err - }) - if err != nil { - return nil, err - } - return keys, nil -} - -func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Peer, error) { - const query = `SELECT id, account_id, key, ip, name, dns_label, user_id, ssh_key, ssh_enabled, login_expiration_enabled, - inactivity_expiration_enabled, last_login, created_at, ephemeral, extra_dns_labels, allow_extra_dns_labels, meta_hostname, - meta_go_os, meta_kernel, meta_core, meta_platform, meta_os, meta_os_version, meta_wt_version, meta_ui_version, - meta_kernel_version, meta_network_addresses, meta_system_serial_number, meta_system_product_name, meta_system_manufacturer, - meta_environment, meta_flags, meta_files, meta_capabilities, peer_status_last_seen, peer_status_session_started_at, - peer_status_connected, peer_status_login_expired, peer_status_requires_approval, location_connection_ip, - location_country_code, location_city_name, location_geo_name_id, proxy_meta_embedded, proxy_meta_cluster, ipv6, meta_sync_message_version - FROM peers WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) - if err != nil { - return nil, err - } - - peers, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (nbpeer.Peer, error) { - var p nbpeer.Peer - p.Status = &nbpeer.PeerStatus{} - var ( - lastLogin, createdAt sql.NullTime - sshEnabled, loginExpirationEnabled, inactivityExpirationEnabled, ephemeral, allowExtraDNSLabels sql.NullBool - peerStatusLastSeen sql.NullTime - peerStatusSessionStartedAt sql.NullInt64 - peerStatusConnected, peerStatusLoginExpired, peerStatusRequiresApproval, proxyEmbedded sql.NullBool - ip, extraDNS, netAddr, env, flags, files, capabilities, connIP, ipv6 []byte - metaHostname, metaGoOS, metaKernel, metaCore, metaPlatform sql.NullString - metaOS, metaOSVersion, metaWtVersion, metaUIVersion, metaKernelVersion sql.NullString - metaSystemSerialNumber, metaSystemProductName, metaSystemManufacturer sql.NullString - locationCountryCode, locationCityName, proxyCluster sql.NullString - locationGeoNameID sql.NullInt64 - metaSyncMessageVersion sql.NullInt32 - ) - - err := row.Scan(&p.ID, &p.AccountID, &p.Key, &ip, &p.Name, &p.DNSLabel, &p.UserID, &p.SSHKey, &sshEnabled, - &loginExpirationEnabled, &inactivityExpirationEnabled, &lastLogin, &createdAt, &ephemeral, &extraDNS, - &allowExtraDNSLabels, &metaHostname, &metaGoOS, &metaKernel, &metaCore, &metaPlatform, - &metaOS, &metaOSVersion, &metaWtVersion, &metaUIVersion, &metaKernelVersion, &netAddr, - &metaSystemSerialNumber, &metaSystemProductName, &metaSystemManufacturer, &env, &flags, &files, &capabilities, - &peerStatusLastSeen, &peerStatusSessionStartedAt, &peerStatusConnected, &peerStatusLoginExpired, - &peerStatusRequiresApproval, &connIP, &locationCountryCode, &locationCityName, &locationGeoNameID, - &proxyEmbedded, &proxyCluster, &ipv6, &metaSyncMessageVersion) - - if err == nil { - if lastLogin.Valid { - p.LastLogin = &lastLogin.Time - } - if createdAt.Valid { - p.CreatedAt = createdAt.Time - } - if sshEnabled.Valid { - p.SSHEnabled = sshEnabled.Bool - } - if loginExpirationEnabled.Valid { - p.LoginExpirationEnabled = loginExpirationEnabled.Bool - } - if inactivityExpirationEnabled.Valid { - p.InactivityExpirationEnabled = inactivityExpirationEnabled.Bool - } - if ephemeral.Valid { - p.Ephemeral = ephemeral.Bool - } - if allowExtraDNSLabels.Valid { - p.AllowExtraDNSLabels = allowExtraDNSLabels.Bool - } - if peerStatusLastSeen.Valid { - p.Status.LastSeen = peerStatusLastSeen.Time - } - if peerStatusSessionStartedAt.Valid { - p.Status.SessionStartedAt = peerStatusSessionStartedAt.Int64 - } - if peerStatusConnected.Valid { - p.Status.Connected = peerStatusConnected.Bool - } - if peerStatusLoginExpired.Valid { - p.Status.LoginExpired = peerStatusLoginExpired.Bool - } - if peerStatusRequiresApproval.Valid { - p.Status.RequiresApproval = peerStatusRequiresApproval.Bool - } - if metaHostname.Valid { - p.Meta.Hostname = metaHostname.String - } - if metaGoOS.Valid { - p.Meta.GoOS = metaGoOS.String - } - if metaKernel.Valid { - p.Meta.Kernel = metaKernel.String - } - if metaCore.Valid { - p.Meta.Core = metaCore.String - } - if metaPlatform.Valid { - p.Meta.Platform = metaPlatform.String - } - if metaOS.Valid { - p.Meta.OS = metaOS.String - } - if metaOSVersion.Valid { - p.Meta.OSVersion = metaOSVersion.String - } - if metaWtVersion.Valid { - p.Meta.WtVersion = metaWtVersion.String - } - if metaUIVersion.Valid { - p.Meta.UIVersion = metaUIVersion.String - } - if metaKernelVersion.Valid { - p.Meta.KernelVersion = metaKernelVersion.String - } - if metaSystemSerialNumber.Valid { - p.Meta.SystemSerialNumber = metaSystemSerialNumber.String - } - if metaSystemProductName.Valid { - p.Meta.SystemProductName = metaSystemProductName.String - } - if metaSystemManufacturer.Valid { - p.Meta.SystemManufacturer = metaSystemManufacturer.String - } - if locationCountryCode.Valid { - p.Location.CountryCode = locationCountryCode.String - } - if locationCityName.Valid { - p.Location.CityName = locationCityName.String - } - if locationGeoNameID.Valid { - p.Location.GeoNameID = uint(locationGeoNameID.Int64) - } - if proxyEmbedded.Valid { - p.ProxyMeta.Embedded = proxyEmbedded.Bool - } - if proxyCluster.Valid { - p.ProxyMeta.Cluster = proxyCluster.String - } - if ip != nil { - _ = json.Unmarshal(ip, &p.IP) - } - if ipv6 != nil { - _ = json.Unmarshal(ipv6, &p.IPv6) - } - if extraDNS != nil { - _ = json.Unmarshal(extraDNS, &p.ExtraDNSLabels) - } - if netAddr != nil { - _ = json.Unmarshal(netAddr, &p.Meta.NetworkAddresses) - } - if env != nil { - _ = json.Unmarshal(env, &p.Meta.Environment) - } - if flags != nil { - _ = json.Unmarshal(flags, &p.Meta.Flags) - } - if files != nil { - _ = json.Unmarshal(files, &p.Meta.Files) - } - if capabilities != nil { - _ = json.Unmarshal(capabilities, &p.Meta.Capabilities) - } - if connIP != nil { - _ = json.Unmarshal(connIP, &p.Location.ConnectionIP) - } - if metaSyncMessageVersion.Valid { - p.Meta.SyncMessageVersion = int(metaSyncMessageVersion.Int32) - } - } - return p, err - }) - if err != nil { - return nil, err - } - return peers, nil -} - -func (s *SqlStore) getUsers(ctx context.Context, accountID string) ([]types.User, error) { - const query = `SELECT id, account_id, role, is_service_user, non_deletable, service_user_name, auto_groups, blocked, pending_approval, last_login, created_at, issued, integration_ref_id, integration_ref_integration_type, email, name FROM users WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) - if err != nil { - return nil, err - } - users, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (types.User, error) { - var u types.User - var autoGroups []byte - var lastLogin, createdAt sql.NullTime - var isServiceUser, nonDeletable, blocked, pendingApproval sql.NullBool - err := row.Scan(&u.Id, &u.AccountID, &u.Role, &isServiceUser, &nonDeletable, &u.ServiceUserName, &autoGroups, &blocked, &pendingApproval, &lastLogin, &createdAt, &u.Issued, &u.IntegrationReference.ID, &u.IntegrationReference.IntegrationType, &u.Email, &u.Name) - if err == nil { - if lastLogin.Valid { - u.LastLogin = &lastLogin.Time - } - if createdAt.Valid { - u.CreatedAt = createdAt.Time - } - if isServiceUser.Valid { - u.IsServiceUser = isServiceUser.Bool - } - if nonDeletable.Valid { - u.NonDeletable = nonDeletable.Bool - } - if blocked.Valid { - u.Blocked = blocked.Bool - } - if pendingApproval.Valid { - u.PendingApproval = pendingApproval.Bool - } - if autoGroups != nil { - _ = json.Unmarshal(autoGroups, &u.AutoGroups) - } else { - u.AutoGroups = []string{} - } - } - return u, err - }) - if err != nil { - return nil, err - } - return users, nil -} - -func (s *SqlStore) getGroups(ctx context.Context, accountID string) ([]*types.Group, error) { - const query = `SELECT id, account_id, public_id, name, issued, resources, integration_ref_id, integration_ref_integration_type FROM groups WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) - if err != nil { - return nil, err - } - groups, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (*types.Group, error) { - var g types.Group - var resources []byte - var refID sql.NullInt64 - var refType sql.NullString - err := row.Scan(&g.ID, &g.AccountID, &g.PublicID, &g.Name, &g.Issued, &resources, &refID, &refType) - if err == nil { - if refID.Valid { - g.IntegrationReference.ID = int(refID.Int64) - } - if refType.Valid { - g.IntegrationReference.IntegrationType = refType.String - } - if resources != nil { - _ = json.Unmarshal(resources, &g.Resources) - } else { - g.Resources = []types.Resource{} - } - g.GroupPeers = []types.GroupPeer{} - g.Peers = []string{} - } - return &g, err - }) - if err != nil { - return nil, err - } - return groups, nil -} - -func (s *SqlStore) getPolicies(ctx context.Context, accountID string) ([]*types.Policy, error) { - const query = `SELECT id, account_id, public_id, name, description, enabled, source_posture_checks FROM policies WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) - if err != nil { - return nil, err - } - policies, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (*types.Policy, error) { - var p types.Policy - var checks []byte - var enabled sql.NullBool - err := row.Scan(&p.ID, &p.AccountID, &p.PublicID, &p.Name, &p.Description, &enabled, &checks) - if err == nil { - if enabled.Valid { - p.Enabled = enabled.Bool - } - if checks != nil { - _ = json.Unmarshal(checks, &p.SourcePostureChecks) - } - } - return &p, err - }) - if err != nil { - return nil, err - } - return policies, nil -} - -func (s *SqlStore) getRoutes(ctx context.Context, accountID string) ([]route.Route, error) { - const query = `SELECT id, account_id, public_id, network, domains, keep_route, net_id, description, peer, peer_groups, network_type, masquerade, metric, enabled, groups, access_control_groups, skip_auto_apply FROM routes WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) - if err != nil { - return nil, err - } - routes, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (route.Route, error) { - var r route.Route - var network, domains, peerGroups, groups, accessGroups []byte - var keepRoute, masquerade, enabled, skipAutoApply sql.NullBool - var metric sql.NullInt64 - err := row.Scan(&r.ID, &r.AccountID, &r.PublicID, &network, &domains, &keepRoute, &r.NetID, &r.Description, &r.Peer, &peerGroups, &r.NetworkType, &masquerade, &metric, &enabled, &groups, &accessGroups, &skipAutoApply) - if err == nil { - if keepRoute.Valid { - r.KeepRoute = keepRoute.Bool - } - if masquerade.Valid { - r.Masquerade = masquerade.Bool - } - if enabled.Valid { - r.Enabled = enabled.Bool - } - if skipAutoApply.Valid { - r.SkipAutoApply = skipAutoApply.Bool - } - if metric.Valid { - r.Metric = int(metric.Int64) - } - if network != nil { - _ = json.Unmarshal(network, &r.Network) - } - if domains != nil { - _ = json.Unmarshal(domains, &r.Domains) - } - if peerGroups != nil { - _ = json.Unmarshal(peerGroups, &r.PeerGroups) - } - if groups != nil { - _ = json.Unmarshal(groups, &r.Groups) - } - if accessGroups != nil { - _ = json.Unmarshal(accessGroups, &r.AccessControlGroups) - } - } - return r, err - }) - if err != nil { - return nil, err - } - return routes, nil -} - -func (s *SqlStore) getNameServerGroups(ctx context.Context, accountID string) ([]nbdns.NameServerGroup, error) { - const query = `SELECT id, account_id, public_id, name, description, name_servers, groups, "primary", domains, enabled, search_domains_enabled FROM name_server_groups WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) - if err != nil { - return nil, err - } - nsgs, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (nbdns.NameServerGroup, error) { - var n nbdns.NameServerGroup - var ns, groups, domains []byte - var primary, enabled, searchDomainsEnabled sql.NullBool - err := row.Scan(&n.ID, &n.AccountID, &n.PublicID, &n.Name, &n.Description, &ns, &groups, &primary, &domains, &enabled, &searchDomainsEnabled) - if err == nil { - if primary.Valid { - n.Primary = primary.Bool - } - if enabled.Valid { - n.Enabled = enabled.Bool - } - if searchDomainsEnabled.Valid { - n.SearchDomainsEnabled = searchDomainsEnabled.Bool - } - if ns != nil { - _ = json.Unmarshal(ns, &n.NameServers) - } else { - n.NameServers = []nbdns.NameServer{} - } - if groups != nil { - _ = json.Unmarshal(groups, &n.Groups) - } else { - n.Groups = []string{} - } - if domains != nil { - _ = json.Unmarshal(domains, &n.Domains) - } else { - n.Domains = []string{} - } - } - return n, err - }) - if err != nil { - return nil, err - } - return nsgs, nil -} - -func (s *SqlStore) getPostureChecks(ctx context.Context, accountID string) ([]*posture.Checks, error) { - const query = `SELECT id, account_id, public_id, name, description, checks FROM posture_checks WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) - if err != nil { - return nil, err - } - checks, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (*posture.Checks, error) { - var c posture.Checks - var checksDef []byte - err := row.Scan(&c.ID, &c.AccountID, &c.PublicID, &c.Name, &c.Description, &checksDef) - if err == nil && checksDef != nil { - _ = json.Unmarshal(checksDef, &c.Checks) - } - return &c, err - }) - if err != nil { - return nil, err - } - return checks, nil -} - -// serviceSelectColumns and targetSelectColumns are the column lists the Postgres -// pgx read path scans. They must stay in sync with the rpservice.Service and -// rpservice.Target gorm models; TestPgxServiceColumnsMatchGorm enforces this. -const serviceSelectColumns = `id, account_id, name, domain, enabled, auth, restrictions, - meta_created_at, meta_certificate_issued_at, meta_last_renewed_at, meta_status, proxy_cluster, - pass_host_header, rewrite_redirects, session_private_key, session_public_key, - mode, listen_port, port_auto_assigned, source, source_peer, terminated, - private, access_groups` - -const targetSelectColumns = `id, account_id, service_id, path, host, port, protocol, - target_id, target_type, enabled, proxy_protocol, - skip_tls_verify, request_timeout, session_idle_timeout, path_rewrite, custom_headers, - direct_upstream, middlewares, capture_max_request_bytes, capture_max_response_bytes, - capture_content_types, agent_network, disable_access_log` - -func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpservice.Service, error) { - const serviceQuery = `SELECT ` + serviceSelectColumns + ` FROM services WHERE account_id = $1` - - serviceRows, err := s.pool.Query(ctx, serviceQuery, accountID) - if err != nil { - return nil, err - } - - services, err := pgx.CollectRows(serviceRows, scanService) - if err != nil { - return nil, err - } - - if len(services) == 0 { - return services, nil - } - - serviceIDs := make([]string, len(services)) - serviceMap := make(map[string]*rpservice.Service) - for i, svc := range services { - serviceIDs[i] = svc.ID - serviceMap[svc.ID] = svc - } - - targets, err := s.getServiceTargets(ctx, serviceIDs) - if err != nil { - return nil, err - } - - for _, target := range targets { - if service, ok := serviceMap[target.ServiceID]; ok { - service.Targets = append(service.Targets, target) - } - } - - return services, nil -} - -func scanService(row pgx.CollectableRow) (*rpservice.Service, error) { - var s rpservice.Service - var auth []byte - var restrictions []byte - var accessGroups []byte - var createdAt, certIssuedAt, lastRenewedAt sql.NullTime - var status, proxyCluster, sessionPrivateKey, sessionPublicKey sql.NullString - var mode, source, sourcePeer sql.NullString - var terminated, portAutoAssigned, private sql.NullBool - var listenPort sql.NullInt64 - err := row.Scan( - &s.ID, - &s.AccountID, - &s.Name, - &s.Domain, - &s.Enabled, - &auth, - &restrictions, - &createdAt, - &certIssuedAt, - &lastRenewedAt, - &status, - &proxyCluster, - &s.PassHostHeader, - &s.RewriteRedirects, - &sessionPrivateKey, - &sessionPublicKey, - &mode, - &listenPort, - &portAutoAssigned, - &source, - &sourcePeer, - &terminated, - &private, - &accessGroups, - ) - if err != nil { - return nil, err - } - - if auth != nil { - if err := json.Unmarshal(auth, &s.Auth); err != nil { - return nil, err - } - } - - if len(restrictions) > 0 { - if err := json.Unmarshal(restrictions, &s.Restrictions); err != nil { - return nil, fmt.Errorf("unmarshal restrictions: %w", err) - } - } - - if len(accessGroups) > 0 { - if err := json.Unmarshal(accessGroups, &s.AccessGroups); err != nil { - return nil, fmt.Errorf("unmarshal access_groups: %w", err) - } - } - - if private.Valid { - s.Private = private.Bool - } - - s.Meta = serviceMetaFromRow(createdAt, certIssuedAt, lastRenewedAt, status) - if proxyCluster.Valid { - s.ProxyCluster = proxyCluster.String - } - if sessionPrivateKey.Valid { - s.SessionPrivateKey = sessionPrivateKey.String - } - if sessionPublicKey.Valid { - s.SessionPublicKey = sessionPublicKey.String - } - if mode.Valid { - s.Mode = mode.String - } - if source.Valid { - s.Source = source.String - } - if sourcePeer.Valid { - s.SourcePeer = sourcePeer.String - } - if terminated.Valid { - s.Terminated = terminated.Bool - } - if portAutoAssigned.Valid { - s.PortAutoAssigned = portAutoAssigned.Bool - } - if listenPort.Valid { - if listenPort.Int64 < 0 || listenPort.Int64 > math.MaxUint16 { - return nil, fmt.Errorf("listen_port %d out of range", listenPort.Int64) - } - s.ListenPort = uint16(listenPort.Int64) - } - s.Targets = []*rpservice.Target{} - return &s, nil -} - -func serviceMetaFromRow(createdAt, certIssuedAt, lastRenewedAt sql.NullTime, status sql.NullString) rpservice.Meta { - meta := rpservice.Meta{} - if createdAt.Valid { - meta.CreatedAt = createdAt.Time - } - if certIssuedAt.Valid { - t := certIssuedAt.Time - meta.CertificateIssuedAt = &t - } - if lastRenewedAt.Valid { - t := lastRenewedAt.Time - meta.LastRenewedAt = &t - } - if status.Valid { - meta.Status = status.String - } - return meta -} - -func (s *SqlStore) getServiceTargets(ctx context.Context, serviceIDs []string) ([]*rpservice.Target, error) { - const targetsQuery = `SELECT ` + targetSelectColumns + ` FROM targets WHERE service_id = ANY($1)` - - rows, err := s.pool.Query(ctx, targetsQuery, serviceIDs) - if err != nil { - return nil, err - } - - return pgx.CollectRows(rows, scanTarget) -} - -func scanTarget(row pgx.CollectableRow) (*rpservice.Target, error) { - var t rpservice.Target - var path sql.NullString - var pathRewrite sql.NullString - var proxyProtocol, skipTLSVerify, directUpstream, agentNetwork, disableAccessLog sql.NullBool - var requestTimeout, sessionIdleTimeout, captureMaxRequestBytes, captureMaxResponseBytes sql.NullInt64 - var customHeaders, middlewares, captureContentTypes []byte - err := row.Scan( - &t.ID, - &t.AccountID, - &t.ServiceID, - &path, - &t.Host, - &t.Port, - &t.Protocol, - &t.TargetId, - &t.TargetType, - &t.Enabled, - &proxyProtocol, - &skipTLSVerify, - &requestTimeout, - &sessionIdleTimeout, - &pathRewrite, - &customHeaders, - &directUpstream, - &middlewares, - &captureMaxRequestBytes, - &captureMaxResponseBytes, - &captureContentTypes, - &agentNetwork, - &disableAccessLog, - ) - if err != nil { - return nil, err - } - if path.Valid { - t.Path = &path.String - } - - t.ProxyProtocol = proxyProtocol.Bool - t.Options.SkipTLSVerify = skipTLSVerify.Bool - t.Options.RequestTimeout = time.Duration(requestTimeout.Int64) - t.Options.SessionIdleTimeout = time.Duration(sessionIdleTimeout.Int64) - t.Options.PathRewrite = rpservice.PathRewriteMode(pathRewrite.String) - t.Options.DirectUpstream = directUpstream.Bool - t.Options.CaptureMaxRequestBytes = captureMaxRequestBytes.Int64 - t.Options.CaptureMaxResponseBytes = captureMaxResponseBytes.Int64 - t.Options.AgentNetwork = agentNetwork.Bool - t.Options.DisableAccessLog = disableAccessLog.Bool - - if len(customHeaders) > 0 { - if err := json.Unmarshal(customHeaders, &t.Options.CustomHeaders); err != nil { - return nil, fmt.Errorf("unmarshal custom_headers: %w", err) - } - } - if len(middlewares) > 0 { - if err := json.Unmarshal(middlewares, &t.Options.Middlewares); err != nil { - return nil, fmt.Errorf("unmarshal middlewares: %w", err) - } - } - if len(captureContentTypes) > 0 { - if err := json.Unmarshal(captureContentTypes, &t.Options.CaptureContentTypes); err != nil { - return nil, fmt.Errorf("unmarshal capture_content_types: %w", err) - } - } - return &t, nil -} - -func (s *SqlStore) getNetworks(ctx context.Context, accountID string) ([]*networkTypes.Network, error) { - const query = `SELECT id, account_id, public_id, name, description FROM networks WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) - if err != nil { - return nil, err - } - networks, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkTypes.Network]) - if err != nil { - return nil, err - } - result := make([]*networkTypes.Network, len(networks)) - for i := range networks { - result[i] = &networks[i] - } - return result, nil -} - -func (s *SqlStore) getNetworkRouters(ctx context.Context, accountID string) ([]*routerTypes.NetworkRouter, error) { - const query = `SELECT id, network_id, account_id, public_id, peer, peer_groups, masquerade, metric, enabled FROM network_routers WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) - if err != nil { - return nil, err - } - routers, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (routerTypes.NetworkRouter, error) { - var r routerTypes.NetworkRouter - var peerGroups []byte - var masquerade, enabled sql.NullBool - var metric sql.NullInt64 - err := row.Scan(&r.ID, &r.NetworkID, &r.AccountID, &r.PublicID, &r.Peer, &peerGroups, &masquerade, &metric, &enabled) - if err == nil { - if masquerade.Valid { - r.Masquerade = masquerade.Bool - } - if enabled.Valid { - r.Enabled = enabled.Bool - } - if metric.Valid { - r.Metric = int(metric.Int64) - } - if peerGroups != nil { - _ = json.Unmarshal(peerGroups, &r.PeerGroups) - } - } - return r, err - }) - if err != nil { - return nil, err - } - result := make([]*routerTypes.NetworkRouter, len(routers)) - for i := range routers { - result[i] = &routers[i] - } - return result, nil -} - -func (s *SqlStore) getNetworkResources(ctx context.Context, accountID string) ([]*resourceTypes.NetworkResource, error) { - const query = `SELECT id, network_id, account_id, public_id, name, description, type, domain, prefix, enabled FROM network_resources WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) - if err != nil { - return nil, err - } - resources, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (resourceTypes.NetworkResource, error) { - var r resourceTypes.NetworkResource - var prefix []byte - var enabled sql.NullBool - err := row.Scan(&r.ID, &r.NetworkID, &r.AccountID, &r.PublicID, &r.Name, &r.Description, &r.Type, &r.Domain, &prefix, &enabled) - if err == nil { - if enabled.Valid { - r.Enabled = enabled.Bool - } - if prefix != nil { - _ = json.Unmarshal(prefix, &r.Prefix) - } - } - return r, err - }) - if err != nil { - return nil, err - } - result := make([]*resourceTypes.NetworkResource, len(resources)) - for i := range resources { - result[i] = &resources[i] - } - return result, nil -} - -func (s *SqlStore) getAccountOnboarding(ctx context.Context, accountID string, account *types.Account) error { - const query = `SELECT account_id, onboarding_flow_pending, signup_form_pending, created_at, updated_at FROM account_onboardings WHERE account_id = $1` - var onboardingFlowPending, signupFormPending sql.NullBool - var createdAt, updatedAt sql.NullTime - err := s.pool.QueryRow(ctx, query, accountID).Scan( - &account.Onboarding.AccountID, - &onboardingFlowPending, - &signupFormPending, - &createdAt, - &updatedAt, - ) - if err != nil && !errors.Is(err, pgx.ErrNoRows) { - return err - } - if createdAt.Valid { - account.Onboarding.CreatedAt = createdAt.Time - } - if updatedAt.Valid { - account.Onboarding.UpdatedAt = updatedAt.Time - } - if onboardingFlowPending.Valid { - account.Onboarding.OnboardingFlowPending = onboardingFlowPending.Bool - } - if signupFormPending.Valid { - account.Onboarding.SignupFormPending = signupFormPending.Bool - } - return nil -} - -func (s *SqlStore) getPersonalAccessTokens(ctx context.Context, userIDs []string) ([]types.PersonalAccessToken, error) { - if len(userIDs) == 0 { - return nil, nil - } - const query = `SELECT id, user_id, name, hashed_token, expiration_date, created_by, created_at, last_used FROM personal_access_tokens WHERE user_id = ANY($1)` - rows, err := s.pool.Query(ctx, query, userIDs) - if err != nil { - return nil, err - } - pats, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (types.PersonalAccessToken, error) { - var pat types.PersonalAccessToken - var expirationDate, lastUsed, createdAt sql.NullTime - err := row.Scan(&pat.ID, &pat.UserID, &pat.Name, &pat.HashedToken, &expirationDate, &pat.CreatedBy, &createdAt, &lastUsed) - if err == nil { - if expirationDate.Valid { - pat.ExpirationDate = &expirationDate.Time - } - if createdAt.Valid { - pat.CreatedAt = createdAt.Time - } - if lastUsed.Valid { - pat.LastUsed = &lastUsed.Time - } - } - return pat, err - }) - if err != nil { - return nil, err - } - return pats, nil -} - -func (s *SqlStore) getPolicyRules(ctx context.Context, policyIDs []string) ([]*types.PolicyRule, error) { - if len(policyIDs) == 0 { - return nil, nil - } - const query = `SELECT id, policy_id, name, description, enabled, action, destinations, destination_resource, sources, source_resource, bidirectional, protocol, ports, port_ranges, authorized_groups, authorized_user FROM policy_rules WHERE policy_id = ANY($1)` - rows, err := s.pool.Query(ctx, query, policyIDs) - if err != nil { - return nil, err - } - rules, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (*types.PolicyRule, error) { - var r types.PolicyRule - var dest, destRes, sources, sourceRes, ports, portRanges, authorizedGroups []byte - var enabled, bidirectional sql.NullBool - var authorizedUser sql.NullString - err := row.Scan(&r.ID, &r.PolicyID, &r.Name, &r.Description, &enabled, &r.Action, &dest, &destRes, &sources, &sourceRes, &bidirectional, &r.Protocol, &ports, &portRanges, &authorizedGroups, &authorizedUser) - if err == nil { - if enabled.Valid { - r.Enabled = enabled.Bool - } - if bidirectional.Valid { - r.Bidirectional = bidirectional.Bool - } - if dest != nil { - _ = json.Unmarshal(dest, &r.Destinations) - } - if destRes != nil { - _ = json.Unmarshal(destRes, &r.DestinationResource) - } - if sources != nil { - _ = json.Unmarshal(sources, &r.Sources) - } - if sourceRes != nil { - _ = json.Unmarshal(sourceRes, &r.SourceResource) - } - if ports != nil { - _ = json.Unmarshal(ports, &r.Ports) - } - if portRanges != nil { - _ = json.Unmarshal(portRanges, &r.PortRanges) - } - if authorizedGroups != nil { - _ = json.Unmarshal(authorizedGroups, &r.AuthorizedGroups) - } - if authorizedUser.Valid { - r.AuthorizedUser = authorizedUser.String - } - } - return &r, err - }) - if err != nil { - return nil, err - } - return rules, nil -} - -func (s *SqlStore) getGroupPeers(ctx context.Context, groupIDs []string) ([]types.GroupPeer, error) { - if len(groupIDs) == 0 { - return nil, nil - } - const query = `SELECT account_id, group_id, peer_id FROM group_peers WHERE group_id = ANY($1)` - rows, err := s.pool.Query(ctx, query, groupIDs) - if err != nil { - return nil, err - } - groupPeers, err := pgx.CollectRows(rows, pgx.RowToStructByName[types.GroupPeer]) - if err != nil { - return nil, err - } - return groupPeers, nil -} - -func (s *SqlStore) GetAccountByUser(ctx context.Context, userID string) (*types.Account, error) { - var user types.User - result := s.db.Select("account_id").Take(&user, idQueryCondition, userID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") - } - return nil, status.NewGetAccountFromStoreError(result.Error) - } - - if user.AccountID == "" { - return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") - } - - return s.GetAccount(ctx, user.AccountID) -} - -func (s *SqlStore) GetAccountByPeerID(ctx context.Context, peerID string) (*types.Account, error) { - var peer nbpeer.Peer - result := s.db.Select("account_id").Take(&peer, idQueryCondition, peerID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") - } - return nil, status.NewGetAccountFromStoreError(result.Error) - } - - if peer.AccountID == "" { - return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") - } - - return s.GetAccount(ctx, peer.AccountID) -} - -func (s *SqlStore) GetAccountByPeerPubKey(ctx context.Context, peerKey string) (*types.Account, error) { - var peer nbpeer.Peer - result := s.db.Select("account_id").Take(&peer, GetKeyQueryCondition(s), peerKey) - - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") - } - return nil, status.NewGetAccountFromStoreError(result.Error) - } - - if peer.AccountID == "" { - return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") - } - - return s.GetAccount(ctx, peer.AccountID) -} - -func (s *SqlStore) GetAnyAccountID(ctx context.Context) (string, error) { - var account types.Account - result := s.db.Select("id").Order("created_at desc").Limit(1).Find(&account) - if result.Error != nil { - return "", status.NewGetAccountFromStoreError(result.Error) - } - if result.RowsAffected == 0 { - return "", status.Errorf(status.NotFound, "account not found: index lookup failed") - } - - return account.Id, nil -} - -func (s *SqlStore) GetAccountIDByPeerPubKey(ctx context.Context, peerKey string) (string, error) { - var peer nbpeer.Peer - var accountID string - result := s.db.Model(&peer).Select("account_id").Where(GetKeyQueryCondition(s), peerKey).Take(&accountID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return "", status.Errorf(status.NotFound, "account not found: index lookup failed") - } - return "", status.NewGetAccountFromStoreError(result.Error) - } - - return accountID, nil -} - -func (s *SqlStore) GetAccountIDByUserID(ctx context.Context, lockStrength LockingStrength, userID string) (string, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var accountID string - result := tx.Model(&types.User{}). - Select("account_id").Where(idQueryCondition, userID).Take(&accountID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return "", status.Errorf(status.NotFound, "account not found: index lookup failed") - } - return "", status.NewGetAccountFromStoreError(result.Error) - } - - return accountID, nil -} - -func (s *SqlStore) GetAccountIDByPeerID(ctx context.Context, lockStrength LockingStrength, peerID string) (string, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var accountID string - result := tx.Model(&nbpeer.Peer{}). - Select("account_id").Where(idQueryCondition, peerID).Take(&accountID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return "", status.Errorf(status.NotFound, "peer %s account not found", peerID) - } - return "", status.NewGetAccountFromStoreError(result.Error) - } - - return accountID, nil -} - -func (s *SqlStore) GetAccountIDBySetupKey(ctx context.Context, setupKey string) (string, error) { - var accountID string - result := s.db.Model(&types.SetupKey{}).Select("account_id").Where(GetKeyQueryCondition(s), setupKey).Take(&accountID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return "", status.NewSetupKeyNotFoundError(setupKey) - } - log.WithContext(ctx).Errorf("failed to get account ID by setup key from store: %v", result.Error) - return "", status.Errorf(status.Internal, "failed to get account ID by setup key from store") - } - - if accountID == "" { - return "", status.Errorf(status.NotFound, "account not found: index lookup failed") - } - - return accountID, nil -} - -func (s *SqlStore) GetTakenIPs(ctx context.Context, lockStrength LockingStrength, accountID string) ([]netip.Addr, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var ipJSONStrings []string - - result := tx.Model(&nbpeer.Peer{}). - Where("account_id = ?", accountID). - Pluck("ip", &ipJSONStrings) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.Errorf(status.NotFound, "no peers found for the account") - } - return nil, status.Errorf(status.Internal, "issue getting IPs from store: %s", result.Error) - } - - ips := make([]netip.Addr, len(ipJSONStrings)) - for i, ipJSON := range ipJSONStrings { - var ip netip.Addr - if err := json.Unmarshal([]byte(ipJSON), &ip); err != nil { - return nil, status.Errorf(status.Internal, "issue parsing IP JSON from store") - } - ips[i] = ip.Unmap() - } - - return ips, nil -} - -func (s *SqlStore) GetPeerLabelsInAccount(ctx context.Context, lockStrength LockingStrength, accountID string, dnsLabel string) ([]string, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var labels []string - result := tx.Model(&nbpeer.Peer{}). - Where("account_id = ? AND dns_label LIKE ?", accountID, dnsLabel+"%"). - Pluck("dns_label", &labels) - - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.Errorf(status.NotFound, "no peers found for the account") - } - log.WithContext(ctx).Errorf("error when getting dns labels from the store: %s", result.Error) - return nil, status.Errorf(status.Internal, "issue getting dns labels from store: %s", result.Error) - } - - return labels, nil -} - -func (s *SqlStore) GetAccountNetwork(ctx context.Context, lockStrength LockingStrength, accountID string) (*types.Network, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var accountNetwork types.AccountNetwork - if err := tx.Model(&types.Account{}).Where(idQueryCondition, accountID).Take(&accountNetwork).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, status.NewAccountNotFoundError(accountID) - } - return nil, status.Errorf(status.Internal, "issue getting network from store: %s", err) - } - return accountNetwork.Network, nil -} - -func (s *SqlStore) GetPeerByPeerPubKey(ctx context.Context, lockStrength LockingStrength, peerKey string) (*nbpeer.Peer, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var peer nbpeer.Peer - result := tx.Take(&peer, GetKeyQueryCondition(s), peerKey) - - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.NewPeerNotFoundError(peerKey) - } - return nil, status.Errorf(status.Internal, "issue getting peer from store: %s", result.Error) - } - - return &peer, nil -} - -func (s *SqlStore) GetAccountSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*types.Settings, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var accountSettings types.AccountSettings - if err := tx.Model(&types.Account{}).Where(idQueryCondition, accountID).Take(&accountSettings).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, status.Errorf(status.NotFound, "settings not found") - } - return nil, status.Errorf(status.Internal, "issue getting settings from store: %s", err) - } - return accountSettings.Settings, nil -} - -func (s *SqlStore) GetAccountCreatedBy(ctx context.Context, lockStrength LockingStrength, accountID string) (string, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var createdBy string - result := tx.Model(&types.Account{}). - Select("created_by").Take(&createdBy, idQueryCondition, accountID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return "", status.NewAccountNotFoundError(accountID) - } - return "", status.NewGetAccountFromStoreError(result.Error) - } - - return createdBy, nil -} - -// SaveUserLastLogin stores the last login time for a user in DB. -func (s *SqlStore) SaveUserLastLogin(ctx context.Context, accountID, userID string, lastLogin time.Time) error { - var user types.User - result := s.db.Take(&user, accountAndIDQueryCondition, accountID, userID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return status.NewUserNotFoundError(userID) - } - return status.NewGetUserFromStoreError() - } - - if !lastLogin.IsZero() { - user.LastLogin = &lastLogin - return s.db.Save(&user).Error - } - - return nil -} - -func (s *SqlStore) GetPostureCheckByChecksDefinition(accountID string, checks *posture.ChecksDefinition) (*posture.Checks, error) { - definitionJSON, err := json.Marshal(checks) - if err != nil { - return nil, err - } - - var postureCheck posture.Checks - err = s.db.Where("account_id = ? AND checks = ?", accountID, string(definitionJSON)).Take(&postureCheck).Error - if err != nil { - return nil, err - } - - return &postureCheck, nil -} - // Close closes the underlying DB connection func (s *SqlStore) Close(_ context.Context) error { sql, err := s.db.DB() @@ -3325,402 +446,6 @@ func newMysqlStoreFromSqlStore(ctx context.Context, sqliteStore *SqlStore, dsn s return store, nil } -func (s *SqlStore) GetSetupKeyBySecret(ctx context.Context, lockStrength LockingStrength, key string) (*types.SetupKey, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var setupKey types.SetupKey - result := tx. - Take(&setupKey, GetKeyQueryCondition(s), key) - - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.Errorf(status.PreconditionFailed, "setup key not found") - } - log.WithContext(ctx).Errorf("failed to get setup key by secret from store: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to get setup key by secret from store") - } - return &setupKey, nil -} - -func (s *SqlStore) IncrementSetupKeyUsage(ctx context.Context, setupKeyID string) error { - result := s.db.Model(&types.SetupKey{}). - Where(idQueryCondition, setupKeyID). - Updates(map[string]interface{}{ - "used_times": gorm.Expr("used_times + 1"), - "last_used": time.Now(), - }) - - if result.Error != nil { - return status.Errorf(status.Internal, "issue incrementing setup key usage count: %s", result.Error) - } - - if result.RowsAffected == 0 { - return status.NewSetupKeyNotFoundError(setupKeyID) - } - - return nil -} - -// AddPeerToAllGroup adds a peer to the 'All' group. Method always needs to run in a transaction -func (s *SqlStore) AddPeerToAllGroup(ctx context.Context, accountID string, peerID string) error { - var groupID string - _ = s.db.Model(types.Group{}). - Select("id"). - Where("account_id = ? AND name = ?", accountID, "All"). - Limit(1). - Scan(&groupID) - - if groupID == "" { - return status.Errorf(status.NotFound, "group 'All' not found for account %s", accountID) - } - - err := s.db.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "group_id"}, {Name: "peer_id"}}, - DoNothing: true, - }).Create(&types.GroupPeer{ - AccountID: accountID, - GroupID: groupID, - PeerID: peerID, - }).Error - if err != nil { - return status.Errorf(status.Internal, "error adding peer to group 'All': %v", err) - } - - return nil -} - -// AddPeerToGroup adds a peer to a group -func (s *SqlStore) AddPeerToGroup(ctx context.Context, accountID, peerID, groupID string) error { - peer := &types.GroupPeer{ - AccountID: accountID, - GroupID: groupID, - PeerID: peerID, - } - - err := s.db.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "group_id"}, {Name: "peer_id"}}, - DoNothing: true, - }).Create(peer).Error - if err != nil { - log.WithContext(ctx).Errorf("failed to add peer %s to group %s for account %s: %v", peerID, groupID, accountID, err) - return status.Errorf(status.Internal, "failed to add peer to group") - } - - return nil -} - -// RemovePeerFromGroup removes a peer from a group -func (s *SqlStore) RemovePeerFromGroup(ctx context.Context, peerID string, groupID string) error { - err := s.db. - Delete(&types.GroupPeer{}, "group_id = ? AND peer_id = ?", groupID, peerID).Error - if err != nil { - log.WithContext(ctx).Errorf("failed to remove peer %s from group %s: %v", peerID, groupID, err) - return status.Errorf(status.Internal, "failed to remove peer from group") - } - - return nil -} - -// RemovePeerFromAllGroups removes a peer from all groups -func (s *SqlStore) RemovePeerFromAllGroups(ctx context.Context, peerID string) error { - err := s.db. - Delete(&types.GroupPeer{}, "peer_id = ?", peerID).Error - if err != nil { - log.WithContext(ctx).Errorf("failed to remove peer %s from all groups: %v", peerID, err) - return status.Errorf(status.Internal, "failed to remove peer from all groups") - } - - return nil -} - -// AddResourceToGroup adds a resource to a group. Method always needs to run n a transaction -func (s *SqlStore) AddResourceToGroup(ctx context.Context, accountId string, groupID string, resource *types.Resource) error { - var group types.Group - result := s.db.Where(accountAndIDQueryCondition, accountId, groupID).Take(&group) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return status.NewGroupNotFoundError(groupID) - } - - return status.Errorf(status.Internal, "issue finding group: %s", result.Error) - } - - for _, res := range group.Resources { - if res.ID == resource.ID { - return nil - } - } - - group.Resources = append(group.Resources, *resource) - - if err := s.db.Save(&group).Error; err != nil { - return status.Errorf(status.Internal, "issue updating group: %s", err) - } - - return nil -} - -// RemoveResourceFromGroup removes a resource from a group. Method always needs to run in a transaction -func (s *SqlStore) RemoveResourceFromGroup(ctx context.Context, accountId string, groupID string, resourceID string) error { - var group types.Group - result := s.db.Where(accountAndIDQueryCondition, accountId, groupID).Take(&group) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return status.NewGroupNotFoundError(groupID) - } - - return status.Errorf(status.Internal, "issue finding group: %s", result.Error) - } - - for i, res := range group.Resources { - if res.ID == resourceID { - group.Resources = append(group.Resources[:i], group.Resources[i+1:]...) - break - } - } - - if err := s.db.Save(&group).Error; err != nil { - return status.Errorf(status.Internal, "issue updating group: %s", err) - } - - return nil -} - -// GetPeerGroups retrieves all groups assigned to a specific peer in a given account. -func (s *SqlStore) GetPeerGroups(ctx context.Context, lockStrength LockingStrength, accountId string, peerId string) ([]*types.Group, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var groups []*types.Group - query := tx. - Joins("JOIN group_peers ON group_peers.group_id = groups.id"). - Where("groups.account_id = ? AND group_peers.peer_id = ?", accountId, peerId). - Preload(clause.Associations). - Find(&groups) - - if query.Error != nil { - return nil, query.Error - } - - for _, group := range groups { - group.LoadGroupPeers() - } - - return groups, nil -} - -// GetPeerGroupIDs retrieves all group IDs assigned to a specific peer in a given account. -func (s *SqlStore) GetPeerGroupIDs(ctx context.Context, lockStrength LockingStrength, accountId string, peerId string) ([]string, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var groupIDs []string - query := tx. - Model(&types.GroupPeer{}). - Where("account_id = ? AND peer_id = ?", accountId, peerId). - Pluck("group_id", &groupIDs) - - if query.Error != nil { - if errors.Is(query.Error, gorm.ErrRecordNotFound) { - return nil, status.Errorf(status.NotFound, "no groups found for peer %s in account %s", peerId, accountId) - } - log.WithContext(ctx).Errorf("failed to get group IDs for peer %s in account %s: %v", peerId, accountId, query.Error) - return nil, status.Errorf(status.Internal, "failed to get group IDs for peer from store") - } - - return groupIDs, nil -} - -// GetAccountPeers retrieves peers for an account. -func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) { - var peers []*nbpeer.Peer - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - query := tx.Where(accountIDCondition, accountID) - - if nameFilter != "" { - query = query.Where("name LIKE ?", "%"+nameFilter+"%") - } - if ipFilter != "" { - query = query.Where("ip LIKE ? OR ipv6 LIKE ?", "%"+ipFilter+"%", "%"+ipFilter+"%") - } - - if err := query.Find(&peers).Error; err != nil { - log.WithContext(ctx).Errorf("failed to get peers from the store: %s", err) - return nil, status.Errorf(status.Internal, "failed to get peers from store") - } - - return peers, nil -} - -// GetUserPeers retrieves peers for a user. -func (s *SqlStore) GetUserPeers(ctx context.Context, lockStrength LockingStrength, accountID, userID string) ([]*nbpeer.Peer, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var peers []*nbpeer.Peer - - // Exclude peers added via setup keys, as they are not user-specific and have an empty user_id. - if userID == "" { - return peers, nil - } - - result := tx. - Find(&peers, "account_id = ? AND user_id = ?", accountID, userID) - if err := result.Error; err != nil { - log.WithContext(ctx).Errorf("failed to get peers from the store: %s", err) - return nil, status.Errorf(status.Internal, "failed to get peers from store") - } - - return peers, nil -} - -func (s *SqlStore) AddPeerToAccount(ctx context.Context, peer *nbpeer.Peer) error { - if err := s.db.Create(peer).Error; err != nil { - return status.Errorf(status.Internal, "issue adding peer to account: %s", err) - } - - return nil -} - -// GetPeerByID retrieves a peer by its ID and account ID. -func (s *SqlStore) GetPeerByID(ctx context.Context, lockStrength LockingStrength, accountID, peerID string) (*nbpeer.Peer, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var peer *nbpeer.Peer - result := tx. - Take(&peer, accountAndIDQueryCondition, accountID, peerID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.NewPeerNotFoundError(peerID) - } - return nil, status.Errorf(status.Internal, "failed to get peer from store") - } - - return peer, nil -} - -// GetPeersByIDs retrieves peers by their IDs and account ID. -func (s *SqlStore) GetPeersByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, peerIDs []string) (map[string]*nbpeer.Peer, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var peers []*nbpeer.Peer - result := tx.Find(&peers, accountAndIDsQueryCondition, accountID, peerIDs) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get peers by ID's from the store: %s", result.Error) - return nil, status.Errorf(status.Internal, "failed to get peers by ID's from the store") - } - - peersMap := make(map[string]*nbpeer.Peer) - for _, peer := range peers { - peersMap[peer.ID] = peer - } - - return peersMap, nil -} - -// GetAccountPeersWithExpiration retrieves a list of peers that have login expiration enabled and added by a user. -func (s *SqlStore) GetAccountPeersWithExpiration(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*nbpeer.Peer, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var peers []*nbpeer.Peer - result := tx. - Where("login_expiration_enabled = ? AND peer_status_login_expired != ? AND user_id IS NOT NULL AND user_id != ''", true, true). - Find(&peers, accountIDCondition, accountID) - if err := result.Error; err != nil { - log.WithContext(ctx).Errorf("failed to get peers with expiration from the store: %s", result.Error) - return nil, status.Errorf(status.Internal, "failed to get peers with expiration from store") - } - - return peers, nil -} - -// GetAccountPeersWithInactivity retrieves a list of peers that have login expiration enabled and added by a user. -func (s *SqlStore) GetAccountPeersWithInactivity(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*nbpeer.Peer, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var peers []*nbpeer.Peer - result := tx. - Where("inactivity_expiration_enabled = ? AND user_id IS NOT NULL AND user_id != ''", true). - Find(&peers, accountIDCondition, accountID) - if err := result.Error; err != nil { - log.WithContext(ctx).Errorf("failed to get peers with inactivity from the store: %s", result.Error) - return nil, status.Errorf(status.Internal, "failed to get peers with inactivity from store") - } - - return peers, nil -} - -// GetAllEphemeralPeers retrieves all peers with Ephemeral set to true across all accounts, optimized for batch processing. -func (s *SqlStore) GetAllEphemeralPeers(ctx context.Context, lockStrength LockingStrength) ([]*nbpeer.Peer, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var allEphemeralPeers, batchPeers []*nbpeer.Peer - result := tx. - Where("ephemeral = ?", true). - FindInBatches(&batchPeers, 1000, func(tx *gorm.DB, batch int) error { - allEphemeralPeers = append(allEphemeralPeers, batchPeers...) - return nil - }) - - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to retrieve ephemeral peers: %s", result.Error) - return nil, fmt.Errorf("failed to retrieve ephemeral peers") - } - - return allEphemeralPeers, nil -} - -// DeletePeer removes a peer from the store. -func (s *SqlStore) DeletePeer(ctx context.Context, accountID string, peerID string) error { - result := s.db.Delete(&nbpeer.Peer{}, accountAndIDQueryCondition, accountID, peerID) - if err := result.Error; err != nil { - log.WithContext(ctx).Errorf("failed to delete peer from the store: %s", err) - return status.Errorf(status.Internal, "failed to delete peer from store") - } - - if result.RowsAffected == 0 { - return status.NewPeerNotFoundError(peerID) - } - - return nil -} - -func (s *SqlStore) IncrementNetworkSerial(ctx context.Context, accountId string) error { - result := s.db.Model(&types.Account{}).Where(idQueryCondition, accountId).Update("network_serial", gorm.Expr("network_serial + 1")) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to increment network serial count in store: %v", result.Error) - return status.Errorf(status.Internal, "failed to increment network serial count in store") - } - return nil -} - func (s *SqlStore) ExecuteInTransaction(ctx context.Context, operation func(store Store) error) error { timeoutCtx, cancel := context.WithTimeout(ctx, s.transactionTimeout) defer cancel() @@ -3832,3002 +557,3 @@ func (s *SqlStore) GetDB() *gorm.DB { func (s *SqlStore) SetFieldEncrypt(enc *crypt.FieldEncrypt) { s.fieldEncrypt = enc } - -func (s *SqlStore) GetAccountDNSSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*types.DNSSettings, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var accountDNSSettings types.AccountDNSSettings - result := tx.Model(&types.Account{}). - Take(&accountDNSSettings, idQueryCondition, accountID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.NewAccountNotFoundError(accountID) - } - log.WithContext(ctx).Errorf("failed to get dns settings from store: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to get dns settings from store") - } - return &accountDNSSettings.DNSSettings, nil -} - -// AccountExists checks whether an account exists by the given ID. -func (s *SqlStore) AccountExists(ctx context.Context, lockStrength LockingStrength, id string) (bool, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var accountID string - result := tx.Model(&types.Account{}). - Select("id").Take(&accountID, idQueryCondition, id) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return false, nil - } - return false, result.Error - } - - return accountID != "", nil -} - -// GetAccountDomainAndCategory retrieves the Domain and DomainCategory fields for an account based on the given accountID. -func (s *SqlStore) GetAccountDomainAndCategory(ctx context.Context, lockStrength LockingStrength, accountID string) (string, string, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var account types.Account - result := tx.Model(&types.Account{}).Select("domain", "domain_category"). - Where(idQueryCondition, accountID).Take(&account) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return "", "", status.Errorf(status.NotFound, "account not found") - } - return "", "", status.Errorf(status.Internal, "failed to get domain category from store: %v", result.Error) - } - - return account.Domain, account.DomainCategory, nil -} - -// GetGroupByID retrieves a group by ID and account ID. -func (s *SqlStore) GetGroupByID(ctx context.Context, lockStrength LockingStrength, accountID, groupID string) (*types.Group, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var group *types.Group - result := tx.Preload(clause.Associations).Take(&group, accountAndIDQueryCondition, accountID, groupID) - if err := result.Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, status.NewGroupNotFoundError(groupID) - } - log.WithContext(ctx).Errorf("failed to get group from store: %s", err) - return nil, status.Errorf(status.Internal, "failed to get group from store") - } - - group.LoadGroupPeers() - - return group, nil -} - -// GetGroupByName retrieves a group by name and account ID. -func (s *SqlStore) GetGroupByName(ctx context.Context, lockStrength LockingStrength, accountID, groupName string) (*types.Group, error) { - tx := s.db - - var group types.Group - - // TODO: This fix is accepted for now, but if we need to handle this more frequently - // we may need to reconsider changing the types. - query := tx.Preload(clause.Associations) - - result := query. - Model(&types.Group{}). - Joins("LEFT JOIN group_peers ON group_peers.group_id = groups.id"). - Where("groups.account_id = ? AND groups.name = ?", accountID, groupName). - Group("groups.id"). - Order("COUNT(group_peers.peer_id) DESC"). - Limit(1). - First(&group) - if err := result.Error; err != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.NewGroupNotFoundError(groupName) - } - log.WithContext(ctx).Errorf("failed to get group by name from store: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to get group by name from store") - } - - group.LoadGroupPeers() - - return &group, nil -} - -// GetGroupsByIDs retrieves groups by their IDs and account ID. -func (s *SqlStore) GetGroupsByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, groupIDs []string) (map[string]*types.Group, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var groups []*types.Group - result := tx.Preload(clause.Associations).Find(&groups, accountAndIDsQueryCondition, accountID, groupIDs) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get groups by ID's from store: %s", result.Error) - return nil, status.Errorf(status.Internal, "failed to get groups by ID's from store") - } - - groupsMap := make(map[string]*types.Group) - for _, group := range groups { - group.LoadGroupPeers() - groupsMap[group.ID] = group - } - - return groupsMap, nil -} - -// CreateGroup creates a group in the store. -func (s *SqlStore) CreateGroup(ctx context.Context, group *types.Group) error { - if group == nil { - return status.Errorf(status.InvalidArgument, "group is nil") - } - - if err := s.db.Omit(clause.Associations).Create(group).Error; err != nil { - log.WithContext(ctx).Errorf("failed to save group to store: %v", err) - return status.Errorf(status.Internal, "failed to save group to store") - } - - return nil -} - -// UpdateGroup updates a group in the store. -func (s *SqlStore) UpdateGroup(ctx context.Context, group *types.Group) error { - if group == nil { - return status.Errorf(status.InvalidArgument, "group is nil") - } - - if err := s.db.Omit(clause.Associations, "public_id").Save(group).Error; err != nil { - log.WithContext(ctx).Errorf("failed to save group to store: %v", err) - return status.Errorf(status.Internal, "failed to save group to store") - } - - return nil -} - -// DeleteGroup deletes a group from the database. -func (s *SqlStore) DeleteGroup(ctx context.Context, accountID, groupID string) error { - result := s.db.Select(clause.Associations). - Delete(&types.Group{}, accountAndIDQueryCondition, accountID, groupID) - if err := result.Error; err != nil { - log.WithContext(ctx).Errorf("failed to delete group from store: %s", result.Error) - return status.Errorf(status.Internal, "failed to delete group from store") - } - - if result.RowsAffected == 0 { - return status.NewGroupNotFoundError(groupID) - } - - return nil -} - -// DeleteGroups deletes groups from the database. -func (s *SqlStore) DeleteGroups(ctx context.Context, accountID string, groupIDs []string) error { - result := s.db.Select(clause.Associations). - Delete(&types.Group{}, accountAndIDsQueryCondition, accountID, groupIDs) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to delete groups from store: %v", result.Error) - return status.Errorf(status.Internal, "failed to delete groups from store") - } - - return nil -} - -// GetAccountPolicies retrieves policies for an account. -func (s *SqlStore) GetAccountPolicies(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.Policy, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var policies []*types.Policy - result := tx. - Preload(clause.Associations).Find(&policies, accountIDCondition, accountID) - if err := result.Error; err != nil { - log.WithContext(ctx).Errorf("failed to get policies from the store: %s", result.Error) - return nil, status.Errorf(status.Internal, "failed to get policies from store") - } - - return policies, nil -} - -// GetPolicyByID retrieves a policy by its ID and account ID. -func (s *SqlStore) GetPolicyByID(ctx context.Context, lockStrength LockingStrength, accountID, policyID string) (*types.Policy, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var policy *types.Policy - - result := tx.Preload(clause.Associations). - Take(&policy, accountAndIDQueryCondition, accountID, policyID) - if err := result.Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, status.NewPolicyNotFoundError(policyID) - } - log.WithContext(ctx).Errorf("failed to get policy from store: %s", err) - return nil, status.Errorf(status.Internal, "failed to get policy from store") - } - - return policy, nil -} - -// GetPolicyByIDOrPublicID retrieves a policy by either its ID or its PublicID. Peers report -// whichever of the two the network map they were served carries, so callers resolving a -// peer-reported reference cannot know upfront which namespace it belongs to. -func (s *SqlStore) GetPolicyByIDOrPublicID(ctx context.Context, lockStrength LockingStrength, accountID, policyID string) (*types.Policy, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var policy *types.Policy - - result := tx.Preload(clause.Associations). - Take(&policy, accountAndAnyIDQueryCondition, accountID, policyID, policyID) - if err := result.Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, status.NewPolicyNotFoundError(policyID) - } - log.WithContext(ctx).Errorf("failed to get policy from store: %s", err) - return nil, status.Errorf(status.Internal, "failed to get policy from store") - } - - return policy, nil -} - -func (s *SqlStore) CreatePolicy(ctx context.Context, policy *types.Policy) error { - result := s.db.Create(policy) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to create policy in store: %s", result.Error) - return status.Errorf(status.Internal, "failed to create policy in store") - } - - return nil -} - -// SavePolicy saves a policy to the database. -func (s *SqlStore) SavePolicy(ctx context.Context, policy *types.Policy) error { - result := s.db.Session(&gorm.Session{FullSaveAssociations: true}).Omit("public_id").Save(policy) - if err := result.Error; err != nil { - log.WithContext(ctx).Errorf("failed to save policy to the store: %s", err) - return status.Errorf(status.Internal, "failed to save policy to store") - } - return nil -} - -func (s *SqlStore) DeletePolicy(ctx context.Context, accountID, policyID string) error { - return s.transaction(func(tx *gorm.DB) error { - if err := tx.Where("policy_id = ?", policyID).Delete(&types.PolicyRule{}).Error; err != nil { - return fmt.Errorf("delete policy rules: %w", err) - } - - result := tx. - Where(accountAndIDQueryCondition, accountID, policyID). - Delete(&types.Policy{}) - - if err := result.Error; err != nil { - log.WithContext(ctx).Errorf("failed to delete policy from store: %s", err) - return status.Errorf(status.Internal, "failed to delete policy from store") - } - - if result.RowsAffected == 0 { - return status.NewPolicyNotFoundError(policyID) - } - - return nil - }) -} - -func (s *SqlStore) GetPolicyRulesByResourceID(ctx context.Context, lockStrength LockingStrength, accountID string, resourceID string) ([]*types.PolicyRule, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var policyRules []*types.PolicyRule - resourceIDPattern := `%"ID":"` + resourceID + `"%` - result := tx.Where("source_resource LIKE ? OR destination_resource LIKE ?", resourceIDPattern, resourceIDPattern). - Find(&policyRules) - - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get policy rules for resource id from store: %s", result.Error) - return nil, status.Errorf(status.Internal, "failed to get policy rules for resource id from store") - } - - return policyRules, nil -} - -// GetAccountPostureChecks retrieves posture checks for an account. -func (s *SqlStore) GetAccountPostureChecks(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*posture.Checks, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var postureChecks []*posture.Checks - result := tx.Find(&postureChecks, accountIDCondition, accountID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get posture checks from store: %s", result.Error) - return nil, status.Errorf(status.Internal, "failed to get posture checks from store") - } - - return postureChecks, nil -} - -// GetPostureChecksByID retrieves posture checks by their ID and account ID. -func (s *SqlStore) GetPostureChecksByID(ctx context.Context, lockStrength LockingStrength, accountID, postureChecksID string) (*posture.Checks, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var postureCheck *posture.Checks - result := tx. - Take(&postureCheck, accountAndIDQueryCondition, accountID, postureChecksID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.NewPostureChecksNotFoundError(postureChecksID) - } - log.WithContext(ctx).Errorf("failed to get posture check from store: %s", result.Error) - return nil, status.Errorf(status.Internal, "failed to get posture check from store") - } - - return postureCheck, nil -} - -// GetPostureChecksByIDs retrieves posture checks by their IDs and account ID. -func (s *SqlStore) GetPostureChecksByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, postureChecksIDs []string) (map[string]*posture.Checks, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var postureChecks []*posture.Checks - result := tx.Find(&postureChecks, accountAndIDsQueryCondition, accountID, postureChecksIDs) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get posture checks by ID's from store: %s", result.Error) - return nil, status.Errorf(status.Internal, "failed to get posture checks by ID's from store") - } - - postureChecksMap := make(map[string]*posture.Checks) - for _, postureCheck := range postureChecks { - postureChecksMap[postureCheck.ID] = postureCheck - } - - return postureChecksMap, nil -} - -// SavePostureChecks saves a posture checks to the database. -func (s *SqlStore) SavePostureChecks(ctx context.Context, postureCheck *posture.Checks) error { - result := s.db.Save(postureCheck) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to save posture checks to store: %s", result.Error) - return status.Errorf(status.Internal, "failed to save posture checks to store") - } - - return nil -} - -// DeletePostureChecks deletes a posture checks from the database. -func (s *SqlStore) DeletePostureChecks(ctx context.Context, accountID, postureChecksID string) error { - result := s.db.Delete(&posture.Checks{}, accountAndIDQueryCondition, accountID, postureChecksID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to delete posture checks from store: %s", result.Error) - return status.Errorf(status.Internal, "failed to delete posture checks from store") - } - - if result.RowsAffected == 0 { - return status.NewPostureChecksNotFoundError(postureChecksID) - } - - return nil -} - -// GetAccountRoutes retrieves network routes for an account. -func (s *SqlStore) GetAccountRoutes(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*route.Route, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var routes []*route.Route - result := tx.Find(&routes, accountIDCondition, accountID) - if err := result.Error; err != nil { - log.WithContext(ctx).Errorf("failed to get routes from the store: %s", err) - return nil, status.Errorf(status.Internal, "failed to get routes from store") - } - - return routes, nil -} - -// GetRouteByID retrieves a route by its ID and account ID. -func (s *SqlStore) GetRouteByID(ctx context.Context, lockStrength LockingStrength, accountID string, routeID string) (*route.Route, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var route *route.Route - result := tx.Take(&route, accountAndIDQueryCondition, accountID, routeID) - if err := result.Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, status.NewRouteNotFoundError(routeID) - } - log.WithContext(ctx).Errorf("failed to get route from the store: %s", err) - return nil, status.Errorf(status.Internal, "failed to get route from store") - } - - return route, nil -} - -// GetRouteByIDOrPublicID retrieves a route by either its ID or its PublicID. See -// GetPolicyByIDOrPublicID for why peer-reported references need both. -func (s *SqlStore) GetRouteByIDOrPublicID(ctx context.Context, lockStrength LockingStrength, accountID string, routeID string) (*route.Route, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var route *route.Route - result := tx.Take(&route, accountAndAnyIDQueryCondition, accountID, routeID, routeID) - if err := result.Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, status.NewRouteNotFoundError(routeID) - } - log.WithContext(ctx).Errorf("failed to get route from the store: %s", err) - return nil, status.Errorf(status.Internal, "failed to get route from store") - } - - return route, nil -} - -// SaveRoute saves a route to the database. -func (s *SqlStore) SaveRoute(ctx context.Context, route *route.Route) error { - result := s.db.Save(route) - if err := result.Error; err != nil { - log.WithContext(ctx).Errorf("failed to save route to the store: %s", err) - return status.Errorf(status.Internal, "failed to save route to store") - } - - return nil -} - -// DeleteRoute deletes a route from the database. -func (s *SqlStore) DeleteRoute(ctx context.Context, accountID, routeID string) error { - result := s.db.Delete(&route.Route{}, accountAndIDQueryCondition, accountID, routeID) - if err := result.Error; err != nil { - log.WithContext(ctx).Errorf("failed to delete route from the store: %s", err) - return status.Errorf(status.Internal, "failed to delete route from store") - } - - if result.RowsAffected == 0 { - return status.NewRouteNotFoundError(routeID) - } - - return nil -} - -// GetAccountSetupKeys retrieves setup keys for an account. -func (s *SqlStore) GetAccountSetupKeys(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.SetupKey, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var setupKeys []*types.SetupKey - result := tx. - Find(&setupKeys, accountIDCondition, accountID) - if err := result.Error; err != nil { - log.WithContext(ctx).Errorf("failed to get setup keys from the store: %s", err) - return nil, status.Errorf(status.Internal, "failed to get setup keys from store") - } - - return setupKeys, nil -} - -// GetSetupKeyByID retrieves a setup key by its ID and account ID. -func (s *SqlStore) GetSetupKeyByID(ctx context.Context, lockStrength LockingStrength, accountID, setupKeyID string) (*types.SetupKey, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var setupKey *types.SetupKey - result := tx.Take(&setupKey, accountAndIDQueryCondition, accountID, setupKeyID) - if err := result.Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, status.NewSetupKeyNotFoundError(setupKeyID) - } - log.WithContext(ctx).Errorf("failed to get setup key from the store: %s", err) - return nil, status.Errorf(status.Internal, "failed to get setup key from store") - } - - return setupKey, nil -} - -// SaveSetupKey saves a setup key to the database. -func (s *SqlStore) SaveSetupKey(ctx context.Context, setupKey *types.SetupKey) error { - result := s.db.Save(setupKey) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to save setup key to store: %s", result.Error) - return status.Errorf(status.Internal, "failed to save setup key to store") - } - - return nil -} - -// DeleteSetupKey deletes a setup key from the database. -func (s *SqlStore) DeleteSetupKey(ctx context.Context, accountID, keyID string) error { - result := s.db.Delete(&types.SetupKey{}, accountAndIDQueryCondition, accountID, keyID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to delete setup key from store: %s", result.Error) - return status.Errorf(status.Internal, "failed to delete setup key from store") - } - - if result.RowsAffected == 0 { - return status.NewSetupKeyNotFoundError(keyID) - } - - return nil -} - -// GetAccountNameServerGroups retrieves name server groups for an account. -func (s *SqlStore) GetAccountNameServerGroups(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*nbdns.NameServerGroup, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var nsGroups []*nbdns.NameServerGroup - result := tx.Find(&nsGroups, accountIDCondition, accountID) - if err := result.Error; err != nil { - log.WithContext(ctx).Errorf("failed to get name server groups from the store: %s", err) - return nil, status.Errorf(status.Internal, "failed to get name server groups from store") - } - - return nsGroups, nil -} - -// GetNameServerGroupByID retrieves a name server group by its ID and account ID. -func (s *SqlStore) GetNameServerGroupByID(ctx context.Context, lockStrength LockingStrength, accountID, nsGroupID string) (*nbdns.NameServerGroup, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var nsGroup *nbdns.NameServerGroup - result := tx. - Take(&nsGroup, accountAndIDQueryCondition, accountID, nsGroupID) - if err := result.Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, status.NewNameServerGroupNotFoundError(nsGroupID) - } - log.WithContext(ctx).Errorf("failed to get name server group from the store: %s", err) - return nil, status.Errorf(status.Internal, "failed to get name server group from store") - } - - return nsGroup, nil -} - -// SaveNameServerGroup saves a name server group to the database. -func (s *SqlStore) SaveNameServerGroup(ctx context.Context, nameServerGroup *nbdns.NameServerGroup) error { - result := s.db.Save(nameServerGroup) - if err := result.Error; err != nil { - log.WithContext(ctx).Errorf("failed to save name server group to the store: %s", err) - return status.Errorf(status.Internal, "failed to save name server group to store") - } - return nil -} - -// DeleteNameServerGroup deletes a name server group from the database. -func (s *SqlStore) DeleteNameServerGroup(ctx context.Context, accountID, nsGroupID string) error { - result := s.db.Delete(&nbdns.NameServerGroup{}, accountAndIDQueryCondition, accountID, nsGroupID) - if err := result.Error; err != nil { - log.WithContext(ctx).Errorf("failed to delete name server group from the store: %s", err) - return status.Errorf(status.Internal, "failed to delete name server group from store") - } - - if result.RowsAffected == 0 { - return status.NewNameServerGroupNotFoundError(nsGroupID) - } - - return nil -} - -// SaveDNSSettings saves the DNS settings to the store. -func (s *SqlStore) SaveDNSSettings(ctx context.Context, accountID string, settings *types.DNSSettings) error { - result := s.db.Model(&types.Account{}). - Where(idQueryCondition, accountID).Updates(&types.AccountDNSSettings{DNSSettings: *settings}) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to save dns settings to store: %v", result.Error) - return status.Errorf(status.Internal, "failed to save dns settings to store") - } - - if result.RowsAffected == 0 { - return status.NewAccountNotFoundError(accountID) - } - - return nil -} - -// SaveAccountSettings stores the account settings in DB. -func (s *SqlStore) SaveAccountSettings(ctx context.Context, accountID string, settings *types.Settings) error { - result := s.db.Model(&types.Account{}). - Select("*").Where(idQueryCondition, accountID).Updates(&types.AccountSettings{Settings: settings}) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to save account settings to store: %v", result.Error) - return status.Errorf(status.Internal, "failed to save account settings to store") - } - - // MySQL reports RowsAffected=0 for no-op updates where values don't change, - // unlike SQLite/Postgres which report matched rows. Skip the check since the - // caller (UpdateAccountSettings) already verified the account exists via - // GetAccountSettings with LockingStrengthUpdate. - - return nil -} - -func (s *SqlStore) GetAccountNetworks(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*networkTypes.Network, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var networks []*networkTypes.Network - result := tx.Find(&networks, accountIDCondition, accountID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get networks from the store: %s", result.Error) - return nil, status.Errorf(status.Internal, "failed to get networks from store") - } - - return networks, nil -} - -func (s *SqlStore) GetNetworkByID(ctx context.Context, lockStrength LockingStrength, accountID, networkID string) (*networkTypes.Network, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var network *networkTypes.Network - result := tx.Take(&network, accountAndIDQueryCondition, accountID, networkID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.NewNetworkNotFoundError(networkID) - } - - log.WithContext(ctx).Errorf("failed to get network from store: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to get network from store") - } - - return network, nil -} - -func (s *SqlStore) SaveNetwork(ctx context.Context, network *networkTypes.Network) error { - result := s.db.Save(network) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to save network to store: %v", result.Error) - return status.Errorf(status.Internal, "failed to save network to store") - } - - return nil -} - -func (s *SqlStore) DeleteNetwork(ctx context.Context, accountID, networkID string) error { - result := s.db.Delete(&networkTypes.Network{}, accountAndIDQueryCondition, accountID, networkID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to delete network from store: %v", result.Error) - return status.Errorf(status.Internal, "failed to delete network from store") - } - - if result.RowsAffected == 0 { - return status.NewNetworkNotFoundError(networkID) - } - - return nil -} - -func (s *SqlStore) GetNetworkRoutersByNetID(ctx context.Context, lockStrength LockingStrength, accountID, netID string) ([]*routerTypes.NetworkRouter, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var netRouters []*routerTypes.NetworkRouter - result := tx. - Find(&netRouters, "account_id = ? AND network_id = ?", accountID, netID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get network routers from store: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to get network routers from store") - } - - return netRouters, nil -} - -func (s *SqlStore) GetNetworkRoutersByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*routerTypes.NetworkRouter, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var netRouters []*routerTypes.NetworkRouter - result := tx. - Find(&netRouters, accountIDCondition, accountID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get network routers from store: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to get network routers from store") - } - - return netRouters, nil -} - -func (s *SqlStore) GetNetworkRouterByID(ctx context.Context, lockStrength LockingStrength, accountID, routerID string) (*routerTypes.NetworkRouter, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var netRouter *routerTypes.NetworkRouter - result := tx. - Take(&netRouter, accountAndIDQueryCondition, accountID, routerID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.NewNetworkRouterNotFoundError(routerID) - } - log.WithContext(ctx).Errorf("failed to get network router from store: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to get network router from store") - } - - return netRouter, nil -} - -func (s *SqlStore) CreateNetworkRouter(ctx context.Context, router *routerTypes.NetworkRouter) error { - if err := s.db.Create(router).Error; err != nil { - log.WithContext(ctx).Errorf("failed to create network router in store: %v", err) - return status.Errorf(status.Internal, "failed to create network router in store") - } - - return nil -} - -func (s *SqlStore) UpdateNetworkRouter(ctx context.Context, router *routerTypes.NetworkRouter) error { - result := s.db. - Select("*"). - Where(accountAndIDQueryCondition, router.AccountID, router.ID). - Updates(router) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to update network router in store: %v", result.Error) - return status.Errorf(status.Internal, "failed to update network router in store") - } - - if result.RowsAffected == 0 { - return status.NewNetworkRouterNotFoundError(router.ID) - } - - return nil -} - -func (s *SqlStore) DeleteNetworkRouter(ctx context.Context, accountID, routerID string) error { - result := s.db.Delete(&routerTypes.NetworkRouter{}, accountAndIDQueryCondition, accountID, routerID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to delete network router from store: %v", result.Error) - return status.Errorf(status.Internal, "failed to delete network router from store") - } - - if result.RowsAffected == 0 { - return status.NewNetworkRouterNotFoundError(routerID) - } - - return nil -} - -func (s *SqlStore) GetNetworkResourcesByNetID(ctx context.Context, lockStrength LockingStrength, accountID, networkID string) ([]*resourceTypes.NetworkResource, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var netResources []*resourceTypes.NetworkResource - result := tx. - Find(&netResources, "account_id = ? AND network_id = ?", accountID, networkID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get network resources from store: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to get network resources from store") - } - - return netResources, nil -} - -func (s *SqlStore) GetNetworkResourcesByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*resourceTypes.NetworkResource, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var netResources []*resourceTypes.NetworkResource - result := tx. - Find(&netResources, accountIDCondition, accountID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get network resources from store: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to get network resources from store") - } - - return netResources, nil -} - -func (s *SqlStore) GetNetworkResourceByID(ctx context.Context, lockStrength LockingStrength, accountID, resourceID string) (*resourceTypes.NetworkResource, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var netResources *resourceTypes.NetworkResource - result := tx. - Take(&netResources, accountAndIDQueryCondition, accountID, resourceID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.NewNetworkResourceNotFoundError(resourceID) - } - log.WithContext(ctx).Errorf("failed to get network resource from store: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to get network resource from store") - } - - return netResources, nil -} - -// GetNetworkResourceByIDOrPublicID retrieves a network resource by either its ID or its -// PublicID. See GetPolicyByIDOrPublicID for why peer-reported references need both. -func (s *SqlStore) GetNetworkResourceByIDOrPublicID(ctx context.Context, lockStrength LockingStrength, accountID, resourceID string) (*resourceTypes.NetworkResource, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var netResources *resourceTypes.NetworkResource - result := tx. - Take(&netResources, accountAndAnyIDQueryCondition, accountID, resourceID, resourceID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.NewNetworkResourceNotFoundError(resourceID) - } - log.WithContext(ctx).Errorf("failed to get network resource from store: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to get network resource from store") - } - - return netResources, nil -} - -func (s *SqlStore) GetNetworkResourceByName(ctx context.Context, lockStrength LockingStrength, accountID, resourceName string) (*resourceTypes.NetworkResource, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var netResources *resourceTypes.NetworkResource - result := tx. - Take(&netResources, "account_id = ? AND name = ?", accountID, resourceName) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.NewNetworkResourceNotFoundError(resourceName) - } - log.WithContext(ctx).Errorf("failed to get network resource from store: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to get network resource from store") - } - - return netResources, nil -} - -func (s *SqlStore) SaveNetworkResource(ctx context.Context, resource *resourceTypes.NetworkResource) error { - result := s.db.Save(resource) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to save network resource to store: %v", result.Error) - return status.Errorf(status.Internal, "failed to save network resource to store") - } - - return nil -} - -func (s *SqlStore) DeleteNetworkResource(ctx context.Context, accountID, resourceID string) error { - result := s.db.Delete(&resourceTypes.NetworkResource{}, accountAndIDQueryCondition, accountID, resourceID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to delete network resource from store: %v", result.Error) - return status.Errorf(status.Internal, "failed to delete network resource from store") - } - - if result.RowsAffected == 0 { - return status.NewNetworkResourceNotFoundError(resourceID) - } - - return nil -} - -// GetPATByHashedToken returns a PersonalAccessToken by its hashed token. -func (s *SqlStore) GetPATByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken string) (*types.PersonalAccessToken, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var pat types.PersonalAccessToken - result := tx.Take(&pat, "hashed_token = ?", hashedToken) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.NewPATNotFoundError(hashedToken) - } - log.WithContext(ctx).Errorf("failed to get pat by hash from the store: %s", result.Error) - return nil, status.Errorf(status.Internal, "failed to get pat by hash from store") - } - - return &pat, nil -} - -// GetPATByID retrieves a personal access token by its ID and user ID. -func (s *SqlStore) GetPATByID(ctx context.Context, lockStrength LockingStrength, userID string, patID string) (*types.PersonalAccessToken, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var pat types.PersonalAccessToken - result := tx. - Take(&pat, "id = ? AND user_id = ?", patID, userID) - if err := result.Error; err != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.NewPATNotFoundError(patID) - } - log.WithContext(ctx).Errorf("failed to get pat from the store: %s", err) - return nil, status.Errorf(status.Internal, "failed to get pat from store") - } - - return &pat, nil -} - -// GetUserPATs retrieves personal access tokens for a user. -func (s *SqlStore) GetUserPATs(ctx context.Context, lockStrength LockingStrength, userID string) ([]*types.PersonalAccessToken, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var pats []*types.PersonalAccessToken - result := tx.Find(&pats, "user_id = ?", userID) - if err := result.Error; err != nil { - log.WithContext(ctx).Errorf("failed to get user pat's from the store: %s", err) - return nil, status.Errorf(status.Internal, "failed to get user pat's from store") - } - - return pats, nil -} - -// MarkPATUsed marks a personal access token as used. -func (s *SqlStore) MarkPATUsed(ctx context.Context, patID string) error { - patCopy := types.PersonalAccessToken{ - LastUsed: util.ToPtr(time.Now().UTC()), - } - - fieldsToUpdate := []string{"last_used"} - result := s.db.Select(fieldsToUpdate). - Where(idQueryCondition, patID).Updates(&patCopy) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to mark pat as used: %s", result.Error) - return status.Errorf(status.Internal, "failed to mark pat as used") - } - - if result.RowsAffected == 0 { - return status.NewPATNotFoundError(patID) - } - - return nil -} - -// SavePAT saves a personal access token to the database. -func (s *SqlStore) SavePAT(ctx context.Context, pat *types.PersonalAccessToken) error { - result := s.db.Save(pat) - if err := result.Error; err != nil { - log.WithContext(ctx).Errorf("failed to save pat to the store: %s", err) - return status.Errorf(status.Internal, "failed to save pat to store") - } - - return nil -} - -// DeletePAT deletes a personal access token from the database. -func (s *SqlStore) DeletePAT(ctx context.Context, userID, patID string) error { - result := s.db.Delete(&types.PersonalAccessToken{}, "user_id = ? AND id = ?", userID, patID) - if err := result.Error; err != nil { - log.WithContext(ctx).Errorf("failed to delete pat from the store: %s", err) - return status.Errorf(status.Internal, "failed to delete pat from store") - } - - if result.RowsAffected == 0 { - return status.NewPATNotFoundError(patID) - } - - return nil -} - -// GetProxyAccessTokenByHashedToken retrieves a proxy access token by its hashed value. -func (s *SqlStore) GetProxyAccessTokenByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken types.HashedProxyToken) (*types.ProxyAccessToken, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var token types.ProxyAccessToken - result := tx.Take(&token, "hashed_token = ?", hashedToken) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.Errorf(status.NotFound, "proxy access token not found") - } - return nil, status.Errorf(status.Internal, "get proxy access token: %v", result.Error) - } - - return &token, nil -} - -// GetAllProxyAccessTokens retrieves all proxy access tokens. -func (s *SqlStore) GetAllProxyAccessTokens(ctx context.Context, lockStrength LockingStrength) ([]*types.ProxyAccessToken, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var tokens []*types.ProxyAccessToken - result := tx.Find(&tokens) - if result.Error != nil { - return nil, status.Errorf(status.Internal, "get proxy access tokens: %v", result.Error) - } - - return tokens, nil -} - -// SaveProxyAccessToken saves a proxy access token to the database. -func (s *SqlStore) SaveProxyAccessToken(ctx context.Context, token *types.ProxyAccessToken) error { - if result := s.db.Create(token); result.Error != nil { - return status.Errorf(status.Internal, "save proxy access token: %v", result.Error) - } - return nil -} - -// RevokeProxyAccessToken revokes a proxy access token by its ID. -func (s *SqlStore) RevokeProxyAccessToken(ctx context.Context, tokenID string) error { - result := s.db.Model(&types.ProxyAccessToken{}).Where(idQueryCondition, tokenID).Update("revoked", true) - if result.Error != nil { - return status.Errorf(status.Internal, "revoke proxy access token: %v", result.Error) - } - - if result.RowsAffected == 0 { - return status.Errorf(status.NotFound, "proxy access token not found") - } - - return nil -} - -func (s *SqlStore) GetProxyAccessTokensByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.ProxyAccessToken, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var tokens []*types.ProxyAccessToken - result := tx.Where("account_id = ?", accountID).Find(&tokens) - if result.Error != nil { - return nil, status.Errorf(status.Internal, "get proxy access tokens by account: %v", result.Error) - } - - return tokens, nil -} - -func (s *SqlStore) IsProxyAccessTokenValid(ctx context.Context, tokenID string) (bool, error) { - token, err := s.GetProxyAccessTokenByID(ctx, LockingStrengthNone, tokenID) - if err != nil { - return false, err - } - return token.IsValid(), nil -} - -func (s *SqlStore) GetProxyAccessTokenByID(ctx context.Context, lockStrength LockingStrength, tokenID string) (*types.ProxyAccessToken, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var token types.ProxyAccessToken - result := tx.Take(&token, idQueryCondition, tokenID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.Errorf(status.NotFound, "proxy access token not found") - } - return nil, status.Errorf(status.Internal, "get proxy access token by ID: %v", result.Error) - } - - return &token, nil -} - -// MarkProxyAccessTokenUsed updates the last used timestamp for a proxy access token. -func (s *SqlStore) MarkProxyAccessTokenUsed(ctx context.Context, tokenID string) error { - result := s.db.Model(&types.ProxyAccessToken{}). - Where(idQueryCondition, tokenID). - Update("last_used", time.Now().UTC()) - if result.Error != nil { - return status.Errorf(status.Internal, "mark proxy access token as used: %v", result.Error) - } - - if result.RowsAffected == 0 { - return status.Errorf(status.NotFound, "proxy access token not found") - } - - return nil -} - -func (s *SqlStore) GetPeerByIP(ctx context.Context, lockStrength LockingStrength, accountID string, ip net.IP) (*nbpeer.Peer, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - column := "ip" - if ip.To4() == nil { - column = "ipv6" - } - jsonValue := fmt.Sprintf(`"%s"`, ip.String()) - - var peer nbpeer.Peer - result := tx. - Take(&peer, fmt.Sprintf("account_id = ? AND %s = ?", column), accountID, jsonValue) - if result.Error != nil { - // A tunnel-IP miss is an expected outcome (e.g. the proxy's - // ValidateTunnelPeer probing an address that isn't in the - // account roster); surface it as NotFound so callers can tell - // it apart from a real store failure. - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.Errorf(status.NotFound, "peer with ip %s not found", ip.String()) - } - return nil, status.Errorf(status.Internal, "failed to get peer from store") - } - - return &peer, nil -} - -func (s *SqlStore) GetPeerIdByLabel(ctx context.Context, lockStrength LockingStrength, accountID string, hostname string) (string, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var peerID string - result := tx.Model(&nbpeer.Peer{}). - Select("id"). - // Where(" = ?", hostname). - Where("account_id = ? AND dns_label = ?", accountID, hostname). - Limit(1). - Scan(&peerID) - - if peerID == "" { - return "", gorm.ErrRecordNotFound - } - - return peerID, result.Error -} - -func (s *SqlStore) CountAccountsByPrivateDomain(ctx context.Context, domain string) (int64, error) { - var count int64 - result := s.db.Model(&types.Account{}). - Where("domain = ? AND domain_category = ?", - strings.ToLower(domain), types.PrivateCategory, - ).Count(&count) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to count accounts by private domain %s: %s", domain, result.Error) - return 0, status.Errorf(status.Internal, "failed to count accounts by private domain") - } - - return count, nil -} - -func (s *SqlStore) GetAccountGroupPeers(ctx context.Context, lockStrength LockingStrength, accountID string) (map[string]map[string]struct{}, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var peers []types.GroupPeer - result := tx.Find(&peers, accountIDCondition, accountID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get account group peers from store: %s", result.Error) - return nil, status.Errorf(status.Internal, "failed to get account group peers from store") - } - - groupPeers := make(map[string]map[string]struct{}) - for _, peer := range peers { - if _, exists := groupPeers[peer.GroupID]; !exists { - groupPeers[peer.GroupID] = make(map[string]struct{}) - } - groupPeers[peer.GroupID][peer.PeerID] = struct{}{} - } - - return groupPeers, nil -} - -func (s *SqlStore) IsPrimaryAccount(ctx context.Context, accountID string) (bool, string, error) { - var info types.PrimaryAccountInfo - result := s.db.Model(&types.Account{}). - Select("is_domain_primary_account, domain"). - Where(idQueryCondition, accountID). - Take(&info) - - if result.Error != nil { - return false, "", status.Errorf(status.Internal, "failed to get account info: %v", result.Error) - } - - return info.IsDomainPrimaryAccount, info.Domain, nil -} - -func (s *SqlStore) MarkAccountPrimary(ctx context.Context, accountID string) error { - result := s.db.Model(&types.Account{}). - Where(idQueryCondition, accountID). - Update("is_domain_primary_account", true) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to mark account as primary: %s", result.Error) - return status.Errorf(status.Internal, "failed to mark account as primary") - } - - if result.RowsAffected == 0 { - return status.NewAccountNotFoundError(accountID) - } - - return nil -} - -type accountNetworkPatch struct { - Network *types.Network `gorm:"embedded;embeddedPrefix:network_"` -} - -func (s *SqlStore) UpdateAccountNetwork(ctx context.Context, accountID string, ipNet net.IPNet) error { - patch := accountNetworkPatch{ - Network: &types.Network{Net: ipNet}, - } - - result := s.db. - Model(&types.Account{}). - Where(idQueryCondition, accountID). - Updates(&patch) - - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to update account network: %v", result.Error) - return status.Errorf(status.Internal, "failed to update account network") - } - if result.RowsAffected == 0 { - return status.NewAccountNotFoundError(accountID) - } - return nil -} - -// UpdateAccountNetworkV6 updates the IPv6 network range for the account. -func (s *SqlStore) UpdateAccountNetworkV6(ctx context.Context, accountID string, ipNet net.IPNet) error { - patch := accountNetworkPatch{ - Network: &types.Network{NetV6: ipNet}, - } - - result := s.db. - Model(&types.Account{}). - Where(idQueryCondition, accountID). - Updates(&patch) - - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to update account network v6: %v", result.Error) - return status.Errorf(status.Internal, "update account network v6") - } - if result.RowsAffected == 0 { - return status.NewAccountNotFoundError(accountID) - } - return nil -} - -func (s *SqlStore) GetPeersByGroupIDs(ctx context.Context, accountID string, groupIDs []string) ([]*nbpeer.Peer, error) { - if len(groupIDs) == 0 { - return []*nbpeer.Peer{}, nil - } - - var peers []*nbpeer.Peer - peerIDsSubquery := s.db.Model(&types.GroupPeer{}). - Select("DISTINCT peer_id"). - Where("account_id = ? AND group_id IN ?", accountID, groupIDs) - - result := s.db.Where("account_id = ? AND id IN (?)", accountID, peerIDsSubquery).Find(&peers) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get peers by group IDs: %s", result.Error) - return nil, status.Errorf(status.Internal, "failed to get peers by group IDs") - } - - return peers, nil -} - -func (s *SqlStore) GetPeerIDsByGroups(ctx context.Context, accountID string, groupIDs []string) ([]string, error) { - if len(groupIDs) == 0 { - return nil, nil - } - - var peerIDs []string - result := s.db.Model(&types.GroupPeer{}). - Select("DISTINCT peer_id"). - Where("account_id = ? AND group_id IN ?", accountID, groupIDs). - Pluck("peer_id", &peerIDs) - if result.Error != nil { - return nil, status.Errorf(status.Internal, "failed to get peer IDs by groups: %s", result.Error) - } - - return peerIDs, nil -} - -func (s *SqlStore) GetGroupIDsByPeerIDs(ctx context.Context, accountID string, peerIDs []string) ([]string, error) { - if len(peerIDs) == 0 { - return nil, nil - } - - var groupIDs []string - result := s.db.Model(&types.GroupPeer{}). - Select("DISTINCT group_id"). - Where("account_id = ? AND peer_id IN ?", accountID, peerIDs). - Pluck("group_id", &groupIDs) - if result.Error != nil { - return nil, status.Errorf(status.Internal, "failed to get group IDs by peers: %s", result.Error) - } - - return groupIDs, nil -} - -// GetEmbeddedProxyPeerIDsByCluster returns peer IDs of all embedded proxy peers -// in the account, grouped by their ProxyCluster. The map is nil when no embedded -// proxy peers exist. -func (s *SqlStore) GetEmbeddedProxyPeerIDsByCluster(ctx context.Context, accountID string) (map[string][]string, error) { - type row struct { - ID string - Cluster string - } - var rows []row - result := s.db.Model(&nbpeer.Peer{}). - Select("id, proxy_meta_cluster AS cluster"). - Where("account_id = ? AND proxy_meta_embedded = ?", accountID, true). - Scan(&rows) - if result.Error != nil { - return nil, status.Errorf(status.Internal, "failed to get embedded proxy peers: %s", result.Error) - } - - out := make(map[string][]string, len(rows)) - for _, r := range rows { - out[r.Cluster] = append(out[r.Cluster], r.ID) - } - return out, nil -} - -func (s *SqlStore) GetUserIDByPeerKey(ctx context.Context, lockStrength LockingStrength, peerKey string) (string, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var userID string - result := tx.Model(&nbpeer.Peer{}). - Select("user_id"). - Take(&userID, GetKeyQueryCondition(s), peerKey) - - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return "", status.Errorf(status.NotFound, "peer not found: index lookup failed") - } - return "", status.Errorf(status.Internal, "failed to get user ID by peer key") - } - - return userID, nil -} - -func (s *SqlStore) CreateZone(ctx context.Context, zone *zones.Zone) error { - result := s.db.Create(zone) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to create zone to store: %v", result.Error) - return status.Errorf(status.Internal, "failed to create zone to store") - } - - return nil -} - -func (s *SqlStore) UpdateZone(ctx context.Context, zone *zones.Zone) error { - result := s.db.Select("*").Save(zone) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to update zone to store: %v", result.Error) - return status.Errorf(status.Internal, "failed to update zone to store") - } - - return nil -} - -func (s *SqlStore) DeleteZone(ctx context.Context, accountID, zoneID string) error { - result := s.db.Delete(&zones.Zone{}, accountAndIDQueryCondition, accountID, zoneID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to delete zone from store: %v", result.Error) - return status.Errorf(status.Internal, "failed to delete zone from store") - } - - if result.RowsAffected == 0 { - return status.NewZoneNotFoundError(zoneID) - } - - return nil -} - -func (s *SqlStore) GetZoneByID(ctx context.Context, lockStrength LockingStrength, accountID, zoneID string) (*zones.Zone, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var zone *zones.Zone - result := tx.Preload("Records").Take(&zone, accountAndIDQueryCondition, accountID, zoneID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.NewZoneNotFoundError(zoneID) - } - - log.WithContext(ctx).Errorf("failed to get zone from store: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to get zone from store") - } - - return zone, nil -} - -func (s *SqlStore) GetZoneByDomain(ctx context.Context, accountID, domain string) (*zones.Zone, error) { - var zone *zones.Zone - result := s.db.Where("account_id = ? AND domain = ?", accountID, domain).First(&zone) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.NewZoneNotFoundError(domain) - } - - log.WithContext(ctx).Errorf("failed to get zone by domain from store: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to get zone by domain from store") - } - - return zone, nil -} - -func (s *SqlStore) GetAccountZones(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*zones.Zone, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var zones []*zones.Zone - result := tx.Preload("Records").Find(&zones, accountIDCondition, accountID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get zones from the store: %s", result.Error) - return nil, status.Errorf(status.Internal, "failed to get zones from store") - } - - return zones, nil -} - -func (s *SqlStore) CreateDNSRecord(ctx context.Context, record *records.Record) error { - result := s.db.Create(record) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to create dns record to store: %v", result.Error) - return status.Errorf(status.Internal, "failed to create dns record to store") - } - - return nil -} - -func (s *SqlStore) UpdateDNSRecord(ctx context.Context, record *records.Record) error { - result := s.db.Select("*").Save(record) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to update dns record to store: %v", result.Error) - return status.Errorf(status.Internal, "failed to update dns record to store") - } - - return nil -} - -func (s *SqlStore) DeleteDNSRecord(ctx context.Context, accountID, zoneID, recordID string) error { - result := s.db.Delete(&records.Record{}, "account_id = ? AND zone_id = ? AND id = ?", accountID, zoneID, recordID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to delete dns record from store: %v", result.Error) - return status.Errorf(status.Internal, "failed to delete dns record from store") - } - - if result.RowsAffected == 0 { - return status.NewDNSRecordNotFoundError(recordID) - } - - return nil -} - -func (s *SqlStore) GetDNSRecordByID(ctx context.Context, lockStrength LockingStrength, accountID, zoneID, recordID string) (*records.Record, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var record *records.Record - result := tx.Where("account_id = ? AND zone_id = ? AND id = ?", accountID, zoneID, recordID).Take(&record) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.NewDNSRecordNotFoundError(recordID) - } - - log.WithContext(ctx).Errorf("failed to get dns record from store: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to get dns record from store") - } - - return record, nil -} - -func (s *SqlStore) GetZoneDNSRecords(ctx context.Context, lockStrength LockingStrength, accountID, zoneID string) ([]*records.Record, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var recordsList []*records.Record - result := tx.Where("account_id = ? AND zone_id = ?", accountID, zoneID).Find(&recordsList) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get zone dns records from the store: %s", result.Error) - return nil, status.Errorf(status.Internal, "failed to get zone dns records from store") - } - - return recordsList, nil -} - -func (s *SqlStore) GetZoneDNSRecordsByName(ctx context.Context, lockStrength LockingStrength, accountID, zoneID, name string) ([]*records.Record, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var recordsList []*records.Record - result := tx.Where("account_id = ? AND zone_id = ? AND name = ?", accountID, zoneID, name).Find(&recordsList) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get zone dns records by name from the store: %s", result.Error) - return nil, status.Errorf(status.Internal, "failed to get zone dns records by name from store") - } - - return recordsList, nil -} - -func (s *SqlStore) DeleteZoneDNSRecords(ctx context.Context, accountID, zoneID string) error { - result := s.db.Delete(&records.Record{}, "account_id = ? AND zone_id = ?", accountID, zoneID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to delete zone dns records from store: %v", result.Error) - return status.Errorf(status.Internal, "failed to delete zone dns records from store") - } - - return nil -} - -func (s *SqlStore) GetPeerIDByKey(ctx context.Context, lockStrength LockingStrength, key string) (string, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var peerID string - result := tx.Model(&nbpeer.Peer{}). - Select("id"). - Where(GetKeyQueryCondition(s), key). - Limit(1). - Scan(&peerID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get peer ID by key: %s", result.Error) - return "", status.Errorf(status.Internal, "failed to get peer ID by key") - } - - return peerID, nil -} - -func (s *SqlStore) CreateService(ctx context.Context, service *rpservice.Service) error { - serviceCopy := service.Copy() - if err := serviceCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil { - return fmt.Errorf("encrypt service data: %w", err) - } - result := s.db.Create(serviceCopy) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to create service to store: %v", result.Error) - return status.Errorf(status.Internal, "failed to create service to store") - } - - return nil -} - -func (s *SqlStore) UpdateService(ctx context.Context, service *rpservice.Service) error { - serviceCopy := service.Copy() - if err := serviceCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil { - return fmt.Errorf("encrypt service data: %w", err) - } - - // Create target type instance outside transaction to avoid variable shadowing - targetType := &rpservice.Target{} - - // Use a transaction to ensure atomic updates of the service and its targets - err := s.db.Transaction(func(tx *gorm.DB) error { - // Delete existing targets - if err := tx.Where("service_id = ?", serviceCopy.ID).Delete(targetType).Error; err != nil { - return err - } - - // Update the service and create new targets - if err := tx.Session(&gorm.Session{FullSaveAssociations: true}).Save(serviceCopy).Error; err != nil { - return err - } - - return nil - }) - if err != nil { - log.WithContext(ctx).Errorf("failed to update service to store: %v", err) - return status.Errorf(status.Internal, "failed to update service to store") - } - - return nil -} - -func (s *SqlStore) DeleteService(ctx context.Context, accountID, serviceID string) error { - result := s.db.Delete(&rpservice.Service{}, accountAndIDQueryCondition, accountID, serviceID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to delete service from store: %v", result.Error) - return status.Errorf(status.Internal, "failed to delete service from store") - } - - if result.RowsAffected == 0 { - return status.Errorf(status.NotFound, "service %s not found", serviceID) - } - - return nil -} - -func (s *SqlStore) DeleteTarget(ctx context.Context, accountID string, serviceID string, targetID uint) error { - result := s.db.Delete(&rpservice.Target{}, "account_id = ? AND service_id = ? AND id = ?", accountID, serviceID, targetID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to delete target from store: %v", result.Error) - return status.Errorf(status.Internal, "failed to delete target from store") - } - - if result.RowsAffected == 0 { - return status.Errorf(status.NotFound, "target not found for service %s", serviceID) - } - - return nil -} - -func (s *SqlStore) DeleteServiceTargets(ctx context.Context, accountID string, serviceID string) error { - result := s.db.Delete(&rpservice.Target{}, "account_id = ? AND service_id = ?", accountID, serviceID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to delete targets from store: %v", result.Error) - return status.Errorf(status.Internal, "failed to delete targets from store") - } - - return nil -} - -// GetTargetsByServiceID retrieves all targets for a given service -func (s *SqlStore) GetTargetsByServiceID(ctx context.Context, lockStrength LockingStrength, accountID string, serviceID string) ([]*rpservice.Target, error) { - var targets []*rpservice.Target - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - result := tx.Where("account_id = ? AND service_id = ?", accountID, serviceID).Find(&targets) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get targets from store: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to get targets from store") - } - - return targets, nil -} - -func (s *SqlStore) GetServiceByID(ctx context.Context, lockStrength LockingStrength, accountID, serviceID string) (*rpservice.Service, error) { - tx := s.db.Preload("Targets") - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var service *rpservice.Service - result := tx.Take(&service, accountAndIDQueryCondition, accountID, serviceID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.Errorf(status.NotFound, "service %s not found", serviceID) - } - - log.WithContext(ctx).Errorf("failed to get service from store: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to get service from store") - } - - if err := service.DecryptSensitiveData(s.fieldEncrypt); err != nil { - return nil, fmt.Errorf("decrypt service data: %w", err) - } - - return service, nil -} - -func (s *SqlStore) GetServiceByDomain(ctx context.Context, domain string) (*rpservice.Service, error) { - var service *rpservice.Service - result := s.db.Preload("Targets").Where("domain = ?", domain).First(&service) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.Errorf(status.NotFound, "service with domain %s not found", domain) - } - - log.WithContext(ctx).Errorf("failed to get service by domain from store: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to get service by domain from store") - } - - if err := service.DecryptSensitiveData(s.fieldEncrypt); err != nil { - return nil, fmt.Errorf("decrypt service data: %w", err) - } - - return service, nil -} - -func (s *SqlStore) GetServices(ctx context.Context, lockStrength LockingStrength) ([]*rpservice.Service, error) { - tx := s.db.Preload("Targets") - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var serviceList []*rpservice.Service - result := tx.Find(&serviceList) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get services from the store: %s", result.Error) - return nil, status.Errorf(status.Internal, "failed to get services from store") - } - - for _, service := range serviceList { - if err := service.DecryptSensitiveData(s.fieldEncrypt); err != nil { - return nil, fmt.Errorf("decrypt service data: %w", err) - } - } - - return serviceList, nil -} - -func (s *SqlStore) GetAccountServices(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*rpservice.Service, error) { - tx := s.db.Preload("Targets") - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var serviceList []*rpservice.Service - result := tx.Find(&serviceList, accountIDCondition, accountID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get services from the store: %s", result.Error) - return nil, status.Errorf(status.Internal, "failed to get services from store") - } - - for _, service := range serviceList { - if err := service.DecryptSensitiveData(s.fieldEncrypt); err != nil { - return nil, fmt.Errorf("decrypt service data: %w", err) - } - } - - return serviceList, nil -} - -// RenewEphemeralService updates the last_renewed_at timestamp for an ephemeral service. -func (s *SqlStore) RenewEphemeralService(ctx context.Context, accountID, peerID, serviceID string) error { - result := s.db.Model(&rpservice.Service{}). - Where("id = ? AND account_id = ? AND source_peer = ? AND source = ?", serviceID, accountID, peerID, rpservice.SourceEphemeral). - Update("meta_last_renewed_at", time.Now()) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to renew ephemeral service: %v", result.Error) - return status.Errorf(status.Internal, "renew ephemeral service") - } - if result.RowsAffected == 0 { - return status.Errorf(status.NotFound, "no active expose session for service %s", serviceID) - } - return nil -} - -// GetExpiredEphemeralServices returns ephemeral services whose last renewal exceeds the given TTL. -// Only the fields needed for reaping are selected. The limit parameter caps the batch size to -// avoid loading too many rows in a single tick. Rows with empty source_peer are excluded to -// skip malformed legacy data. -func (s *SqlStore) GetExpiredEphemeralServices(ctx context.Context, ttl time.Duration, limit int) ([]*rpservice.Service, error) { - cutoff := time.Now().Add(-ttl) - var services []*rpservice.Service - result := s.db. - Select("id", "account_id", "source_peer", "domain"). - Where("source = ? AND source_peer <> '' AND meta_last_renewed_at < ?", rpservice.SourceEphemeral, cutoff). - Limit(limit). - Find(&services) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get expired ephemeral services: %v", result.Error) - return nil, status.Errorf(status.Internal, "get expired ephemeral services") - } - return services, nil -} - -// CountEphemeralServicesByPeer returns the count of ephemeral services for a specific peer. -// Use LockingStrengthUpdate inside a transaction to serialize concurrent create operations. -// The locking is applied via a row-level SELECT ... FOR UPDATE (not on the aggregate) to -// stay compatible with Postgres, which disallows FOR UPDATE on COUNT(*). -func (s *SqlStore) CountEphemeralServicesByPeer(ctx context.Context, lockStrength LockingStrength, accountID, peerID string) (int64, error) { - if lockStrength == LockingStrengthNone { - var count int64 - result := s.db.Model(&rpservice.Service{}). - Where("account_id = ? AND source_peer = ? AND source = ?", accountID, peerID, rpservice.SourceEphemeral). - Count(&count) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to count ephemeral services: %v", result.Error) - return 0, status.Errorf(status.Internal, "count ephemeral services") - } - return count, nil - } - - var ids []string - result := s.db.Model(&rpservice.Service{}). - Clauses(clause.Locking{Strength: string(lockStrength)}). - Select("id"). - Where("account_id = ? AND source_peer = ? AND source = ?", accountID, peerID, rpservice.SourceEphemeral). - Pluck("id", &ids) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to count ephemeral services: %v", result.Error) - return 0, status.Errorf(status.Internal, "count ephemeral services") - } - return int64(len(ids)), nil -} - -// EphemeralServiceExists checks if an ephemeral service exists for the given peer and domain. -// Use LockingStrengthUpdate inside a transaction to serialize concurrent create operations. -func (s *SqlStore) EphemeralServiceExists(ctx context.Context, lockStrength LockingStrength, accountID, peerID, domain string) (bool, error) { - if lockStrength == LockingStrengthNone { - var count int64 - result := s.db.Model(&rpservice.Service{}). - Where("account_id = ? AND source_peer = ? AND domain = ? AND source = ?", accountID, peerID, domain, rpservice.SourceEphemeral). - Count(&count) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to check ephemeral service existence: %v", result.Error) - return false, status.Errorf(status.Internal, "check ephemeral service existence") - } - return count > 0, nil - } - - var id string - result := s.db.Model(&rpservice.Service{}). - Clauses(clause.Locking{Strength: string(lockStrength)}). - Select("id"). - Where("account_id = ? AND source_peer = ? AND domain = ? AND source = ?", accountID, peerID, domain, rpservice.SourceEphemeral). - Limit(1). - Pluck("id", &id) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to check ephemeral service existence: %v", result.Error) - return false, status.Errorf(status.Internal, "check ephemeral service existence") - } - return id != "", nil -} - -// GetServicesByClusterAndPort returns services matching the given proxy cluster, mode, and listen port. -func (s *SqlStore) GetServicesByClusterAndPort(ctx context.Context, lockStrength LockingStrength, proxyCluster string, mode string, listenPort uint16) ([]*rpservice.Service, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var services []*rpservice.Service - result := tx.Where("proxy_cluster = ? AND mode = ? AND listen_port = ?", proxyCluster, mode, listenPort).Find(&services) - if result.Error != nil { - return nil, status.Errorf(status.Internal, "query services by cluster and port") - } - - return services, nil -} - -// GetServicesByCluster returns all services for the given proxy cluster. -func (s *SqlStore) GetServicesByCluster(ctx context.Context, lockStrength LockingStrength, proxyCluster string) ([]*rpservice.Service, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var services []*rpservice.Service - result := tx.Where("proxy_cluster = ?", proxyCluster).Find(&services) - if result.Error != nil { - return nil, status.Errorf(status.Internal, "query services by cluster") - } - return services, nil -} - -func (s *SqlStore) GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error) { - tx := s.db - - customDomain := &domain.Domain{} - result := tx.Take(&customDomain, accountAndIDQueryCondition, accountID, domainID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.Errorf(status.NotFound, "custom domain %s not found", domainID) - } - - log.WithContext(ctx).Errorf("failed to get custom domain from store: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to get custom domain from store") - } - - return customDomain, nil -} - -func (s *SqlStore) ListFreeDomains(ctx context.Context, accountID string) ([]string, error) { - return nil, nil -} - -func (s *SqlStore) ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error) { - tx := s.db - - var domains []*domain.Domain - result := tx.Find(&domains, accountIDCondition, accountID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get reverse proxy custom domains from the store: %s", result.Error) - return nil, status.Errorf(status.Internal, "failed to get reverse proxy custom domains from store") - } - - return domains, nil -} - -// GetCustomDomainByName returns the custom domain row holding the given name, -// regardless of which account owns it. -func (s *SqlStore) GetCustomDomainByName(ctx context.Context, domainName string) (*domain.Domain, error) { - customDomain := &domain.Domain{} - result := s.db.Take(customDomain, "domain = ?", domainName) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.Errorf(status.NotFound, "custom domain %s not found", domainName) - } - - log.WithContext(ctx).Errorf("failed to get custom domain by name from store: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to get custom domain from store") - } - - return customDomain, nil -} - -func (s *SqlStore) CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error) { - newDomain := &domain.Domain{ - ID: xid.New().String(), // Generate our own ID because gorm doesn't always configure the database to handle this for us. - Domain: domainName, - AccountID: accountID, - TargetCluster: targetCluster, - Type: domain.TypeCustom, - Validated: validated, - } - if !validated { - expiresAt := time.Now().UTC().Add(domain.ValidationTTL) - newDomain.ValidationExpiresAt = &expiresAt - } - result := s.db.Create(newDomain) - if result.Error != nil { - // The unique index is the last guard when two requests clear the - // manager's availability check at the same time. The one that loses the - // insert is a conflict, not an internal failure. - var count int64 - if err := s.db.Model(&domain.Domain{}).Where("domain = ?", domainName).Count(&count).Error; err == nil && count > 0 { - // The insert error is logged even on this path: the name being taken - // is what the caller has to act on, but if the insert also failed for - // an unrelated reason the operator still needs to see it. - log.WithContext(ctx).Warnf("create reverse proxy custom domain %s rejected, name already registered: %v", domainName, result.Error) - return nil, status.Errorf(status.AlreadyExists, "domain %s is already registered", domainName) - } - - log.WithContext(ctx).Errorf("failed to create reverse proxy custom domain to store: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to create reverse proxy custom domain to store") - } - - return newDomain, nil -} - -// UpdateCustomDomain completes validation only while the original registration is pending. -func (s *SqlStore) UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error) { - if !d.Validated { - return nil, status.Errorf(status.InvalidArgument, "custom domain update must complete validation") - } - result := s.db.WithContext(ctx).Model(&domain.Domain{}). - Where(accountAndIDQueryCondition, accountID, d.ID). - Where("domain = ? AND target_cluster = ?", d.Domain, d.TargetCluster). - Where("validated = ? AND validation_expires_at > ?", false, time.Now().UTC()). - Update("validated", true) - if result.Error != nil { - return nil, fmt.Errorf("validate custom domain in store: %w", result.Error) - } - if result.RowsAffected == 0 { - return nil, status.Errorf(status.PreconditionFailed, "custom domain registration is no longer pending validation") - } - - return d, nil -} - -func (s *SqlStore) DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error { - result := s.db.Delete(domain.Domain{}, accountAndIDQueryCondition, accountID, domainID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to delete reverse proxy custom domain from store: %v", result.Error) - return status.Errorf(status.Internal, "failed to delete reverse proxy custom domain from store") - } - - if result.RowsAffected == 0 { - return status.Errorf(status.NotFound, "reverse proxy custom domain %s not found", domainID) - } - - return nil -} - -// CreateAccessLog creates a new access log entry in the database -func (s *SqlStore) CreateAccessLog(ctx context.Context, logEntry *accesslogs.AccessLogEntry) error { - result := s.db.Create(logEntry) - if result.Error != nil { - log.WithContext(ctx).WithFields(log.Fields{ - "service_id": logEntry.ServiceID, - "method": logEntry.Method, - "host": logEntry.Host, - "path": logEntry.Path, - }).Errorf("failed to create access log entry in store: %v", result.Error) - return status.Errorf(status.Internal, "failed to create access log entry in store") - } - return nil -} - -// CreateAgentNetworkAccessLog persists a flattened agent-network access-log -// entry together with its authorising-group child rows in a single -// transaction. -func (s *SqlStore) CreateAgentNetworkAccessLog(ctx context.Context, entry *agentNetworkTypes.AgentNetworkAccessLog, groups []agentNetworkTypes.AgentNetworkAccessLogGroup) error { - err := s.db.Transaction(func(tx *gorm.DB) error { - // Idempotent on the log id / (log_id, group_id) so a proxy resend of the - // same entry can't fail the request. - if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(entry).Error; err != nil { - return err - } - if len(groups) > 0 { - if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&groups).Error; err != nil { - return err - } - } - return nil - }) - if err != nil { - log.WithContext(ctx).WithFields(log.Fields{ - "account_id": entry.AccountID, - "service_id": entry.ServiceID, - "model": entry.Model, - }).Errorf("failed to create agent-network access log entry in store: %v", err) - return status.Errorf(status.Internal, "failed to create agent-network access log entry in store") - } - return nil -} - -// CreateAgentNetworkUsage persists a stripped agent-network usage record -// together with its authorising-group child rows in a single transaction. -func (s *SqlStore) CreateAgentNetworkUsage(ctx context.Context, usage *agentNetworkTypes.AgentNetworkUsage, groups []agentNetworkTypes.AgentNetworkUsageGroup) error { - err := s.db.Transaction(func(tx *gorm.DB) error { - // Idempotent on the usage id / (usage_id, group_id) so a proxy resend of - // the same entry can't fail the request. - if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(usage).Error; err != nil { - return err - } - if len(groups) > 0 { - if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&groups).Error; err != nil { - return err - } - } - return nil - }) - if err != nil { - log.WithContext(ctx).WithFields(log.Fields{ - "account_id": usage.AccountID, - "model": usage.Model, - }).Errorf("failed to create agent-network usage record in store: %v", err) - return status.Errorf(status.Internal, "failed to create agent-network usage record in store") - } - return nil -} - -// DeleteOldAgentNetworkAccessLogs deletes an account's access-log rows (and -// their authorising-group child rows) older than the cutoff. Usage records are -// untouched — they are the long-term aggregate. Returns the number of log rows -// deleted. -func (s *SqlStore) DeleteOldAgentNetworkAccessLogs(ctx context.Context, accountID string, olderThan time.Time) (int64, error) { - var deleted int64 - err := s.db.Transaction(func(tx *gorm.DB) error { - // Remove group child rows for the soon-to-be-deleted logs first. - if err := tx.Exec( - "DELETE FROM agent_network_access_log_group WHERE account_id = ? AND log_id IN (SELECT id FROM agent_network_access_log WHERE account_id = ? AND timestamp < ?)", - accountID, accountID, olderThan, - ).Error; err != nil { - return err - } - res := tx.Where("account_id = ? AND timestamp < ?", accountID, olderThan). - Delete(&agentNetworkTypes.AgentNetworkAccessLog{}) - if res.Error != nil { - return res.Error - } - deleted = res.RowsAffected - return nil - }) - if err != nil { - log.WithContext(ctx).Errorf("failed to delete old agent-network access logs for account %s: %v", accountID, err) - return 0, status.Errorf(status.Internal, "failed to delete old agent-network access logs") - } - return deleted, nil -} - -// GetAgentNetworkUsageRows returns the stripped usage rows for an account that -// match the filter (date / user / group / provider / model). Aggregation into -// time buckets happens in the manager so granularities stay engine-portable. -func (s *SqlStore) GetAgentNetworkUsageRows(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkUsage, error) { - var rows []*agentNetworkTypes.AgentNetworkUsage - - query := s.applyAgentNetworkUsageFilters( - s.db.Where(accountIDCondition, accountID), - filter, - ).Order("timestamp ASC") - - if lockStrength != LockingStrengthNone { - query = query.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - if err := query.Find(&rows).Error; err != nil { - log.WithContext(ctx).Errorf("failed to get agent-network usage rows from store: %v", err) - return nil, status.Errorf(status.Internal, "failed to get agent-network usage rows from store") - } - return rows, nil -} - -// applyAgentNetworkUsageFilters applies the shared access-log filter's -// date/user/group/provider/model conditions to a usage-table query. Pagination, -// sort and free-text search are ignored — the overview is an aggregate. -func (s *SqlStore) applyAgentNetworkUsageFilters(query *gorm.DB, filter agentNetworkTypes.AgentNetworkAccessLogFilter) *gorm.DB { - if filter.UserID != nil { - query = query.Where("user_id = ?", *filter.UserID) - } - if filter.SessionID != nil { - query = query.Where("session_id = ?", *filter.SessionID) - } - if len(filter.ProviderIDs) > 0 { - query = query.Where("resolved_provider_id IN ?", filter.ProviderIDs) - } - if len(filter.Models) > 0 { - query = query.Where("model IN ?", filter.Models) - } - if len(filter.GroupIDs) > 0 { - query = query.Where( - "id IN (SELECT usage_id FROM agent_network_request_usage_group WHERE group_id IN ?)", - filter.GroupIDs, - ) - } - if filter.StartDate != nil { - query = query.Where("timestamp >= ?", *filter.StartDate) - } - if filter.EndDate != nil { - query = query.Where("timestamp <= ?", *filter.EndDate) - } - return query -} - -// GetAgentNetworkAccessLogs retrieves flattened agent-network access logs for -// an account with server-side pagination, filtering and sorting. Authorising -// group ids are hydrated from the group child table for the returned page. -func (s *SqlStore) GetAgentNetworkAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLog, int64, error) { - var logs []*agentNetworkTypes.AgentNetworkAccessLog - var totalCount int64 - - countQuery := s.applyAgentNetworkAccessLogFilters( - s.db.Model(&agentNetworkTypes.AgentNetworkAccessLog{}).Where(accountIDCondition, accountID), - filter, - ) - if err := countQuery.Count(&totalCount).Error; err != nil { - log.WithContext(ctx).Errorf("failed to count agent-network access logs: %v", err) - return nil, 0, status.Errorf(status.Internal, "failed to count agent-network access logs") - } - - query := s.applyAgentNetworkAccessLogFilters( - s.db.Where(accountIDCondition, accountID), - filter, - ). - Order(filter.GetSortColumn() + " " + filter.GetSortOrder()). - Limit(filter.GetLimit()). - Offset(filter.GetOffset()) - - if lockStrength != LockingStrengthNone { - query = query.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - if err := query.Find(&logs).Error; err != nil { - log.WithContext(ctx).Errorf("failed to get agent-network access logs from store: %v", err) - return nil, 0, status.Errorf(status.Internal, "failed to get agent-network access logs from store") - } - - if err := s.hydrateAgentNetworkAccessLogGroups(ctx, accountID, logs); err != nil { - return nil, 0, err - } - - return logs, totalCount, nil -} - -// applyAgentNetworkAccessLogFilters applies the filter conditions to a query. -func (s *SqlStore) applyAgentNetworkAccessLogFilters(query *gorm.DB, filter agentNetworkTypes.AgentNetworkAccessLogFilter) *gorm.DB { - if filter.Search != nil { - p := "%" + *filter.Search + "%" - query = query.Where( - "id LIKE ? OR host LIKE ? OR path LIKE ? OR model LIKE ? OR user_id IN (SELECT id FROM users WHERE email LIKE ? OR name LIKE ?)", - p, p, p, p, p, p, - ) - } - if filter.UserID != nil { - query = query.Where("user_id = ?", *filter.UserID) - } - if filter.SessionID != nil { - query = query.Where("session_id = ?", *filter.SessionID) - } - if filter.Decision != nil { - query = query.Where("decision = ?", *filter.Decision) - } - if filter.PathPrefix != nil { - query = query.Where("path LIKE ?", *filter.PathPrefix+"%") - } - if len(filter.ProviderIDs) > 0 { - query = query.Where("resolved_provider_id IN ?", filter.ProviderIDs) - } - if len(filter.Models) > 0 { - query = query.Where("model IN ?", filter.Models) - } - if len(filter.GroupIDs) > 0 { - query = query.Where( - "id IN (SELECT log_id FROM agent_network_access_log_group WHERE group_id IN ?)", - filter.GroupIDs, - ) - } - if filter.StartDate != nil { - query = query.Where("timestamp >= ?", *filter.StartDate) - } - if filter.EndDate != nil { - query = query.Where("timestamp <= ?", *filter.EndDate) - } - return query -} - -// hydrateAgentNetworkAccessLogGroups loads the authorising group ids for the -// given page of entries and assigns them onto each entry's GroupIDs field. -func (s *SqlStore) hydrateAgentNetworkAccessLogGroups(ctx context.Context, accountID string, logs []*agentNetworkTypes.AgentNetworkAccessLog) error { - if len(logs) == 0 { - return nil - } - - ids := make([]string, 0, len(logs)) - for _, l := range logs { - ids = append(ids, l.ID) - } - - var rows []agentNetworkTypes.AgentNetworkAccessLogGroup - if err := s.db. - Where(accountIDCondition, accountID). - Where("log_id IN ?", ids). - Find(&rows).Error; err != nil { - log.WithContext(ctx).Errorf("failed to hydrate agent-network access log groups: %v", err) - return status.Errorf(status.Internal, "failed to hydrate agent-network access log groups") - } - - byLog := make(map[string][]string, len(logs)) - for _, r := range rows { - byLog[r.LogID] = append(byLog[r.LogID], r.GroupID) - } - for _, l := range logs { - l.GroupIDs = byLog[l.ID] - } - return nil -} - -// agentNetworkSessionKeyExpr is the SQL group key for session-grouped access -// logs: the row's session id, or — when the client sent none — the row id, so -// session-less requests each form their own singleton group. COALESCE/NULLIF -// are standard SQL, so this stays portable across SQLite and Postgres. -const agentNetworkSessionKeyExpr = "COALESCE(NULLIF(session_id, ''), id)" - -// GetAgentNetworkAccessLogSessions retrieves agent-network access logs grouped -// by session, with server-side pagination, filtering and sorting at the session -// level. It paginates over the distinct session keys (ordered by the requested -// session-level aggregate), fetches every entry for the page's sessions, and -// folds them into per-session summaries. The returned count is the number of -// matching sessions. Filters apply to the entries, so a session's summary -// reflects only its filter-matching requests. -func (s *SqlStore) GetAgentNetworkAccessLogSessions(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLogSession, int64, error) { - // Count distinct sessions via a grouped subquery — portable and avoids - // relying on COUNT(DISTINCT ) quoting quirks. - sessionsSubquery := s.applyAgentNetworkAccessLogFilters( - s.db.Model(&agentNetworkTypes.AgentNetworkAccessLog{}).Where(accountIDCondition, accountID), - filter, - ). - Select(agentNetworkSessionKeyExpr + " AS session_key"). - Group(agentNetworkSessionKeyExpr) - - var totalCount int64 - if err := s.db.Table("(?) AS sessions", sessionsSubquery).Count(&totalCount).Error; err != nil { - log.WithContext(ctx).Errorf("failed to count agent-network access-log sessions: %v", err) - return nil, 0, status.Errorf(status.Internal, "failed to count agent-network access-log sessions") - } - - // The page of session keys, ordered by the session-level aggregate. The - // session-key tiebreaker keeps pagination deterministic when the primary - // aggregate ties. - type sessionKeyRow struct { - SessionKey string - } - var keyRows []sessionKeyRow - keyQuery := s.applyAgentNetworkAccessLogFilters( - s.db.Model(&agentNetworkTypes.AgentNetworkAccessLog{}).Where(accountIDCondition, accountID), - filter, - ). - Select(agentNetworkSessionKeyExpr + " AS session_key"). - Group(agentNetworkSessionKeyExpr). - Order(filter.GetSessionSortExpr() + " " + filter.GetSortOrder()). - Order("session_key ASC"). - Limit(filter.GetLimit()). - Offset(filter.GetOffset()) - if err := keyQuery.Scan(&keyRows).Error; err != nil { - log.WithContext(ctx).Errorf("failed to list agent-network access-log session keys: %v", err) - return nil, 0, status.Errorf(status.Internal, "failed to list agent-network access-log session keys") - } - if len(keyRows) == 0 { - return nil, totalCount, nil - } - - keys := make([]string, 0, len(keyRows)) - for _, r := range keyRows { - keys = append(keys, r.SessionKey) - } - - // All entries for the page's sessions, contiguous per session and oldest - // first within each — the fold relies on that ordering. - var entries []*agentNetworkTypes.AgentNetworkAccessLog - entriesQuery := s.applyAgentNetworkAccessLogFilters( - s.db.Where(accountIDCondition, accountID), - filter, - ). - Where(agentNetworkSessionKeyExpr+" IN ?", keys). - Order(agentNetworkSessionKeyExpr + ", timestamp ASC") - - if lockStrength != LockingStrengthNone { - entriesQuery = entriesQuery.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - if err := entriesQuery.Find(&entries).Error; err != nil { - log.WithContext(ctx).Errorf("failed to get agent-network access-log session entries: %v", err) - return nil, 0, status.Errorf(status.Internal, "failed to get agent-network access-log session entries") - } - - if err := s.hydrateAgentNetworkAccessLogGroups(ctx, accountID, entries); err != nil { - return nil, 0, err - } - - return agentNetworkTypes.FoldAccessLogSessions(keys, entries), totalCount, nil -} - -// GetAccountAccessLogs retrieves access logs for a given account with pagination and filtering -func (s *SqlStore) GetAccountAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) { - var logs []*accesslogs.AccessLogEntry - var totalCount int64 - - baseQuery := s.db. - Model(&accesslogs.AccessLogEntry{}). - Where(accountIDCondition, accountID) - - baseQuery = s.applyAccessLogFilters(baseQuery, filter) - - if err := baseQuery.Count(&totalCount).Error; err != nil { - log.WithContext(ctx).Errorf("failed to count access logs: %v", err) - return nil, 0, status.Errorf(status.Internal, "failed to count access logs") - } - - query := s.db. - Where(accountIDCondition, accountID) - - query = s.applyAccessLogFilters(query, filter) - - sortColumns := filter.GetSortColumn() - sortOrder := strings.ToUpper(filter.GetSortOrder()) - - var orderClauses []string - for _, col := range strings.Split(sortColumns, ",") { - col = strings.TrimSpace(col) - if col != "" { - orderClauses = append(orderClauses, col+" "+sortOrder) - } - } - orderClause := strings.Join(orderClauses, ", ") - - query = query. - Order(orderClause). - Limit(filter.GetLimit()). - Offset(filter.GetOffset()) - - if lockStrength != LockingStrengthNone { - query = query.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - result := query.Find(&logs) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get access logs from store: %v", result.Error) - return nil, 0, status.Errorf(status.Internal, "failed to get access logs from store") - } - - return logs, totalCount, nil -} - -// DeleteOldAccessLogs deletes all access logs older than the specified time -func (s *SqlStore) DeleteOldAccessLogs(ctx context.Context, olderThan time.Time) (int64, error) { - result := s.db. - Where("timestamp < ?", olderThan). - Delete(&accesslogs.AccessLogEntry{}) - - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to delete old access logs: %v", result.Error) - return 0, status.Errorf(status.Internal, "failed to delete old access logs") - } - - return result.RowsAffected, nil -} - -// applyAccessLogFilters applies filter conditions to the query -func (s *SqlStore) applyAccessLogFilters(query *gorm.DB, filter accesslogs.AccessLogFilter) *gorm.DB { - if filter.Search != nil { - searchPattern := "%" + *filter.Search + "%" - query = query.Where( - "id LIKE ? OR location_connection_ip LIKE ? OR host LIKE ? OR path LIKE ? OR CONCAT(host, path) LIKE ? OR user_id IN (SELECT id FROM users WHERE email LIKE ? OR name LIKE ?)", - searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, - ) - } - - if filter.SourceIP != nil { - query = query.Where("location_connection_ip = ?", *filter.SourceIP) - } - - if filter.Host != nil { - query = query.Where("host = ?", *filter.Host) - } - - if filter.Path != nil { - // Support LIKE pattern for path filtering - query = query.Where("path LIKE ?", "%"+*filter.Path+"%") - } - - if filter.UserID != nil { - query = query.Where("user_id = ?", *filter.UserID) - } - - if filter.Method != nil { - query = query.Where("method = ?", *filter.Method) - } - - if filter.Status != nil { - switch *filter.Status { - case "success": - query = query.Where("status_code >= ? AND status_code < ?", 200, 400) - case "failed": - query = query.Where("status_code < ? OR status_code >= ?", 200, 400) - } - } - - if filter.StatusCode != nil { - query = query.Where("status_code = ?", *filter.StatusCode) - } - - if filter.StartDate != nil { - query = query.Where("timestamp >= ?", *filter.StartDate) - } - - if filter.EndDate != nil { - query = query.Where("timestamp <= ?", *filter.EndDate) - } - - return query -} - -func (s *SqlStore) GetServiceTargetByTargetID(ctx context.Context, lockStrength LockingStrength, accountID string, targetID string) (*rpservice.Target, error) { - tx := s.db - if lockStrength != LockingStrengthNone { - tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) - } - - var target *rpservice.Target - result := tx.Take(&target, "account_id = ? AND target_id = ?", accountID, targetID) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.Errorf(status.NotFound, "service target with ID %s not found", targetID) - } - - log.WithContext(ctx).Errorf("failed to get service target from store: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to get service target from store") - } - - return target, nil -} - -// SaveProxy saves or updates a proxy in the database -func (s *SqlStore) SaveProxy(ctx context.Context, p *proxy.Proxy) error { - result := s.db.Save(p) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to save proxy: %v", result.Error) - return status.Errorf(status.Internal, "failed to save proxy") - } - return nil -} - -// DisconnectProxy marks a proxy as disconnected only if the session ID matches. -// This prevents a slow-to-close old session from overwriting a newer reconnection. -func (s *SqlStore) DisconnectProxy(ctx context.Context, proxyID, sessionID string) error { - now := time.Now() - result := s.db. - Model(&proxy.Proxy{}). - Where("id = ? AND session_id = ?", proxyID, sessionID). - Updates(map[string]any{ - "status": proxy.StatusDisconnected, - "disconnected_at": now, - "last_seen": now, - }) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to disconnect proxy %s session %s: %v", proxyID, sessionID, result.Error) - return status.Errorf(status.Internal, "failed to disconnect proxy") - } - if result.RowsAffected == 0 { - log.WithContext(ctx).Debugf("proxy %s session %s: no row updated (superseded by newer session)", proxyID, sessionID) - } - return nil -} - -// GetAllProxies returns all reverse proxy instance rows. -func (s *SqlStore) GetAllProxies(ctx context.Context) ([]*proxy.Proxy, error) { - var proxies []*proxy.Proxy - result := s.db.Order("cluster_address, id").Find(&proxies) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get proxies: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to get proxies") - } - return proxies, nil -} - -// DisconnectAllProxies force-marks every proxy that is not already disconnected -// as disconnected, regardless of session ID. Unlike DisconnectProxy it is not -// session-guarded: it is an administrative repair helper, not part of the -// connection lifecycle. last_seen is left untouched so the stale-proxy reaper -// keeps working off the real last heartbeat. Returns the number of proxies updated. -func (s *SqlStore) DisconnectAllProxies(ctx context.Context) (int64, error) { - result := s.db. - Model(&proxy.Proxy{}). - Where("status != ?", proxy.StatusDisconnected). - Updates(map[string]any{ - "status": proxy.StatusDisconnected, - "disconnected_at": time.Now(), - }) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to disconnect all proxies: %v", result.Error) - return 0, status.Errorf(status.Internal, "failed to disconnect all proxies") - } - return result.RowsAffected, nil -} - -// UpdateProxyHeartbeat updates the last_seen timestamp for the proxy's current session. -func (s *SqlStore) UpdateProxyHeartbeat(ctx context.Context, p *proxy.Proxy) error { - now := time.Now() - - result := s.db. - Model(&proxy.Proxy{}). - Where("id = ? AND session_id = ?", p.ID, p.SessionID). - Updates(map[string]any{ - "last_seen": now, - "status": proxy.StatusConnected, - "disconnected_at": nil, - }) - - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to update proxy heartbeat: %v", result.Error) - return status.Errorf(status.Internal, "failed to update proxy heartbeat") - } - - if result.RowsAffected == 0 { - p.LastSeen = now - p.ConnectedAt = &now - p.Status = proxy.StatusConnected - if err := s.db.Create(p).Error; err != nil { - log.WithContext(ctx).Debugf("proxy %s session %s: heartbeat fallback insert skipped: %v", p.ID, p.SessionID, err) - } - } - - return nil -} - -// GetActiveProxyClusterAddresses returns the unique cluster addresses of active -// shared proxies (those without an account scope). BYOP cluster addresses are -// excluded; use GetActiveProxyClusterAddressesForAccount to retrieve them. -func (s *SqlStore) GetActiveProxyClusterAddresses(ctx context.Context) ([]string, error) { - var addresses []string - - result := s.db. - Model(&proxy.Proxy{}). - Where("account_id IS NULL AND status = ? AND last_seen > ?", proxy.StatusConnected, time.Now().Add(-proxyActiveThreshold)). - Distinct("cluster_address"). - Pluck("cluster_address", &addresses) - - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get active proxy cluster addresses: %v", result.Error) - return nil, status.Errorf(status.Internal, "failed to get active proxy cluster addresses") - } - - return addresses, nil -} - -func (s *SqlStore) GetActiveProxyClusterAddressesForAccount(ctx context.Context, accountID string) ([]string, error) { - var addresses []string - - result := s.db. - Model(&proxy.Proxy{}). - Where("account_id = ? AND status = ? AND last_seen > ?", accountID, proxy.StatusConnected, time.Now().Add(-proxyActiveThreshold)). - Distinct("cluster_address"). - Pluck("cluster_address", &addresses) - - if result.Error != nil { - return nil, status.Errorf(status.Internal, "failed to get active proxy cluster addresses for account") - } - - return addresses, nil -} - -func (s *SqlStore) GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error) { - var p proxy.Proxy - result := s.db.Where("account_id = ?", accountID).Take(&p) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrRecordNotFound) { - return nil, status.Errorf(status.NotFound, "proxy not found for account") - } - return nil, status.Errorf(status.Internal, "get proxy by account ID: %v", result.Error) - } - return &p, nil -} - -func (s *SqlStore) CountProxiesByAccountID(ctx context.Context, accountID string) (int64, error) { - var count int64 - result := s.db.Model(&proxy.Proxy{}).Where("account_id = ?", accountID).Count(&count) - if result.Error != nil { - return 0, status.Errorf(status.Internal, "count proxies by account ID: %v", result.Error) - } - return count, nil -} - -// HasActiveProxyAtClusterAddress reports whether any proxy — shared or -// account-scoped — is currently active at the given cluster address, using -// the same connected-within-threshold window as the other active-proxy -// queries. Backs the agent-network settings delete guard: settings cannot be -// deleted while a proxy declares the endpoint hostname as its address. -// -// The comparison folds case on both sides: the caller passes a normalized -// (lowercase) hostname, but proxies declare their cluster address verbatim -// and Connect stores it unchanged, so on case-sensitive collations a proxy -// declaring "GW.Example.com" would otherwise slip past the guard. Hostnames -// are case-insensitive per RFC 4343; the guard must be too. -func (s *SqlStore) HasActiveProxyAtClusterAddress(ctx context.Context, clusterAddress string) (bool, error) { - var count int64 - result := s.db. - Model(&proxy.Proxy{}). - Where("LOWER(cluster_address) = LOWER(?) AND status = ? AND last_seen > ?", clusterAddress, proxy.StatusConnected, time.Now().Add(-proxyActiveThreshold)). - Count(&count) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to count active proxies at cluster address: %v", result.Error) - return false, status.Errorf(status.Internal, "failed to count active proxies at cluster address") - } - return count > 0, nil -} - -func (s *SqlStore) IsClusterAddressConflicting(ctx context.Context, clusterAddress, accountID string) (bool, error) { - var count int64 - result := s.db. - Model(&proxy.Proxy{}). - Where("cluster_address = ? AND (account_id IS NULL OR account_id != ?)", clusterAddress, accountID). - Count(&count) - if result.Error != nil { - return false, status.Errorf(status.Internal, "check cluster address conflict: %v", result.Error) - } - return count > 0, nil -} - -// HasForeignAccountProxyAtHost reports whether a proxy owned by a different -// account declares this host. Shared proxies (account_id IS NULL) are not -// foreign: a shared cluster is what most accounts pin their agent network -// gateway to. The match folds case because proxies declare their address as -// the operator spelled it while the caller's host is normalised; that costs a -// scan of the proxies table, taken once per account when its gateway is -// bootstrapped, not on the per-connect path IsClusterAddressConflicting serves. -func (s *SqlStore) HasForeignAccountProxyAtHost(ctx context.Context, host, accountID string) (bool, error) { - var count int64 - result := s.db. - Model(&proxy.Proxy{}). - Where("LOWER(cluster_address) = LOWER(?) AND account_id IS NOT NULL AND account_id != ?", host, accountID). - Count(&count) - if result.Error != nil { - return false, status.Errorf(status.Internal, "check proxy host ownership: %v", result.Error) - } - return count > 0, nil -} - -func (s *SqlStore) DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error { - result := s.db. - Where("cluster_address = ? AND account_id = ?", clusterAddress, accountID). - Delete(&proxy.Proxy{}) - if result.Error != nil { - return status.Errorf(status.Internal, "delete account cluster: %v", result.Error) - } - if result.RowsAffected == 0 { - return status.Errorf(status.NotFound, "cluster not found") - } - return nil -} - -// GetProxyClusters returns every cluster the account can see (shared -// plus its own BYOP), regardless of whether any proxy in the cluster -// is currently heartbeating. Online and ConnectedProxies are derived -// from the 2-min active window so the dashboard can render offline -// clusters distinctly; the 1-hour heartbeat reaper still removes rows -// that go quiet for too long. -// -// AccountOwned is determined by whether any proxy row in the group -// carries a non-NULL account_id; the caller maps that to Cluster.Type. -// Capability flags are NOT filled here — the handler enriches them via -// the per-cluster capability lookups. -func (s *SqlStore) GetProxyClusters(ctx context.Context, accountID string) ([]proxy.Cluster, error) { - activeCutoff := time.Now().Add(-proxyActiveThreshold) - - type clusterRow struct { - ID string - Address string - ConnectedProxies int - Online bool - AccountOwned bool - } - - var rows []clusterRow - result := s.db.Model(&proxy.Proxy{}). - Select( - "MIN(id) AS id, "+ - "cluster_address AS address, "+ - // COUNT(CASE WHEN ... THEN 1 END) counts only non-NULL — i.e. only - // rows that satisfy the predicate — so it works portably across - // sqlite/postgres/mysql without dialect-specific FILTER syntax. - "COUNT(CASE WHEN status = ? AND last_seen > ? THEN 1 END) AS connected_proxies, "+ - // MAX(CASE …) > 0 expresses BOOL_OR in a way Postgres tolerates - // (Postgres can't MAX a boolean column). - "MAX(CASE WHEN status = ? AND last_seen > ? THEN 1 ELSE 0 END) > 0 AS online, "+ - "MAX(CASE WHEN account_id IS NOT NULL THEN 1 ELSE 0 END) > 0 AS account_owned", - proxy.StatusConnected, activeCutoff, - proxy.StatusConnected, activeCutoff, - ). - Where("account_id IS NULL OR account_id = ?", accountID). - Group("cluster_address"). - Scan(&rows) - - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get proxy clusters: %v", result.Error) - return nil, status.Errorf(status.Internal, "get proxy clusters") - } - - clusters := make([]proxy.Cluster, 0, len(rows)) - for _, r := range rows { - c := proxy.Cluster{ - ID: r.ID, - Address: r.Address, - Online: r.Online, - ConnectedProxies: r.ConnectedProxies, - } - if r.AccountOwned { - c.Type = proxy.ClusterTypeAccount - } else { - c.Type = proxy.ClusterTypeShared - } - clusters = append(clusters, c) - } - - return clusters, nil -} - -// proxyActiveThreshold is the maximum age of a heartbeat for a proxy to be -// considered active. Must be at least 2x the heartbeat interval (1 min). -const proxyActiveThreshold = 2 * time.Minute - -var validCapabilityColumns = map[string]struct{}{ - "supports_custom_ports": {}, - "require_subdomain": {}, - "supports_crowdsec": {}, - "private": {}, -} - -// GetClusterSupportsCustomPorts returns whether any active proxy in the cluster -// supports custom ports. Returns nil when no proxy reported the capability. -func (s *SqlStore) GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool { - return s.getClusterCapability(ctx, clusterAddr, "supports_custom_ports") -} - -// GetClusterRequireSubdomain returns whether any active proxy in the cluster -// requires a subdomain. Returns nil when no proxy reported the capability. -func (s *SqlStore) GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool { - return s.getClusterCapability(ctx, clusterAddr, "require_subdomain") -} - -// GetClusterSupportsPrivate reports whether any active proxy in the cluster -// has the private capability (nil = unreported). -func (s *SqlStore) GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool { - return s.getClusterCapability(ctx, clusterAddr, "private") -} - -// GetClusterSupportsCrowdSec returns whether all active proxies in the cluster -// have CrowdSec configured. Returns nil when no proxy reported the capability. -// Unlike other capabilities that use ANY-true (for rolling upgrades), CrowdSec -// requires unanimous support: a single unconfigured proxy would let requests -// bypass reputation checks. -func (s *SqlStore) GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool { - return s.getClusterUnanimousCapability(ctx, clusterAddr, "supports_crowdsec") -} - -// getClusterUnanimousCapability returns an aggregated boolean capability -// requiring all active proxies in the cluster to report true. -func (s *SqlStore) getClusterUnanimousCapability(ctx context.Context, clusterAddr, column string) *bool { - if _, ok := validCapabilityColumns[column]; !ok { - log.WithContext(ctx).Errorf("invalid capability column: %s", column) - return nil - } - - var result struct { - Total int64 - Reported int64 - AllTrue bool - } - - // All active proxies must have reported the capability (no NULLs) and all - // must report true. A single unreported or false proxy means the cluster - // does not unanimously support the capability. - err := s.db.WithContext(ctx). - Model(&proxy.Proxy{}). - Select("COUNT(*) AS total, "+ - "COUNT(CASE WHEN "+column+" IS NOT NULL THEN 1 END) AS reported, "+ - "COUNT(*) > 0 AND COUNT(*) = COUNT(CASE WHEN "+column+" = true THEN 1 END) AS all_true"). - Where("cluster_address = ? AND status = ? AND last_seen > ?", - clusterAddr, "connected", time.Now().Add(-proxyActiveThreshold)). - Scan(&result).Error - if err != nil { - log.WithContext(ctx).Errorf("query cluster capability %s for %s: %v", column, clusterAddr, err) - return nil - } - - if result.Total == 0 || result.Reported == 0 { - return nil - } - - // If any proxy has not reported (NULL), we can't confirm unanimous support. - if result.Reported < result.Total { - v := false - return &v - } - - return &result.AllTrue -} - -// getClusterCapability returns an aggregated boolean capability for the given -// cluster. It checks active (connected, recently seen) proxies and returns: -// - *true if any proxy in the cluster has the capability set to true, -// - *false if at least one proxy reported but none set it to true, -// - nil if no proxy reported the capability at all. -func (s *SqlStore) getClusterCapability(ctx context.Context, clusterAddr, column string) *bool { - if _, ok := validCapabilityColumns[column]; !ok { - log.WithContext(ctx).Errorf("invalid capability column: %s", column) - return nil - } - - var result struct { - HasCapability bool - AnyTrue bool - } - - err := s.db. - WithContext(ctx). - Model(&proxy.Proxy{}). - Select("COUNT(CASE WHEN "+column+" IS NOT NULL THEN 1 END) > 0 AS has_capability, "+ - "COALESCE(MAX(CASE WHEN "+column+" = true THEN 1 ELSE 0 END), 0) = 1 AS any_true"). - Where("cluster_address = ? AND status = ? AND last_seen > ?", - clusterAddr, "connected", time.Now().Add(-proxyActiveThreshold)). - Scan(&result).Error - if err != nil { - log.WithContext(ctx).Errorf("query cluster capability %s for %s: %v", column, clusterAddr, err) - return nil - } - - if !result.HasCapability { - return nil - } - - return &result.AnyTrue -} - -// CleanupStaleProxies deletes proxies that haven't sent heartbeat in the specified duration -func (s *SqlStore) CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error { - cutoffTime := time.Now().Add(-inactivityDuration) - - result := s.db. - Where("last_seen < ?", cutoffTime). - Delete(&proxy.Proxy{}) - - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to cleanup stale proxies: %v", result.Error) - return status.Errorf(status.Internal, "failed to cleanup stale proxies") - } - - if result.RowsAffected > 0 { - log.WithContext(ctx).Infof("Cleaned up %d stale proxies", result.RowsAffected) - } - - return nil -} - -// GetRoutingPeerNetworks returns the distinct network names where the peer is assigned as a routing peer -// in an enabled network router, either directly or via peer groups. -func (s *SqlStore) GetRoutingPeerNetworks(_ context.Context, accountID, peerID string) ([]string, error) { - var routers []*routerTypes.NetworkRouter - if err := s.db.Select("peer, peer_groups, network_id").Where("account_id = ? AND enabled = true", accountID).Find(&routers).Error; err != nil { - return nil, status.Errorf(status.Internal, "failed to get enabled routers: %v", err) - } - - if len(routers) == 0 { - return nil, nil - } - - var groupPeers []types.GroupPeer - if err := s.db.Select("group_id").Where("account_id = ? AND peer_id = ?", accountID, peerID).Find(&groupPeers).Error; err != nil { - return nil, status.Errorf(status.Internal, "failed to get peer group memberships: %v", err) - } - - groupSet := make(map[string]struct{}, len(groupPeers)) - for _, gp := range groupPeers { - groupSet[gp.GroupID] = struct{}{} - } - - networkIDs := make(map[string]struct{}) - for _, r := range routers { - if r.Peer == peerID { - networkIDs[r.NetworkID] = struct{}{} - } else if r.Peer == "" { - for _, pg := range r.PeerGroups { - if _, ok := groupSet[pg]; ok { - networkIDs[r.NetworkID] = struct{}{} - break - } - } - } - } - - if len(networkIDs) == 0 { - return nil, nil - } - - ids := make([]string, 0, len(networkIDs)) - for id := range networkIDs { - ids = append(ids, id) - } - - var networks []*networkTypes.Network - if err := s.db.Select("name").Where("account_id = ? AND id IN ?", accountID, ids).Find(&networks).Error; err != nil { - return nil, status.Errorf(status.Internal, "failed to get networks: %v", err) - } - - names := make([]string, 0, len(networks)) - for _, n := range networks { - names = append(names, n.Name) - } - - return names, nil -} diff --git a/management/server/store/sql_store_access_log.go b/management/server/store/sql_store_access_log.go new file mode 100644 index 000000000..56092eb74 --- /dev/null +++ b/management/server/store/sql_store_access_log.go @@ -0,0 +1,149 @@ +package store + +import ( + "context" + "strings" + "time" + + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/shared/management/status" +) + +// CreateAccessLog creates a new access log entry in the database +func (s *SqlStore) CreateAccessLog(ctx context.Context, logEntry *accesslogs.AccessLogEntry) error { + result := s.db.Create(logEntry) + if result.Error != nil { + log.WithContext(ctx).WithFields(log.Fields{ + "service_id": logEntry.ServiceID, + "method": logEntry.Method, + "host": logEntry.Host, + "path": logEntry.Path, + }).Errorf("failed to create access log entry in store: %v", result.Error) + return status.Errorf(status.Internal, "failed to create access log entry in store") + } + return nil +} + +// GetAccountAccessLogs retrieves access logs for a given account with pagination and filtering +func (s *SqlStore) GetAccountAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) { + var logs []*accesslogs.AccessLogEntry + var totalCount int64 + + baseQuery := s.db. + Model(&accesslogs.AccessLogEntry{}). + Where(accountIDCondition, accountID) + + baseQuery = s.applyAccessLogFilters(baseQuery, filter) + + if err := baseQuery.Count(&totalCount).Error; err != nil { + log.WithContext(ctx).Errorf("failed to count access logs: %v", err) + return nil, 0, status.Errorf(status.Internal, "failed to count access logs") + } + + query := s.db. + Where(accountIDCondition, accountID) + + query = s.applyAccessLogFilters(query, filter) + + sortColumns := filter.GetSortColumn() + sortOrder := strings.ToUpper(filter.GetSortOrder()) + + var orderClauses []string + for _, col := range strings.Split(sortColumns, ",") { + col = strings.TrimSpace(col) + if col != "" { + orderClauses = append(orderClauses, col+" "+sortOrder) + } + } + orderClause := strings.Join(orderClauses, ", ") + + query = query. + Order(orderClause). + Limit(filter.GetLimit()). + Offset(filter.GetOffset()) + + if lockStrength != LockingStrengthNone { + query = query.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + result := query.Find(&logs) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get access logs from store: %v", result.Error) + return nil, 0, status.Errorf(status.Internal, "failed to get access logs from store") + } + + return logs, totalCount, nil +} + +// DeleteOldAccessLogs deletes all access logs older than the specified time +func (s *SqlStore) DeleteOldAccessLogs(ctx context.Context, olderThan time.Time) (int64, error) { + result := s.db. + Where("timestamp < ?", olderThan). + Delete(&accesslogs.AccessLogEntry{}) + + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete old access logs: %v", result.Error) + return 0, status.Errorf(status.Internal, "failed to delete old access logs") + } + + return result.RowsAffected, nil +} + +// applyAccessLogFilters applies filter conditions to the query +func (s *SqlStore) applyAccessLogFilters(query *gorm.DB, filter accesslogs.AccessLogFilter) *gorm.DB { + if filter.Search != nil { + searchPattern := "%" + *filter.Search + "%" + query = query.Where( + "id LIKE ? OR location_connection_ip LIKE ? OR host LIKE ? OR path LIKE ? OR CONCAT(host, path) LIKE ? OR user_id IN (SELECT id FROM users WHERE email LIKE ? OR name LIKE ?)", + searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, + ) + } + + if filter.SourceIP != nil { + query = query.Where("location_connection_ip = ?", *filter.SourceIP) + } + + if filter.Host != nil { + query = query.Where("host = ?", *filter.Host) + } + + if filter.Path != nil { + // Support LIKE pattern for path filtering + query = query.Where("path LIKE ?", "%"+*filter.Path+"%") + } + + if filter.UserID != nil { + query = query.Where("user_id = ?", *filter.UserID) + } + + if filter.Method != nil { + query = query.Where("method = ?", *filter.Method) + } + + if filter.Status != nil { + switch *filter.Status { + case "success": + query = query.Where("status_code >= ? AND status_code < ?", 200, 400) + case "failed": + query = query.Where("status_code < ? OR status_code >= ?", 200, 400) + } + } + + if filter.StatusCode != nil { + query = query.Where("status_code = ?", *filter.StatusCode) + } + + if filter.StartDate != nil { + query = query.Where("timestamp >= ?", *filter.StartDate) + } + + if filter.EndDate != nil { + query = query.Where("timestamp <= ?", *filter.EndDate) + } + + return query +} diff --git a/management/server/store/sql_store_account.go b/management/server/store/sql_store_account.go new file mode 100644 index 000000000..f4cb15a3c --- /dev/null +++ b/management/server/store/sql_store_account.go @@ -0,0 +1,1148 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net" + "runtime/debug" + "strings" + "sync" + "time" + + "github.com/jackc/pgx/v5" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + nbdns "github.com/netbirdio/netbird/dns" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/status" +) + +// Deprecated: Full +// account operations are no longer supported +func (s *SqlStore) SaveAccount(ctx context.Context, account *types.Account) error { + start := time.Now() + defer func() { + elapsed := time.Since(start) + if elapsed > 1*time.Second { + log.WithContext(ctx).Tracef("SaveAccount for account %s exceeded 1s, took: %v", account.Id, elapsed) + } + }() + + // todo: remove this check after the issue is resolved + s.checkAccountDomainBeforeSave(ctx, account.Id, account.Domain) + + generateAccountSQLTypes(account) + + // Encrypt sensitive user data before saving + for i := range account.UsersG { + if err := account.UsersG[i].EncryptSensitiveData(s.fieldEncrypt); err != nil { + return fmt.Errorf("encrypt user: %w", err) + } + } + + for _, group := range account.GroupsG { + group.StoreGroupPeers() + } + + err := s.transaction(func(tx *gorm.DB) error { + result := tx.Select(clause.Associations).Delete(account.Policies, "account_id = ?", account.Id) + if result.Error != nil { + return result.Error + } + + result = tx.Select(clause.Associations).Delete(account.UsersG, "account_id = ?", account.Id) + if result.Error != nil { + return result.Error + } + + result = tx.Select(clause.Associations).Delete(account) + if result.Error != nil { + return result.Error + } + + result = tx. + Session(&gorm.Session{FullSaveAssociations: true}). + Clauses(clause.OnConflict{UpdateAll: true}). + Create(account) + if result.Error != nil { + return result.Error + } + return nil + }) + + took := time.Since(start) + if s.metrics != nil { + s.metrics.StoreMetrics().CountPersistenceDuration(took) + } + log.WithContext(ctx).Debugf("took %d ms to persist an account to the store", took.Milliseconds()) + + return err +} + +// generateAccountSQLTypes generates the GORM compatible types for the account +func generateAccountSQLTypes(account *types.Account) { + for _, key := range account.SetupKeys { + account.SetupKeysG = append(account.SetupKeysG, *key) + } + + if len(account.SetupKeys) != len(account.SetupKeysG) { + log.Warnf("SetupKeysG length mismatch for account %s", account.Id) + } + + for id, peer := range account.Peers { + peer.ID = id + account.PeersG = append(account.PeersG, *peer) + } + + for id, user := range account.Users { + user.Id = id + for id, pat := range user.PATs { + pat.ID = id + user.PATsG = append(user.PATsG, *pat) + } + account.UsersG = append(account.UsersG, *user) + } + + for id, group := range account.Groups { + group.ID = id + group.AccountID = account.Id + account.GroupsG = append(account.GroupsG, group) + } + + for id, route := range account.Routes { + route.ID = id + account.RoutesG = append(account.RoutesG, *route) + } + + for id, ns := range account.NameServerGroups { + ns.ID = id + account.NameServerGroupsG = append(account.NameServerGroupsG, *ns) + } +} + +// checkAccountDomainBeforeSave temporary method to troubleshoot an issue with domains getting blank +func (s *SqlStore) checkAccountDomainBeforeSave(ctx context.Context, accountID, newDomain string) { + var acc types.Account + var domain string + result := s.db.Model(&acc).Select("domain").Where(idQueryCondition, accountID).Take(&domain) + if result.Error != nil { + if !errors.Is(result.Error, gorm.ErrRecordNotFound) { + log.WithContext(ctx).Errorf("error when getting account %s from the store to check domain: %s", accountID, result.Error) + } + return + } + if domain != "" && newDomain == "" { + log.WithContext(ctx).Warnf("saving an account with empty domain when there was a domain set. Previous domain %s, Account ID: %s, Trace: %s", domain, accountID, debug.Stack()) + } +} + +func (s *SqlStore) DeleteAccount(ctx context.Context, account *types.Account) error { + start := time.Now() + + err := s.transaction(func(tx *gorm.DB) error { + result := tx.Select(clause.Associations).Delete(account.Policies, "account_id = ?", account.Id) + if result.Error != nil { + return result.Error + } + + result = tx.Select(clause.Associations).Delete(account.UsersG, "account_id = ?", account.Id) + if result.Error != nil { + return result.Error + } + + result = tx.Select(clause.Associations).Delete(account.Services, "account_id = ?", account.Id) + if result.Error != nil { + return result.Error + } + + result = tx.Select(clause.Associations).Delete(account) + if result.Error != nil { + return result.Error + } + + return nil + }) + + took := time.Since(start) + if s.metrics != nil { + s.metrics.StoreMetrics().CountPersistenceDuration(took) + } + log.WithContext(ctx).Tracef("took %d ms to delete an account to the store", took.Milliseconds()) + + return err +} + +func (s *SqlStore) UpdateAccountDomainAttributes(ctx context.Context, accountID string, domain string, category string, isPrimaryDomain bool) error { + accountCopy := types.Account{ + Domain: domain, + DomainCategory: category, + IsDomainPrimaryAccount: isPrimaryDomain, + } + + fieldsToUpdate := []string{"domain", "domain_category", "is_domain_primary_account"} + result := s.db.Model(&types.Account{}). + Select(fieldsToUpdate). + Where(idQueryCondition, accountID). + Updates(&accountCopy) + if result.Error != nil { + return status.Errorf(status.Internal, "failed to update account domain attributes to store: %v", result.Error) + } + + if result.RowsAffected == 0 { + return status.Errorf(status.NotFound, "account %s", accountID) + } + + return nil +} + +func (s *SqlStore) GetAccountByPrivateDomain(ctx context.Context, domain string) (*types.Account, error) { + accountID, err := s.GetAccountIDByPrivateDomain(ctx, LockingStrengthNone, domain) + if err != nil { + return nil, err + } + + // TODO: rework to not call GetAccount + return s.GetAccount(ctx, accountID) +} + +func (s *SqlStore) GetAccountIDByPrivateDomain(ctx context.Context, lockStrength LockingStrength, domain string) (string, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var accountID string + result := tx.Model(&types.Account{}).Select("id"). + Where("domain = ? and is_domain_primary_account = ? and domain_category = ?", + strings.ToLower(domain), true, types.PrivateCategory, + ).Take(&accountID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return "", status.Errorf(status.NotFound, "account not found: provided domain is not registered or is not private") + } + log.WithContext(ctx).Errorf("error when getting account from the store: %s", result.Error) + return "", status.NewGetAccountFromStoreError(result.Error) + } + + return accountID, nil +} + +func (s *SqlStore) GetAccountsCounter(ctx context.Context) (int64, error) { + var count int64 + result := s.db.Model(&types.Account{}).Count(&count) + if result.Error != nil { + return 0, fmt.Errorf("failed to get all accounts counter: %w", result.Error) + } + + return count, nil +} + +func (s *SqlStore) GetAllAccounts(ctx context.Context) (all []*types.Account) { + var accounts []types.Account + result := s.db.Find(&accounts) + if result.Error != nil { + return all + } + + for _, account := range accounts { + if acc, err := s.GetAccount(ctx, account.Id); err == nil { + all = append(all, acc) + } + } + + return all +} + +func (s *SqlStore) GetAccountMeta(ctx context.Context, lockStrength LockingStrength, accountID string) (*types.AccountMeta, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var accountMeta types.AccountMeta + result := tx.Model(&types.Account{}). + Take(&accountMeta, idQueryCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("error when getting account meta %s from the store: %s", accountID, result.Error) + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewAccountNotFoundError(accountID) + } + return nil, status.NewGetAccountFromStoreError(result.Error) + } + + return &accountMeta, nil +} + +func (s *SqlStore) GetAccount(ctx context.Context, accountID string) (*types.Account, error) { + if s.pool != nil { + return s.getAccountPgx(ctx, accountID) + } + return s.getAccountGorm(ctx, accountID) +} + +func (s *SqlStore) getAccountGorm(ctx context.Context, accountID string) (*types.Account, error) { + start := time.Now() + defer func() { + elapsed := time.Since(start) + if elapsed > 1*time.Second { + log.WithContext(ctx).Tracef("GetAccount for account %s exceeded 1s, took: %v", accountID, elapsed) + } + }() + + var account types.Account + result := s.db.Model(&account). + Preload("UsersG.PATsG"). // have to be specified as this is nested reference + Preload("Policies.Rules"). + Preload("SetupKeysG"). + Preload("PeersG"). + Preload("UsersG"). + Preload("GroupsG.GroupPeers"). + Preload("RoutesG"). + Preload("NameServerGroupsG"). + Preload("PostureChecks"). + Preload("Networks"). + Preload("NetworkRouters"). + Preload("NetworkResources"). + Preload("Onboarding"). + Preload("Services.Targets"). + Preload("Domains"). + Take(&account, idQueryCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("error when getting account %s from the store: %s", accountID, result.Error) + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewAccountNotFoundError(accountID) + } + return nil, status.NewGetAccountFromStoreError(result.Error) + } + + account.SetupKeys = make(map[string]*types.SetupKey, len(account.SetupKeysG)) + for _, key := range account.SetupKeysG { + if key.UpdatedAt.IsZero() { + key.UpdatedAt = key.CreatedAt + } + if key.AutoGroups == nil { + key.AutoGroups = []string{} + } + account.SetupKeys[key.Key] = &key + } + account.SetupKeysG = nil + + account.Peers = make(map[string]*nbpeer.Peer, len(account.PeersG)) + for _, peer := range account.PeersG { + account.Peers[peer.ID] = &peer + } + account.PeersG = nil + account.Users = make(map[string]*types.User, len(account.UsersG)) + for _, user := range account.UsersG { + user.PATs = make(map[string]*types.PersonalAccessToken, len(user.PATs)) + for _, pat := range user.PATsG { + pat.UserID = "" + user.PATs[pat.ID] = &pat + } + if user.AutoGroups == nil { + user.AutoGroups = []string{} + } + if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt user: %w", err) + } + account.Users[user.Id] = &user + user.PATsG = nil + } + account.UsersG = nil + account.Groups = make(map[string]*types.Group, len(account.GroupsG)) + for _, group := range account.GroupsG { + group.Peers = make([]string, len(group.GroupPeers)) + for i, gp := range group.GroupPeers { + group.Peers[i] = gp.PeerID + } + if group.Resources == nil { + group.Resources = []types.Resource{} + } + account.Groups[group.ID] = group + } + account.GroupsG = nil + + account.Routes = make(map[route.ID]*route.Route, len(account.RoutesG)) + for _, route := range account.RoutesG { + account.Routes[route.ID] = &route + } + account.RoutesG = nil + account.NameServerGroups = make(map[string]*nbdns.NameServerGroup, len(account.NameServerGroupsG)) + for _, ns := range account.NameServerGroupsG { + ns.AccountID = "" + if ns.NameServers == nil { + ns.NameServers = []nbdns.NameServer{} + } + if ns.Groups == nil { + ns.Groups = []string{} + } + if ns.Domains == nil { + ns.Domains = []string{} + } + account.NameServerGroups[ns.ID] = &ns + } + account.NameServerGroupsG = nil + return &account, nil +} + +func (s *SqlStore) getAccountPgx(ctx context.Context, accountID string) (*types.Account, error) { + account, err := s.getAccount(ctx, accountID) + if err != nil { + return nil, err + } + + var wg sync.WaitGroup + errChan := make(chan error, 16) + + wg.Add(1) + go func() { + defer wg.Done() + keys, err := s.getSetupKeys(ctx, accountID) + if err != nil { + errChan <- err + return + } + account.SetupKeysG = keys + }() + + wg.Add(1) + go func() { + defer wg.Done() + peers, err := s.getPeers(ctx, accountID) + if err != nil { + errChan <- err + return + } + account.PeersG = peers + }() + + wg.Add(1) + go func() { + defer wg.Done() + users, err := s.getUsers(ctx, accountID) + if err != nil { + errChan <- err + return + } + account.UsersG = users + }() + + wg.Add(1) + go func() { + defer wg.Done() + groups, err := s.getGroups(ctx, accountID) + if err != nil { + errChan <- err + return + } + account.GroupsG = groups + }() + + wg.Add(1) + go func() { + defer wg.Done() + policies, err := s.getPolicies(ctx, accountID) + if err != nil { + errChan <- err + return + } + account.Policies = policies + }() + + wg.Add(1) + go func() { + defer wg.Done() + routes, err := s.getRoutes(ctx, accountID) + if err != nil { + errChan <- err + return + } + account.RoutesG = routes + }() + + wg.Add(1) + go func() { + defer wg.Done() + nsgs, err := s.getNameServerGroups(ctx, accountID) + if err != nil { + errChan <- err + return + } + account.NameServerGroupsG = nsgs + }() + + wg.Add(1) + go func() { + defer wg.Done() + checks, err := s.getPostureChecks(ctx, accountID) + if err != nil { + errChan <- err + return + } + account.PostureChecks = checks + }() + + wg.Add(1) + go func() { + defer wg.Done() + services, err := s.getServices(ctx, accountID) + if err != nil { + errChan <- err + return + } + account.Services = services + }() + + wg.Add(1) + go func() { + defer wg.Done() + domains, err := s.ListCustomDomains(ctx, accountID) + if err != nil { + errChan <- err + return + } + account.Domains = domains + }() + + wg.Add(1) + go func() { + defer wg.Done() + networks, err := s.getNetworks(ctx, accountID) + if err != nil { + errChan <- err + return + } + account.Networks = networks + }() + + wg.Add(1) + go func() { + defer wg.Done() + routers, err := s.getNetworkRouters(ctx, accountID) + if err != nil { + errChan <- err + return + } + account.NetworkRouters = routers + }() + + wg.Add(1) + go func() { + defer wg.Done() + resources, err := s.getNetworkResources(ctx, accountID) + if err != nil { + errChan <- err + return + } + account.NetworkResources = resources + }() + + wg.Add(1) + go func() { + defer wg.Done() + err := s.getAccountOnboarding(ctx, accountID, account) + if err != nil { + errChan <- err + return + } + }() + + wg.Wait() + close(errChan) + for e := range errChan { + if e != nil { + return nil, e + } + } + + var userIDs []string + for _, u := range account.UsersG { + userIDs = append(userIDs, u.Id) + } + var policyIDs []string + for _, p := range account.Policies { + policyIDs = append(policyIDs, p.ID) + } + var groupIDs []string + for _, g := range account.GroupsG { + groupIDs = append(groupIDs, g.ID) + } + + wg.Add(3) + errChan = make(chan error, 3) + + var pats []types.PersonalAccessToken + go func() { + defer wg.Done() + var err error + pats, err = s.getPersonalAccessTokens(ctx, userIDs) + if err != nil { + errChan <- err + } + }() + + var rules []*types.PolicyRule + go func() { + defer wg.Done() + var err error + rules, err = s.getPolicyRules(ctx, policyIDs) + if err != nil { + errChan <- err + } + }() + + var groupPeers []types.GroupPeer + go func() { + defer wg.Done() + var err error + groupPeers, err = s.getGroupPeers(ctx, groupIDs) + if err != nil { + errChan <- err + } + }() + + wg.Wait() + close(errChan) + for e := range errChan { + if e != nil { + return nil, e + } + } + + patsByUserID := make(map[string][]*types.PersonalAccessToken) + for i := range pats { + pat := &pats[i] + patsByUserID[pat.UserID] = append(patsByUserID[pat.UserID], pat) + pat.UserID = "" + } + + rulesByPolicyID := make(map[string][]*types.PolicyRule) + for _, rule := range rules { + rulesByPolicyID[rule.PolicyID] = append(rulesByPolicyID[rule.PolicyID], rule) + } + + peersByGroupID := make(map[string][]string) + for _, gp := range groupPeers { + peersByGroupID[gp.GroupID] = append(peersByGroupID[gp.GroupID], gp.PeerID) + } + + account.SetupKeys = make(map[string]*types.SetupKey, len(account.SetupKeysG)) + for i := range account.SetupKeysG { + key := &account.SetupKeysG[i] + account.SetupKeys[key.Key] = key + } + + account.Peers = make(map[string]*nbpeer.Peer, len(account.PeersG)) + for i := range account.PeersG { + peer := &account.PeersG[i] + account.Peers[peer.ID] = peer + } + + account.Users = make(map[string]*types.User, len(account.UsersG)) + for i := range account.UsersG { + user := &account.UsersG[i] + if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt user: %w", err) + } + user.PATs = make(map[string]*types.PersonalAccessToken) + if userPats, ok := patsByUserID[user.Id]; ok { + for j := range userPats { + pat := userPats[j] + user.PATs[pat.ID] = pat + } + } + account.Users[user.Id] = user + } + + for i := range account.Policies { + policy := account.Policies[i] + if policyRules, ok := rulesByPolicyID[policy.ID]; ok { + policy.Rules = policyRules + } + } + + account.Groups = make(map[string]*types.Group, len(account.GroupsG)) + for i := range account.GroupsG { + group := account.GroupsG[i] + if peerIDs, ok := peersByGroupID[group.ID]; ok { + group.Peers = peerIDs + } + account.Groups[group.ID] = group + } + + account.Routes = make(map[route.ID]*route.Route, len(account.RoutesG)) + for i := range account.RoutesG { + route := &account.RoutesG[i] + account.Routes[route.ID] = route + } + + account.NameServerGroups = make(map[string]*nbdns.NameServerGroup, len(account.NameServerGroupsG)) + for i := range account.NameServerGroupsG { + nsg := &account.NameServerGroupsG[i] + nsg.AccountID = "" + account.NameServerGroups[nsg.ID] = nsg + } + + account.SetupKeysG = nil + account.PeersG = nil + account.UsersG = nil + account.GroupsG = nil + account.RoutesG = nil + account.NameServerGroupsG = nil + + return account, nil +} + +func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Account, error) { + var account types.Account + account.Network = &types.Network{} + const accountQuery = ` + SELECT + id, created_by, created_at, domain, domain_category, is_domain_primary_account, + -- Embedded Network + network_identifier, network_net, network_net_v6, network_dns, network_serial, + -- Embedded DNSSettings + dns_settings_disabled_management_groups, + -- Embedded Settings + settings_peer_login_expiration_enabled, settings_peer_login_expiration, + settings_peer_inactivity_expiration_enabled, settings_peer_inactivity_expiration, + settings_regular_users_view_blocked, settings_groups_propagation_enabled, + settings_jwt_groups_enabled, settings_jwt_groups_claim_name, settings_jwt_allow_groups, + settings_routing_peer_dns_resolution_enabled, settings_dns_domain, settings_network_range, + settings_network_range_v6, settings_ipv6_enabled_groups, settings_lazy_connection_enabled, + settings_local_mfa_enabled, settings_metrics_push_enabled, settings_agent_network_only, + settings_dashboard_features, settings_auto_update_version, settings_auto_update_always, + settings_peer_expose_enabled, settings_peer_expose_groups, + -- Embedded ExtraSettings + settings_extra_peer_approval_enabled, settings_extra_user_approval_required, + settings_extra_integrated_validator, settings_extra_integrated_validator_groups + FROM accounts WHERE id = $1` + + var ( + sPeerLoginExpirationEnabled sql.NullBool + sPeerLoginExpiration sql.NullInt64 + sPeerInactivityExpirationEnabled sql.NullBool + sPeerInactivityExpiration sql.NullInt64 + sRegularUsersViewBlocked sql.NullBool + sGroupsPropagationEnabled sql.NullBool + sJWTGroupsEnabled sql.NullBool + sJWTGroupsClaimName sql.NullString + sJWTAllowGroups sql.NullString + sRoutingPeerDNSResolutionEnabled sql.NullBool + sDNSDomain sql.NullString + sNetworkRange sql.NullString + sNetworkRangeV6 sql.NullString + sIPv6EnabledGroups sql.NullString + sLazyConnectionEnabled sql.NullBool + sLocalMFAEnabled sql.NullBool + sMetricsPushEnabled sql.NullBool + sAgentNetworkOnly sql.NullBool + sDashboardFeatures sql.NullString + autoUpdateVersion sql.NullString + autoUpdateAlways sql.NullBool + peerExposeEnabled sql.NullBool + peerExposeGroups sql.NullString + sExtraPeerApprovalEnabled sql.NullBool + sExtraUserApprovalRequired sql.NullBool + sExtraIntegratedValidator sql.NullString + sExtraIntegratedValidatorGroups sql.NullString + networkNet sql.NullString + networkNetV6 sql.NullString + dnsSettingsDisabledGroups sql.NullString + networkIdentifier sql.NullString + networkDns sql.NullString + networkSerial sql.NullInt64 + createdAt sql.NullTime + ) + err := s.pool.QueryRow(ctx, accountQuery, accountID).Scan( + &account.Id, &account.CreatedBy, &createdAt, &account.Domain, &account.DomainCategory, &account.IsDomainPrimaryAccount, + &networkIdentifier, &networkNet, &networkNetV6, &networkDns, &networkSerial, + &dnsSettingsDisabledGroups, + &sPeerLoginExpirationEnabled, &sPeerLoginExpiration, + &sPeerInactivityExpirationEnabled, &sPeerInactivityExpiration, + &sRegularUsersViewBlocked, &sGroupsPropagationEnabled, + &sJWTGroupsEnabled, &sJWTGroupsClaimName, &sJWTAllowGroups, + &sRoutingPeerDNSResolutionEnabled, &sDNSDomain, &sNetworkRange, + &sNetworkRangeV6, &sIPv6EnabledGroups, &sLazyConnectionEnabled, + &sLocalMFAEnabled, &sMetricsPushEnabled, &sAgentNetworkOnly, + &sDashboardFeatures, &autoUpdateVersion, &autoUpdateAlways, + &peerExposeEnabled, &peerExposeGroups, + &sExtraPeerApprovalEnabled, &sExtraUserApprovalRequired, + &sExtraIntegratedValidator, &sExtraIntegratedValidatorGroups, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, status.NewAccountNotFoundError(accountID) + } + return nil, status.NewGetAccountFromStoreError(err) + } + + account.Settings = &types.Settings{Extra: &types.ExtraSettings{}} + if networkNet.Valid { + _ = json.Unmarshal([]byte(networkNet.String), &account.Network.Net) + } + if createdAt.Valid { + account.CreatedAt = createdAt.Time + } + if dnsSettingsDisabledGroups.Valid { + _ = json.Unmarshal([]byte(dnsSettingsDisabledGroups.String), &account.DNSSettings.DisabledManagementGroups) + } + if networkIdentifier.Valid { + account.Network.Identifier = networkIdentifier.String + } + if networkDns.Valid { + account.Network.Dns = networkDns.String + } + if networkSerial.Valid { + account.Network.Serial = uint64(networkSerial.Int64) + } + if sPeerLoginExpirationEnabled.Valid { + account.Settings.PeerLoginExpirationEnabled = sPeerLoginExpirationEnabled.Bool + } + if sPeerLoginExpiration.Valid { + account.Settings.PeerLoginExpiration = time.Duration(sPeerLoginExpiration.Int64) + } + if sPeerInactivityExpirationEnabled.Valid { + account.Settings.PeerInactivityExpirationEnabled = sPeerInactivityExpirationEnabled.Bool + } + if sPeerInactivityExpiration.Valid { + account.Settings.PeerInactivityExpiration = time.Duration(sPeerInactivityExpiration.Int64) + } + if sRegularUsersViewBlocked.Valid { + account.Settings.RegularUsersViewBlocked = sRegularUsersViewBlocked.Bool + } + if sGroupsPropagationEnabled.Valid { + account.Settings.GroupsPropagationEnabled = sGroupsPropagationEnabled.Bool + } + if sJWTGroupsEnabled.Valid { + account.Settings.JWTGroupsEnabled = sJWTGroupsEnabled.Bool + } + if sJWTGroupsClaimName.Valid { + account.Settings.JWTGroupsClaimName = sJWTGroupsClaimName.String + } + if sRoutingPeerDNSResolutionEnabled.Valid { + account.Settings.RoutingPeerDNSResolutionEnabled = sRoutingPeerDNSResolutionEnabled.Bool + } + if sDNSDomain.Valid { + account.Settings.DNSDomain = sDNSDomain.String + } + if sLazyConnectionEnabled.Valid { + account.Settings.LazyConnectionEnabled = sLazyConnectionEnabled.Bool + } + if sLocalMFAEnabled.Valid { + account.Settings.LocalMfaEnabled = sLocalMFAEnabled.Bool + } + if sMetricsPushEnabled.Valid { + account.Settings.MetricsPushEnabled = sMetricsPushEnabled.Bool + } + if sAgentNetworkOnly.Valid { + account.Settings.AgentNetworkOnly = sAgentNetworkOnly.Bool + } + if sDashboardFeatures.Valid && sDashboardFeatures.String != "" { + if err := json.Unmarshal([]byte(sDashboardFeatures.String), &account.Settings.DashboardFeatures); err != nil { + log.WithContext(ctx).Warnf("failed to unmarshal dashboard features for account %s: %v", accountID, err) + } + } + if sJWTAllowGroups.Valid { + _ = json.Unmarshal([]byte(sJWTAllowGroups.String), &account.Settings.JWTAllowGroups) + } + if sNetworkRange.Valid { + _ = json.Unmarshal([]byte(sNetworkRange.String), &account.Settings.NetworkRange) + } + if networkNetV6.Valid { + _ = json.Unmarshal([]byte(networkNetV6.String), &account.Network.NetV6) + } + if sNetworkRangeV6.Valid { + _ = json.Unmarshal([]byte(sNetworkRangeV6.String), &account.Settings.NetworkRangeV6) + } + if sIPv6EnabledGroups.Valid { + _ = json.Unmarshal([]byte(sIPv6EnabledGroups.String), &account.Settings.IPv6EnabledGroups) + } + if autoUpdateAlways.Valid { + account.Settings.AutoUpdateAlways = autoUpdateAlways.Bool + } + if autoUpdateVersion.Valid { + account.Settings.AutoUpdateVersion = autoUpdateVersion.String + } + if peerExposeEnabled.Valid { + account.Settings.PeerExposeEnabled = peerExposeEnabled.Bool + } + if peerExposeGroups.Valid { + _ = json.Unmarshal([]byte(peerExposeGroups.String), &account.Settings.PeerExposeGroups) + } + + if sExtraPeerApprovalEnabled.Valid { + account.Settings.Extra.PeerApprovalEnabled = sExtraPeerApprovalEnabled.Bool + } + if sExtraUserApprovalRequired.Valid { + account.Settings.Extra.UserApprovalRequired = sExtraUserApprovalRequired.Bool + } + if sExtraIntegratedValidator.Valid { + account.Settings.Extra.IntegratedValidator = sExtraIntegratedValidator.String + } + if sExtraIntegratedValidatorGroups.Valid { + _ = json.Unmarshal([]byte(sExtraIntegratedValidatorGroups.String), &account.Settings.Extra.IntegratedValidatorGroups) + } + return &account, nil +} + +func (s *SqlStore) GetAnyAccountID(ctx context.Context) (string, error) { + var account types.Account + result := s.db.Select("id").Order("created_at desc").Limit(1).Find(&account) + if result.Error != nil { + return "", status.NewGetAccountFromStoreError(result.Error) + } + if result.RowsAffected == 0 { + return "", status.Errorf(status.NotFound, "account not found: index lookup failed") + } + + return account.Id, nil +} + +func (s *SqlStore) GetAccountNetwork(ctx context.Context, lockStrength LockingStrength, accountID string) (*types.Network, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var accountNetwork types.AccountNetwork + if err := tx.Model(&types.Account{}).Where(idQueryCondition, accountID).Take(&accountNetwork).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, status.NewAccountNotFoundError(accountID) + } + return nil, status.Errorf(status.Internal, "issue getting network from store: %s", err) + } + return accountNetwork.Network, nil +} + +func (s *SqlStore) GetAccountSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*types.Settings, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var accountSettings types.AccountSettings + if err := tx.Model(&types.Account{}).Where(idQueryCondition, accountID).Take(&accountSettings).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "settings not found") + } + return nil, status.Errorf(status.Internal, "issue getting settings from store: %s", err) + } + return accountSettings.Settings, nil +} + +func (s *SqlStore) GetAccountCreatedBy(ctx context.Context, lockStrength LockingStrength, accountID string) (string, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var createdBy string + result := tx.Model(&types.Account{}). + Select("created_by").Take(&createdBy, idQueryCondition, accountID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return "", status.NewAccountNotFoundError(accountID) + } + return "", status.NewGetAccountFromStoreError(result.Error) + } + + return createdBy, nil +} + +func (s *SqlStore) IncrementNetworkSerial(ctx context.Context, accountId string) error { + result := s.db.Model(&types.Account{}).Where(idQueryCondition, accountId).Update("network_serial", gorm.Expr("network_serial + 1")) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to increment network serial count in store: %v", result.Error) + return status.Errorf(status.Internal, "failed to increment network serial count in store") + } + return nil +} + +func (s *SqlStore) GetAccountDNSSettings(ctx context.Context, lockStrength LockingStrength, accountID string) (*types.DNSSettings, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var accountDNSSettings types.AccountDNSSettings + result := tx.Model(&types.Account{}). + Take(&accountDNSSettings, idQueryCondition, accountID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewAccountNotFoundError(accountID) + } + log.WithContext(ctx).Errorf("failed to get dns settings from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get dns settings from store") + } + return &accountDNSSettings.DNSSettings, nil +} + +// AccountExists checks whether an account exists by the given ID. +func (s *SqlStore) AccountExists(ctx context.Context, lockStrength LockingStrength, id string) (bool, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var accountID string + result := tx.Model(&types.Account{}). + Select("id").Take(&accountID, idQueryCondition, id) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return false, nil + } + return false, result.Error + } + + return accountID != "", nil +} + +// GetAccountDomainAndCategory retrieves the Domain and DomainCategory fields for an account based on the given accountID. +func (s *SqlStore) GetAccountDomainAndCategory(ctx context.Context, lockStrength LockingStrength, accountID string) (string, string, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var account types.Account + result := tx.Model(&types.Account{}).Select("domain", "domain_category"). + Where(idQueryCondition, accountID).Take(&account) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return "", "", status.Errorf(status.NotFound, "account not found") + } + return "", "", status.Errorf(status.Internal, "failed to get domain category from store: %v", result.Error) + } + + return account.Domain, account.DomainCategory, nil +} + +// SaveDNSSettings saves the DNS settings to the store. +func (s *SqlStore) SaveDNSSettings(ctx context.Context, accountID string, settings *types.DNSSettings) error { + result := s.db.Model(&types.Account{}). + Where(idQueryCondition, accountID).Updates(&types.AccountDNSSettings{DNSSettings: *settings}) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save dns settings to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to save dns settings to store") + } + + if result.RowsAffected == 0 { + return status.NewAccountNotFoundError(accountID) + } + + return nil +} + +// SaveAccountSettings stores the account settings in DB. +func (s *SqlStore) SaveAccountSettings(ctx context.Context, accountID string, settings *types.Settings) error { + result := s.db.Model(&types.Account{}). + Select("*").Where(idQueryCondition, accountID).Updates(&types.AccountSettings{Settings: settings}) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save account settings to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to save account settings to store") + } + + // MySQL reports RowsAffected=0 for no-op updates where values don't change, + // unlike SQLite/Postgres which report matched rows. Skip the check since the + // caller (UpdateAccountSettings) already verified the account exists via + // GetAccountSettings with LockingStrengthUpdate. + + return nil +} + +func (s *SqlStore) CountAccountsByPrivateDomain(ctx context.Context, domain string) (int64, error) { + var count int64 + result := s.db.Model(&types.Account{}). + Where("domain = ? AND domain_category = ?", + strings.ToLower(domain), types.PrivateCategory, + ).Count(&count) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to count accounts by private domain %s: %s", domain, result.Error) + return 0, status.Errorf(status.Internal, "failed to count accounts by private domain") + } + + return count, nil +} + +func (s *SqlStore) IsPrimaryAccount(ctx context.Context, accountID string) (bool, string, error) { + var info types.PrimaryAccountInfo + result := s.db.Model(&types.Account{}). + Select("is_domain_primary_account, domain"). + Where(idQueryCondition, accountID). + Take(&info) + + if result.Error != nil { + return false, "", status.Errorf(status.Internal, "failed to get account info: %v", result.Error) + } + + return info.IsDomainPrimaryAccount, info.Domain, nil +} + +func (s *SqlStore) MarkAccountPrimary(ctx context.Context, accountID string) error { + result := s.db.Model(&types.Account{}). + Where(idQueryCondition, accountID). + Update("is_domain_primary_account", true) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to mark account as primary: %s", result.Error) + return status.Errorf(status.Internal, "failed to mark account as primary") + } + + if result.RowsAffected == 0 { + return status.NewAccountNotFoundError(accountID) + } + + return nil +} + +type accountNetworkPatch struct { + Network *types.Network `gorm:"embedded;embeddedPrefix:network_"` +} + +func (s *SqlStore) UpdateAccountNetwork(ctx context.Context, accountID string, ipNet net.IPNet) error { + patch := accountNetworkPatch{ + Network: &types.Network{Net: ipNet}, + } + + result := s.db. + Model(&types.Account{}). + Where(idQueryCondition, accountID). + Updates(&patch) + + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to update account network: %v", result.Error) + return status.Errorf(status.Internal, "failed to update account network") + } + if result.RowsAffected == 0 { + return status.NewAccountNotFoundError(accountID) + } + return nil +} + +// UpdateAccountNetworkV6 updates the IPv6 network range for the account. +func (s *SqlStore) UpdateAccountNetworkV6(ctx context.Context, accountID string, ipNet net.IPNet) error { + patch := accountNetworkPatch{ + Network: &types.Network{NetV6: ipNet}, + } + + result := s.db. + Model(&types.Account{}). + Where(idQueryCondition, accountID). + Updates(&patch) + + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to update account network v6: %v", result.Error) + return status.Errorf(status.Internal, "update account network v6") + } + if result.RowsAffected == 0 { + return status.NewAccountNotFoundError(accountID) + } + return nil +} diff --git a/management/server/store/sql_store_account_onboarding.go b/management/server/store/sql_store_account_onboarding.go new file mode 100644 index 000000000..5872ea63e --- /dev/null +++ b/management/server/store/sql_store_account_onboarding.go @@ -0,0 +1,70 @@ +package store + +import ( + "context" + "database/sql" + "errors" + + "github.com/jackc/pgx/v5" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +// GetAccountOnboarding retrieves the onboarding information for a specific account. +func (s *SqlStore) GetAccountOnboarding(ctx context.Context, accountID string) (*types.AccountOnboarding, error) { + var accountOnboarding types.AccountOnboarding + result := s.db.Model(&accountOnboarding).Take(&accountOnboarding, accountIDCondition, accountID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewAccountOnboardingNotFoundError(accountID) + } + log.WithContext(ctx).Errorf("error when getting account onboarding %s from the store: %s", accountID, result.Error) + return nil, status.NewGetAccountFromStoreError(result.Error) + } + + return &accountOnboarding, nil +} + +// SaveAccountOnboarding updates the onboarding information for a specific account. +func (s *SqlStore) SaveAccountOnboarding(ctx context.Context, onboarding *types.AccountOnboarding) error { + result := s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(onboarding) + if result.Error != nil { + log.WithContext(ctx).Errorf("error when saving account onboarding %s in the store: %s", onboarding.AccountID, result.Error) + return status.Errorf(status.Internal, "error when saving account onboarding %s in the store: %s", onboarding.AccountID, result.Error) + } + + return nil +} + +func (s *SqlStore) getAccountOnboarding(ctx context.Context, accountID string, account *types.Account) error { + const query = `SELECT account_id, onboarding_flow_pending, signup_form_pending, created_at, updated_at FROM account_onboardings WHERE account_id = $1` + var onboardingFlowPending, signupFormPending sql.NullBool + var createdAt, updatedAt sql.NullTime + err := s.pool.QueryRow(ctx, query, accountID).Scan( + &account.Onboarding.AccountID, + &onboardingFlowPending, + &signupFormPending, + &createdAt, + &updatedAt, + ) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + if createdAt.Valid { + account.Onboarding.CreatedAt = createdAt.Time + } + if updatedAt.Valid { + account.Onboarding.UpdatedAt = updatedAt.Time + } + if onboardingFlowPending.Valid { + account.Onboarding.OnboardingFlowPending = onboardingFlowPending.Bool + } + if signupFormPending.Valid { + account.Onboarding.SignupFormPending = signupFormPending.Bool + } + return nil +} diff --git a/management/server/store/sql_store_account_onboarding_test.go b/management/server/store/sql_store_account_onboarding_test.go new file mode 100644 index 000000000..9531cd892 --- /dev/null +++ b/management/server/store/sql_store_account_onboarding_test.go @@ -0,0 +1,68 @@ +package store + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/types" +) + +func TestSqlStore_GetAccountOnboarding(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "9439-34653001fc3b-bf1c8084-ba50-4ce7" + a, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + t.Logf("Onboarding: %+v", a.Onboarding) + err = store.SaveAccount(context.Background(), a) + require.NoError(t, err) + onboarding, err := store.GetAccountOnboarding(context.Background(), accountID) + require.NoError(t, err) + require.NotNil(t, onboarding) + require.Equal(t, accountID, onboarding.AccountID) + require.Equal(t, time.Date(2024, time.October, 2, 14, 1, 38, 210000000, time.UTC), onboarding.CreatedAt.UTC()) +} + +func TestSqlStore_SaveAccountOnboarding(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + t.Run("New onboarding should be saved correctly", func(t *testing.T) { + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + onboarding := &types.AccountOnboarding{ + AccountID: accountID, + SignupFormPending: true, + OnboardingFlowPending: true, + } + + err = store.SaveAccountOnboarding(context.Background(), onboarding) + require.NoError(t, err) + + savedOnboarding, err := store.GetAccountOnboarding(context.Background(), accountID) + require.NoError(t, err) + require.Equal(t, onboarding.SignupFormPending, savedOnboarding.SignupFormPending) + require.Equal(t, onboarding.OnboardingFlowPending, savedOnboarding.OnboardingFlowPending) + }) + + t.Run("Existing onboarding should be updated correctly", func(t *testing.T) { + accountID := "9439-34653001fc3b-bf1c8084-ba50-4ce7" + onboarding, err := store.GetAccountOnboarding(context.Background(), accountID) + require.NoError(t, err) + + onboarding.OnboardingFlowPending = !onboarding.OnboardingFlowPending + onboarding.SignupFormPending = !onboarding.SignupFormPending + + err = store.SaveAccountOnboarding(context.Background(), onboarding) + require.NoError(t, err) + + savedOnboarding, err := store.GetAccountOnboarding(context.Background(), accountID) + require.NoError(t, err) + require.Equal(t, onboarding.SignupFormPending, savedOnboarding.SignupFormPending) + require.Equal(t, onboarding.OnboardingFlowPending, savedOnboarding.OnboardingFlowPending) + }) +} diff --git a/management/server/store/sql_store_account_test.go b/management/server/store/sql_store_account_test.go new file mode 100644 index 000000000..4c3b3f5fd --- /dev/null +++ b/management/server/store/sql_store_account_test.go @@ -0,0 +1,965 @@ +package store + +import ( + "context" + "encoding/binary" + "fmt" + "net" + "net/netip" + "os" + "reflect" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbdns "github.com/netbirdio/netbird/dns" + proxydomain "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + 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" + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" + nbroute "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/status" + "github.com/netbirdio/netbird/shared/testing_helpers" +) + +func Test_SaveAccount_Large(t *testing.T) { + if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" { + t.Skip("skip CI tests on darwin and windows") + } + + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + runLargeTest(t, store) + }) +} + +func runLargeTest(t *testing.T, store Store) { + t.Helper() + + account := newAccountWithId(context.Background(), "account_id", "testuser", "") + groupALL, err := account.GetGroupAll() + if err != nil { + t.Fatal(err) + } + setupKey, _ := types.GenerateDefaultSetupKey() + account.SetupKeys[setupKey.Key] = setupKey + const numPerAccount = 6000 + for n := 0; n < numPerAccount; n++ { + netIP := sequentialIPv4(n) + peerID := fmt.Sprintf("%s-peer-%d", account.Id, n) + addr, _ := netip.AddrFromSlice(netIP) + + peer := &nbpeer.Peer{ + ID: peerID, + Key: peerID, + IP: addr.Unmap(), + Name: peerID, + DNSLabel: peerID, + UserID: "testuser", + Status: &nbpeer.PeerStatus{Connected: false, LastSeen: time.Now()}, + SSHEnabled: false, + } + account.Peers[peerID] = peer + group, _ := account.GetGroupAll() + group.Peers = append(group.Peers, peerID) + user := &types.User{ + Id: fmt.Sprintf("%s-user-%d", account.Id, n), + AccountID: account.Id, + } + account.Users[user.Id] = user + route := &nbroute.Route{ + ID: nbroute.ID(fmt.Sprintf("network-id-%d", n)), + Description: "base route", + NetID: nbroute.NetID(fmt.Sprintf("network-id-%d", n)), + Network: netip.MustParsePrefix(netIP.String() + "/24"), + NetworkType: nbroute.IPv4Network, + Metric: 9999, + Masquerade: false, + Enabled: true, + Groups: []string{groupALL.ID}, + } + account.Routes[route.ID] = route + + group = &types.Group{ + ID: fmt.Sprintf("group-id-%d", n), + AccountID: account.Id, + Name: fmt.Sprintf("group-id-%d", n), + Issued: "api", + Peers: nil, + } + account.Groups[group.ID] = group + + nameserver := &nbdns.NameServerGroup{ + ID: fmt.Sprintf("nameserver-id-%d", n), + AccountID: account.Id, + Name: fmt.Sprintf("nameserver-id-%d", n), + Description: "", + NameServers: []nbdns.NameServer{{IP: netip.MustParseAddr(netIP.String()), NSType: nbdns.UDPNameServerType}}, + Groups: []string{group.ID}, + Primary: false, + Domains: nil, + Enabled: false, + SearchDomainsEnabled: false, + } + account.NameServerGroups[nameserver.ID] = nameserver + + setupKey, _ := types.GenerateDefaultSetupKey() + _, exists := account.SetupKeys[setupKey.Key] + if exists { + t.Errorf("setup key already exists") + } + account.SetupKeys[setupKey.Key] = setupKey + } + + err = store.SaveAccount(context.Background(), account) + require.NoError(t, err) + + if len(store.GetAllAccounts(context.Background())) != 1 { + t.Errorf("expecting 1 Accounts to be stored after SaveAccount()") + } + + a, err := store.GetAccount(context.Background(), account.Id) + if a == nil { + t.Errorf("expecting Account to be stored after SaveAccount(): %v", err) + } + + if a != nil && len(a.Policies) != 1 { + t.Errorf("expecting Account to have one policy stored after SaveAccount(), got %d", len(a.Policies)) + } + + if a != nil && len(a.Policies[0].Rules) != 1 { + t.Errorf("expecting Account to have one policy rule stored after SaveAccount(), got %d", len(a.Policies[0].Rules)) + return + } + + if a != nil && len(a.Peers) != numPerAccount { + t.Errorf("expecting Account to have %d peers stored after SaveAccount(), got %d", + numPerAccount, len(a.Peers)) + return + } + + if a != nil && len(a.Users) != numPerAccount+1 { + t.Errorf("expecting Account to have %d users stored after SaveAccount(), got %d", + numPerAccount+1, len(a.Users)) + return + } + + if a != nil && len(a.Routes) != numPerAccount { + t.Errorf("expecting Account to have %d routes stored after SaveAccount(), got %d", + numPerAccount, len(a.Routes)) + return + } + + if a != nil && len(a.NameServerGroups) != numPerAccount { + t.Errorf("expecting Account to have %d NameServerGroups stored after SaveAccount(), got %d", + numPerAccount, len(a.NameServerGroups)) + return + } + + if a != nil && len(a.NameServerGroups) != numPerAccount { + t.Errorf("expecting Account to have %d NameServerGroups stored after SaveAccount(), got %d", + numPerAccount, len(a.NameServerGroups)) + return + } + + if a != nil && len(a.SetupKeys) != numPerAccount+1 { + t.Errorf("expecting Account to have %d SetupKeys stored after SaveAccount(), got %d", + numPerAccount+1, len(a.SetupKeys)) + return + } +} + +// sequentialIPv4 returns a unique IPv4 address for the given index, avoiding +// the random collisions that would otherwise violate the unique (account_id, ip) +// index when generating a large number of peers. +func sequentialIPv4(n int) net.IP { + b := make([]byte, 4) + binary.BigEndian.PutUint32(b, 0x0A000000+uint32(n)) + return net.IP(b) +} + +func Test_SaveAccount(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("The SQLite store is not properly supported by Windows yet") + } + + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + account := newAccountWithId(context.Background(), "account_id", "testuser", "") + setupKey, _ := types.GenerateDefaultSetupKey() + account.SetupKeys[setupKey.Key] = setupKey + account.Peers["testpeer"] = &nbpeer.Peer{ + Key: "peerkey", + IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::1"), + Meta: nbpeer.PeerSystemMeta{}, + Name: "peer name", + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, + } + + err := store.SaveAccount(context.Background(), account) + require.NoError(t, err) + + account2 := newAccountWithId(context.Background(), "account_id2", "testuser2", "") + setupKey, _ = types.GenerateDefaultSetupKey() + account2.SetupKeys[setupKey.Key] = setupKey + account2.Peers["testpeer2"] = &nbpeer.Peer{ + Key: "peerkey2", + IP: netip.AddrFrom4([4]byte{127, 0, 0, 2}), + IPv6: netip.MustParseAddr("fd00::2"), + Meta: nbpeer.PeerSystemMeta{}, + Name: "peer name 2", + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, + } + + err = store.SaveAccount(context.Background(), account2) + require.NoError(t, err) + + if len(store.GetAllAccounts(context.Background())) != 2 { + t.Errorf("expecting 2 Accounts to be stored after SaveAccount()") + } + + a, err := store.GetAccount(context.Background(), account.Id) + if a == nil { + t.Errorf("expecting Account to be stored after SaveAccount(): %v", err) + } + + if a != nil && len(a.Policies) != 1 { + t.Errorf("expecting Account to have one policy stored after SaveAccount(), got %d", len(a.Policies)) + } + + if a != nil && len(a.Policies[0].Rules) != 1 { + t.Errorf("expecting Account to have one policy rule stored after SaveAccount(), got %d", len(a.Policies[0].Rules)) + return + } + + if a, err := store.GetAccountByPeerPubKey(context.Background(), "peerkey"); a == nil { + t.Errorf("expecting PeerKeyID2AccountID index updated after SaveAccount(): %v", err) + } + + if a, err := store.GetAccountByUser(context.Background(), "testuser"); a == nil { + t.Errorf("expecting UserID2AccountID index updated after SaveAccount(): %v", err) + } + + if a, err := store.GetAccountByPeerID(context.Background(), "testpeer"); a == nil { + t.Errorf("expecting PeerID2AccountID index updated after SaveAccount(): %v", err) + } + + if a, err := store.GetAccountBySetupKey(context.Background(), setupKey.Key); a == nil { + t.Errorf("expecting SetupKeyID2AccountID index updated after SaveAccount(): %v", err) + } + }) +} + +func Test_AccountSettings_SaveAndRetrieve(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("The SQLite store is not properly supported by Windows yet") + } + + populateFields := testing_helpers.NewPopulateFields().WithCustomFieldSetter( + reflect.PointerTo(reflect.TypeOf(types.ExtraSettings{})), func(this *testing_helpers.PopulateFields, field reflect.Value) (int, error) { + es := types.ExtraSettings{} + reflectedEs := reflect.ValueOf(&es).Elem() + n, err := this.PopulateAll(reflectedEs) + if err != nil { + return n, err + } + field.Set(reflectedEs.Addr()) + return n, nil + }).WithCustomFieldSetter( + reflect.PointerTo(reflect.TypeOf(types.DashboardFeatures{})), func(this *testing_helpers.PopulateFields, field reflect.Value) (int, error) { + t := true + df := types.DashboardFeatures{AgentNetwork: &t} + reflectedDf := reflect.ValueOf(&df).Elem() + field.Set(reflectedDf.Addr()) + return 1, nil + }).WithSkippedTag("gorm", "-") + + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + account := newAccountWithId(context.Background(), "account_id", "testuser", "") + setupKey, _ := types.GenerateDefaultSetupKey() + account.SetupKeys[setupKey.Key] = setupKey + + settings := types.Settings{} + numOfExportedFields, err := populateFields.PopulateAll(reflect.ValueOf(&settings).Elem()) + assert.NoError(t, err) + assert.Equal(t, 27, numOfExportedFields) + account.Settings = &settings + + err = store.SaveAccount(context.Background(), account) + assert.NoError(t, err) + + accountFromDb, err := store.GetAccount(context.Background(), account.Id) + assert.NoError(t, err) + assert.NotNil(t, accountFromDb) + assert.NotNil(t, accountFromDb.Settings) + + assert.True(t, reflect.DeepEqual(&settings, accountFromDb.Settings), "created settings and settings retrieved from the db should match") + }) +} + +func TestSqlite_DeleteAccount(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("The SQLite store is not properly supported by Windows yet") + } + + t.Setenv("NETBIRD_STORE_ENGINE", string(types.SqliteStoreEngine)) + store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir()) + t.Cleanup(cleanUp) + assert.NoError(t, err) + + testUserID := "testuser" + user := types.NewAdminUser(testUserID) + user.PATs = map[string]*types.PersonalAccessToken{"testtoken": { + ID: "testtoken", + Name: "test token", + }} + + account := newAccountWithId(context.Background(), "account_id", testUserID, "") + setupKey, _ := types.GenerateDefaultSetupKey() + account.SetupKeys[setupKey.Key] = setupKey + account.Peers["testpeer"] = &nbpeer.Peer{ + Key: "peerkey", + IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::1"), + Meta: nbpeer.PeerSystemMeta{}, + Name: "peer name", + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, + } + account.Users[testUserID] = user + account.Networks = []*networkTypes.Network{ + { + ID: "network_id", + AccountID: account.Id, + Name: "network name", + Description: "network description", + }, + } + account.NetworkRouters = []*routerTypes.NetworkRouter{ + { + ID: "router_id", + NetworkID: account.Networks[0].ID, + AccountID: account.Id, + PeerGroups: []string{"group_id"}, + Masquerade: true, + Metric: 1, + }, + } + account.NetworkResources = []*resourceTypes.NetworkResource{ + { + ID: "resource_id", + NetworkID: account.Networks[0].ID, + AccountID: account.Id, + Name: "Name", + Description: "Description", + Type: "Domain", + Address: "example.com", + }, + } + + account.Services = []*rpservice.Service{ + { + ID: "service_id", + AccountID: account.Id, + Name: "test service", + Domain: "svc.example.com", + Enabled: true, + Targets: []*rpservice.Target{ + { + AccountID: account.Id, + ServiceID: "service_id", + Host: "localhost", + Port: 8080, + Protocol: "http", + Enabled: true, + }, + }, + }, + } + + account.Domains = []*proxydomain.Domain{ + { + ID: "domain_id", + Domain: "custom.example.com", + AccountID: account.Id, + Validated: true, + }, + } + + err = store.SaveAccount(context.Background(), account) + require.NoError(t, err) + + if len(store.GetAllAccounts(context.Background())) != 1 { + t.Errorf("expecting 1 Accounts to be stored after SaveAccount()") + } + + o, err := store.GetAccountOnboarding(context.Background(), account.Id) + require.NoError(t, err) + require.Equal(t, o.AccountID, account.Id) + + err = store.DeleteAccount(context.Background(), account) + require.NoError(t, err) + + _, err = store.GetAccountOnboarding(context.Background(), account.Id) + require.Error(t, err, "expecting error after removing DeleteAccount when getting onboarding") + + if len(store.GetAllAccounts(context.Background())) != 0 { + t.Errorf("expecting 0 Accounts to be stored after DeleteAccount()") + } + + _, err = store.GetAccountByPeerPubKey(context.Background(), "peerkey") + require.Error(t, err, "expecting error after removing DeleteAccount when getting account by peer public key") + + _, err = store.GetAccountByUser(context.Background(), "testuser") + require.Error(t, err, "expecting error after removing DeleteAccount when getting account by user") + + _, err = store.GetAccountByPeerID(context.Background(), "testpeer") + require.Error(t, err, "expecting error after removing DeleteAccount when getting account by peer id") + + _, err = store.GetAccountBySetupKey(context.Background(), setupKey.Key) + require.Error(t, err, "expecting error after removing DeleteAccount when getting account by setup key") + + _, err = store.GetAccount(context.Background(), account.Id) + require.Error(t, err, "expecting error after removing DeleteAccount when getting account by id") + + for _, policy := range account.Policies { + var rules []*types.PolicyRule + err = store.(*SqlStore).db.Model(&types.PolicyRule{}).Find(&rules, "policy_id = ?", policy.ID).Error + require.NoError(t, err, "expecting no error after removing DeleteAccount when searching for policy rules") + require.Len(t, rules, 0, "expecting no policy rules to be found after removing DeleteAccount") + + } + + for _, accountUser := range account.Users { + var pats []*types.PersonalAccessToken + err = store.(*SqlStore).db.Model(&types.PersonalAccessToken{}).Find(&pats, "user_id = ?", accountUser.Id).Error + require.NoError(t, err, "expecting no error after removing DeleteAccount when searching for personal access token") + require.Len(t, pats, 0, "expecting no personal access token to be found after removing DeleteAccount") + + } + + for _, network := range account.Networks { + routers, err := store.GetNetworkRoutersByNetID(context.Background(), LockingStrengthNone, account.Id, network.ID) + require.NoError(t, err, "expecting no error after removing DeleteAccount when searching for network routers") + require.Len(t, routers, 0, "expecting no network routers to be found after DeleteAccount") + + resources, err := store.GetNetworkResourcesByNetID(context.Background(), LockingStrengthNone, account.Id, network.ID) + require.NoError(t, err, "expecting no error after removing DeleteAccount when searching for network resources") + require.Len(t, resources, 0, "expecting no network resources to be found after DeleteAccount") + } + + domains, err := store.ListCustomDomains(context.Background(), account.Id) + require.NoError(t, err, "expecting no error after DeleteAccount when searching for custom domains") + require.Len(t, domains, 0, "expecting no custom domains to be found after DeleteAccount") + + var services []*rpservice.Service + err = store.(*SqlStore).db.Model(&rpservice.Service{}).Find(&services, "account_id = ?", account.Id).Error + require.NoError(t, err, "expecting no error after DeleteAccount when searching for services") + require.Len(t, services, 0, "expecting no services to be found after DeleteAccount") + + var targets []*rpservice.Target + err = store.(*SqlStore).db.Model(&rpservice.Target{}).Find(&targets, "account_id = ?", account.Id).Error + require.NoError(t, err, "expecting no error after DeleteAccount when searching for service targets") + require.Len(t, targets, 0, "expecting no service targets to be found after DeleteAccount") +} + +func Test_GetAccount(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("The SQLite store is not properly supported by Windows yet") + } + + runTestForAllEngines(t, "../testdata/store.sql", func(t *testing.T, store Store) { + id := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + account, err := store.GetAccount(context.Background(), id) + require.NoError(t, err) + require.Equal(t, id, account.Id, "account id should match") + require.Equal(t, false, account.Onboarding.OnboardingFlowPending) + + id = "9439-34653001fc3b-bf1c8084-ba50-4ce7" + + account, err = store.GetAccount(context.Background(), id) + require.NoError(t, err) + require.Equal(t, id, account.Id, "account id should match") + require.Equal(t, true, account.Onboarding.OnboardingFlowPending) + + _, err = store.GetAccount(context.Background(), "non-existing-account") + assert.Error(t, err) + parsedErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") + + }) +} + +func Test_TestGetAccountByPrivateDomain(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("The SQLite store is not properly supported by Windows yet") + } + + runTestForAllEngines(t, "../testdata/store.sql", func(t *testing.T, store Store) { + existingDomain := "test.com" + + account, err := store.GetAccountByPrivateDomain(context.Background(), existingDomain) + require.NoError(t, err, "should found account") + require.Equal(t, existingDomain, account.Domain, "domains should match") + + _, err = store.GetAccountByPrivateDomain(context.Background(), "missing-domain.com") + require.Error(t, err, "should return error on domain lookup") + parsedErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") + }) +} + +func TestPostgresql_SaveAccount(t *testing.T) { + if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" { + t.Skip("skip CI tests on darwin and windows") + } + + t.Setenv("NETBIRD_STORE_ENGINE", string(types.PostgresStoreEngine)) + store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir()) + t.Cleanup(cleanUp) + assert.NoError(t, err) + + account := newAccountWithId(context.Background(), "account_id", "testuser", "") + setupKey, _ := types.GenerateDefaultSetupKey() + account.SetupKeys[setupKey.Key] = setupKey + account.Peers["testpeer"] = &nbpeer.Peer{ + Key: "peerkey", + IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::1"), + Meta: nbpeer.PeerSystemMeta{}, + Name: "peer name", + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, + } + + err = store.SaveAccount(context.Background(), account) + require.NoError(t, err) + + account2 := newAccountWithId(context.Background(), "account_id2", "testuser2", "") + setupKey, _ = types.GenerateDefaultSetupKey() + account2.SetupKeys[setupKey.Key] = setupKey + account2.Peers["testpeer2"] = &nbpeer.Peer{ + Key: "peerkey2", + IP: netip.AddrFrom4([4]byte{127, 0, 0, 2}), + IPv6: netip.MustParseAddr("fd00::2"), + Meta: nbpeer.PeerSystemMeta{}, + Name: "peer name 2", + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, + } + + err = store.SaveAccount(context.Background(), account2) + require.NoError(t, err) + + if len(store.GetAllAccounts(context.Background())) != 2 { + t.Errorf("expecting 2 Accounts to be stored after SaveAccount()") + } + + a, err := store.GetAccount(context.Background(), account.Id) + if a == nil { + t.Errorf("expecting Account to be stored after SaveAccount(): %v", err) + } + + if a != nil && len(a.Policies) != 1 { + t.Errorf("expecting Account to have one policy stored after SaveAccount(), got %d", len(a.Policies)) + } + + if a != nil && len(a.Policies[0].Rules) != 1 { + t.Errorf("expecting Account to have one policy rule stored after SaveAccount(), got %d", len(a.Policies[0].Rules)) + return + } + + if a, err := store.GetAccountByPeerPubKey(context.Background(), "peerkey"); a == nil { + t.Errorf("expecting PeerKeyID2AccountID index updated after SaveAccount(): %v", err) + } + + if a, err := store.GetAccountByUser(context.Background(), "testuser"); a == nil { + t.Errorf("expecting UserID2AccountID index updated after SaveAccount(): %v", err) + } + + if a, err := store.GetAccountByPeerID(context.Background(), "testpeer"); a == nil { + t.Errorf("expecting PeerID2AccountID index updated after SaveAccount(): %v", err) + } + + if a, err := store.GetAccountBySetupKey(context.Background(), setupKey.Key); a == nil { + t.Errorf("expecting SetupKeyID2AccountID index updated after SaveAccount(): %v", err) + } +} + +func TestPostgresql_DeleteAccount(t *testing.T) { + if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" { + t.Skip("skip CI tests on darwin and windows") + } + + t.Setenv("NETBIRD_STORE_ENGINE", string(types.PostgresStoreEngine)) + store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir()) + t.Cleanup(cleanUp) + assert.NoError(t, err) + + testUserID := "testuser" + user := types.NewAdminUser(testUserID) + user.PATs = map[string]*types.PersonalAccessToken{"testtoken": { + ID: "testtoken", + Name: "test token", + }} + + account := newAccountWithId(context.Background(), "account_id", testUserID, "") + setupKey, _ := types.GenerateDefaultSetupKey() + account.SetupKeys[setupKey.Key] = setupKey + account.Peers["testpeer"] = &nbpeer.Peer{ + Key: "peerkey", + IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::1"), + Meta: nbpeer.PeerSystemMeta{}, + Name: "peer name", + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, + } + account.Users[testUserID] = user + + err = store.SaveAccount(context.Background(), account) + require.NoError(t, err) + + if len(store.GetAllAccounts(context.Background())) != 1 { + t.Errorf("expecting 1 Accounts to be stored after SaveAccount()") + } + + err = store.DeleteAccount(context.Background(), account) + require.NoError(t, err) + + if len(store.GetAllAccounts(context.Background())) != 0 { + t.Errorf("expecting 0 Accounts to be stored after DeleteAccount()") + } + + _, err = store.GetAccountByPeerPubKey(context.Background(), "peerkey") + require.Error(t, err, "expecting error after removing DeleteAccount when getting account by peer public key") + + _, err = store.GetAccountByUser(context.Background(), "testuser") + require.Error(t, err, "expecting error after removing DeleteAccount when getting account by user") + + _, err = store.GetAccountByPeerID(context.Background(), "testpeer") + require.Error(t, err, "expecting error after removing DeleteAccount when getting account by peer id") + + _, err = store.GetAccountBySetupKey(context.Background(), setupKey.Key) + require.Error(t, err, "expecting error after removing DeleteAccount when getting account by setup key") + + _, err = store.GetAccount(context.Background(), account.Id) + require.Error(t, err, "expecting error after removing DeleteAccount when getting account by id") + + for _, policy := range account.Policies { + var rules []*types.PolicyRule + err = store.(*SqlStore).db.Model(&types.PolicyRule{}).Find(&rules, "policy_id = ?", policy.ID).Error + require.NoError(t, err, "expecting no error after removing DeleteAccount when searching for policy rules") + require.Len(t, rules, 0, "expecting no policy rules to be found after removing DeleteAccount") + + } + + for _, accountUser := range account.Users { + var pats []*types.PersonalAccessToken + err = store.(*SqlStore).db.Model(&types.PersonalAccessToken{}).Find(&pats, "user_id = ?", accountUser.Id).Error + require.NoError(t, err, "expecting no error after removing DeleteAccount when searching for personal access token") + require.Len(t, pats, 0, "expecting no personal access token to be found after removing DeleteAccount") + + } + +} + +func TestPostgresql_TestGetAccountByPrivateDomain(t *testing.T) { + if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" { + t.Skip("skip CI tests on darwin and windows") + } + + t.Setenv("NETBIRD_STORE_ENGINE", string(types.PostgresStoreEngine)) + store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanUp) + assert.NoError(t, err) + + existingDomain := "test.com" + + account, err := store.GetAccountByPrivateDomain(context.Background(), existingDomain) + require.NoError(t, err, "should found account") + require.Equal(t, existingDomain, account.Domain, "domains should match") + + _, err = store.GetAccountByPrivateDomain(context.Background(), "missing-domain.com") + require.Error(t, err, "should return error on domain lookup") +} + +func TestSqlite_GetAccountNetwork(t *testing.T) { + t.Setenv("NETBIRD_STORE_ENGINE", string(types.SqliteStoreEngine)) + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + if err != nil { + t.Fatal(err) + } + + existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + _, err = store.GetAccount(context.Background(), existingAccountID) + require.NoError(t, err) + + network, err := store.GetAccountNetwork(context.Background(), LockingStrengthNone, existingAccountID) + require.NoError(t, err) + ip := net.IP{100, 64, 0, 0}.To16() + assert.Equal(t, ip, network.Net.IP) + assert.Equal(t, net.IPMask{255, 255, 0, 0}, network.Net.Mask) + assert.Equal(t, "", network.Dns) + assert.Equal(t, "af1c8024-ha40-4ce2-9418-34653101fc3c", network.Identifier) + assert.Equal(t, uint64(0), network.Serial) +} + +func TestSqlStore_SaveAccountPersistsAgentNetworkOnly(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + account, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.False(t, account.Settings.AgentNetworkOnly, "setting should default to false") + + account.Settings.AgentNetworkOnly = true + require.NoError(t, store.SaveAccount(context.Background(), account)) + + reloaded, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.True(t, reloaded.Settings.AgentNetworkOnly, "setting should survive a save/load round-trip") + + reloaded.Settings.AgentNetworkOnly = false + require.NoError(t, store.SaveAccount(context.Background(), reloaded)) + + disabled, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.False(t, disabled.Settings.AgentNetworkOnly, "disabling should persist") +} + +func TestSqlStore_SaveAccountPersistsDashboardFeatures(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + account, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.Nil(t, account.Settings.DashboardFeatures, "dashboard features should default to unset") + + agentNetwork := true + account.Settings.DashboardFeatures = &types.DashboardFeatures{AgentNetwork: &agentNetwork} + require.NoError(t, store.SaveAccount(context.Background(), account)) + + reloaded, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.NotNil(t, reloaded.Settings.DashboardFeatures, "dashboard features should survive a save/load round-trip") + require.NotNil(t, reloaded.Settings.DashboardFeatures.AgentNetwork, "agent network flag should be set") + require.True(t, *reloaded.Settings.DashboardFeatures.AgentNetwork, "agent network flag should persist as true") + + disabled := false + reloaded.Settings.DashboardFeatures = &types.DashboardFeatures{AgentNetwork: &disabled} + require.NoError(t, store.SaveAccount(context.Background(), reloaded)) + + reloadedDisabled, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.NotNil(t, reloadedDisabled.Settings.DashboardFeatures.AgentNetwork, "agent network flag should remain set") + require.False(t, *reloadedDisabled.Settings.DashboardFeatures.AgentNetwork, "explicit false should persist") +} + +func TestSqlStore_UpdateAccountDomainAttributes(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + if err != nil { + t.Fatal(err) + } + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + t.Run("Should update attributes with public domain", func(t *testing.T) { + require.NoError(t, err) + domain := "example.com" + category := "public" + IsDomainPrimaryAccount := false + err = store.UpdateAccountDomainAttributes(context.Background(), accountID, domain, category, IsDomainPrimaryAccount) + require.NoError(t, err) + account, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.Equal(t, domain, account.Domain) + require.Equal(t, category, account.DomainCategory) + require.Equal(t, IsDomainPrimaryAccount, account.IsDomainPrimaryAccount) + }) + + t.Run("Should update attributes with private domain", func(t *testing.T) { + require.NoError(t, err) + domain := "test.com" + category := "private" + IsDomainPrimaryAccount := true + err = store.UpdateAccountDomainAttributes(context.Background(), accountID, domain, category, IsDomainPrimaryAccount) + require.NoError(t, err) + account, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + require.Equal(t, domain, account.Domain) + require.Equal(t, category, account.DomainCategory) + require.Equal(t, IsDomainPrimaryAccount, account.IsDomainPrimaryAccount) + }) + + t.Run("Should fail when account does not exist", func(t *testing.T) { + require.NoError(t, err) + domain := "test.com" + category := "private" + IsDomainPrimaryAccount := true + err = store.UpdateAccountDomainAttributes(context.Background(), "non-existing-account-id", domain, category, IsDomainPrimaryAccount) + require.Error(t, err) + }) + +} + +func TestSqlStore_GetDNSSettings(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + tests := []struct { + name string + accountID string + expectError bool + }{ + { + name: "retrieve existing account dns settings", + accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", + expectError: false, + }, + { + name: "retrieve non-existing account dns settings", + accountID: "non-existing", + expectError: true, + }, + { + name: "retrieve dns settings with empty account ID", + accountID: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dnsSettings, err := store.GetAccountDNSSettings(context.Background(), LockingStrengthNone, tt.accountID) + if tt.expectError { + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) + require.Nil(t, dnsSettings) + } else { + require.NoError(t, err) + require.NotNil(t, dnsSettings) + } + }) + } +} + +func TestSqlStore_SaveDNSSettings(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + dnsSettings, err := store.GetAccountDNSSettings(context.Background(), LockingStrengthNone, accountID) + require.NoError(t, err) + + dnsSettings.DisabledManagementGroups = []string{"groupA", "groupB"} + err = store.SaveDNSSettings(context.Background(), accountID, dnsSettings) + require.NoError(t, err) + + saveDNSSettings, err := store.GetAccountDNSSettings(context.Background(), LockingStrengthNone, accountID) + require.NoError(t, err) + require.Equal(t, saveDNSSettings, dnsSettings) +} + +func TestSqlStore_GetAccountCreatedBy(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + tests := []struct { + name string + accountID string + expectError bool + createdBy string + }{ + { + name: "existing account ID", + accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", + expectError: false, + createdBy: "edafee4e-63fb-11ec-90d6-0242ac120003", + }, + { + name: "non-existing account ID", + accountID: "nonexistent", + expectError: true, + }, + { + name: "empty account ID", + accountID: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + createdBy, err := store.GetAccountCreatedBy(context.Background(), LockingStrengthNone, tt.accountID) + if tt.expectError { + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) + require.Empty(t, createdBy) + } else { + require.NoError(t, err) + require.NotNil(t, createdBy) + require.Equal(t, tt.createdBy, createdBy) + } + }) + } + +} + +func TestSqlStore_GetAccountMeta(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + accountMeta, err := store.GetAccountMeta(context.Background(), LockingStrengthNone, accountID) + require.NoError(t, err) + require.NotNil(t, accountMeta) + require.Equal(t, accountID, accountMeta.AccountID) + require.Equal(t, "edafee4e-63fb-11ec-90d6-0242ac120003", accountMeta.CreatedBy) + require.Equal(t, "test.com", accountMeta.Domain) + require.Equal(t, "private", accountMeta.DomainCategory) + require.Equal(t, time.Date(2024, time.October, 2, 14, 1, 38, 210000000, time.UTC), accountMeta.CreatedAt.UTC()) +} + +func TestSqlStore_GetAnyAccountID(t *testing.T) { + t.Run("should return account ID when accounts exist", func(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID, err := store.GetAnyAccountID(context.Background()) + require.NoError(t, err) + assert.Equal(t, "bf1c8084-ba50-4ce7-9439-34653001fc3b", accountID) + }) + + t.Run("should return error when no accounts exist", func(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID, err := store.GetAnyAccountID(context.Background()) + require.Error(t, err) + sErr, ok := status.FromError(err) + assert.True(t, ok) + assert.Equal(t, sErr.Type(), status.NotFound) + assert.Empty(t, accountID) + }) +} diff --git a/management/server/store/sql_store_agent_network_access_log.go b/management/server/store/sql_store_agent_network_access_log.go new file mode 100644 index 000000000..fdaea1bf7 --- /dev/null +++ b/management/server/store/sql_store_agent_network_access_log.go @@ -0,0 +1,268 @@ +package store + +import ( + "context" + "time" + + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +// CreateAgentNetworkAccessLog persists a flattened agent-network access-log +// entry together with its authorising-group child rows in a single +// transaction. +func (s *SqlStore) CreateAgentNetworkAccessLog(ctx context.Context, entry *agentNetworkTypes.AgentNetworkAccessLog, groups []agentNetworkTypes.AgentNetworkAccessLogGroup) error { + err := s.db.Transaction(func(tx *gorm.DB) error { + // Idempotent on the log id / (log_id, group_id) so a proxy resend of the + // same entry can't fail the request. + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(entry).Error; err != nil { + return err + } + if len(groups) > 0 { + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&groups).Error; err != nil { + return err + } + } + return nil + }) + if err != nil { + log.WithContext(ctx).WithFields(log.Fields{ + "account_id": entry.AccountID, + "service_id": entry.ServiceID, + "model": entry.Model, + }).Errorf("failed to create agent-network access log entry in store: %v", err) + return status.Errorf(status.Internal, "failed to create agent-network access log entry in store") + } + return nil +} + +// DeleteOldAgentNetworkAccessLogs deletes an account's access-log rows (and +// their authorising-group child rows) older than the cutoff. Usage records are +// untouched — they are the long-term aggregate. Returns the number of log rows +// deleted. +func (s *SqlStore) DeleteOldAgentNetworkAccessLogs(ctx context.Context, accountID string, olderThan time.Time) (int64, error) { + var deleted int64 + err := s.db.Transaction(func(tx *gorm.DB) error { + // Remove group child rows for the soon-to-be-deleted logs first. + if err := tx.Exec( + "DELETE FROM agent_network_access_log_group WHERE account_id = ? AND log_id IN (SELECT id FROM agent_network_access_log WHERE account_id = ? AND timestamp < ?)", + accountID, accountID, olderThan, + ).Error; err != nil { + return err + } + res := tx.Where("account_id = ? AND timestamp < ?", accountID, olderThan). + Delete(&agentNetworkTypes.AgentNetworkAccessLog{}) + if res.Error != nil { + return res.Error + } + deleted = res.RowsAffected + return nil + }) + if err != nil { + log.WithContext(ctx).Errorf("failed to delete old agent-network access logs for account %s: %v", accountID, err) + return 0, status.Errorf(status.Internal, "failed to delete old agent-network access logs") + } + return deleted, nil +} + +// GetAgentNetworkAccessLogs retrieves flattened agent-network access logs for +// an account with server-side pagination, filtering and sorting. Authorising +// group ids are hydrated from the group child table for the returned page. +func (s *SqlStore) GetAgentNetworkAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLog, int64, error) { + var logs []*agentNetworkTypes.AgentNetworkAccessLog + var totalCount int64 + + countQuery := s.applyAgentNetworkAccessLogFilters( + s.db.Model(&agentNetworkTypes.AgentNetworkAccessLog{}).Where(accountIDCondition, accountID), + filter, + ) + if err := countQuery.Count(&totalCount).Error; err != nil { + log.WithContext(ctx).Errorf("failed to count agent-network access logs: %v", err) + return nil, 0, status.Errorf(status.Internal, "failed to count agent-network access logs") + } + + query := s.applyAgentNetworkAccessLogFilters( + s.db.Where(accountIDCondition, accountID), + filter, + ). + Order(filter.GetSortColumn() + " " + filter.GetSortOrder()). + Limit(filter.GetLimit()). + Offset(filter.GetOffset()) + + if lockStrength != LockingStrengthNone { + query = query.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + if err := query.Find(&logs).Error; err != nil { + log.WithContext(ctx).Errorf("failed to get agent-network access logs from store: %v", err) + return nil, 0, status.Errorf(status.Internal, "failed to get agent-network access logs from store") + } + + if err := s.hydrateAgentNetworkAccessLogGroups(ctx, accountID, logs); err != nil { + return nil, 0, err + } + + return logs, totalCount, nil +} + +// applyAgentNetworkAccessLogFilters applies the filter conditions to a query. +func (s *SqlStore) applyAgentNetworkAccessLogFilters(query *gorm.DB, filter agentNetworkTypes.AgentNetworkAccessLogFilter) *gorm.DB { + if filter.Search != nil { + p := "%" + *filter.Search + "%" + query = query.Where( + "id LIKE ? OR host LIKE ? OR path LIKE ? OR model LIKE ? OR user_id IN (SELECT id FROM users WHERE email LIKE ? OR name LIKE ?)", + p, p, p, p, p, p, + ) + } + if filter.UserID != nil { + query = query.Where("user_id = ?", *filter.UserID) + } + if filter.SessionID != nil { + query = query.Where("session_id = ?", *filter.SessionID) + } + if filter.Decision != nil { + query = query.Where("decision = ?", *filter.Decision) + } + if filter.PathPrefix != nil { + query = query.Where("path LIKE ?", *filter.PathPrefix+"%") + } + if len(filter.ProviderIDs) > 0 { + query = query.Where("resolved_provider_id IN ?", filter.ProviderIDs) + } + if len(filter.Models) > 0 { + query = query.Where("model IN ?", filter.Models) + } + if len(filter.GroupIDs) > 0 { + query = query.Where( + "id IN (SELECT log_id FROM agent_network_access_log_group WHERE group_id IN ?)", + filter.GroupIDs, + ) + } + if filter.StartDate != nil { + query = query.Where("timestamp >= ?", *filter.StartDate) + } + if filter.EndDate != nil { + query = query.Where("timestamp <= ?", *filter.EndDate) + } + return query +} + +// hydrateAgentNetworkAccessLogGroups loads the authorising group ids for the +// given page of entries and assigns them onto each entry's GroupIDs field. +func (s *SqlStore) hydrateAgentNetworkAccessLogGroups(ctx context.Context, accountID string, logs []*agentNetworkTypes.AgentNetworkAccessLog) error { + if len(logs) == 0 { + return nil + } + + ids := make([]string, 0, len(logs)) + for _, l := range logs { + ids = append(ids, l.ID) + } + + var rows []agentNetworkTypes.AgentNetworkAccessLogGroup + if err := s.db. + Where(accountIDCondition, accountID). + Where("log_id IN ?", ids). + Find(&rows).Error; err != nil { + log.WithContext(ctx).Errorf("failed to hydrate agent-network access log groups: %v", err) + return status.Errorf(status.Internal, "failed to hydrate agent-network access log groups") + } + + byLog := make(map[string][]string, len(logs)) + for _, r := range rows { + byLog[r.LogID] = append(byLog[r.LogID], r.GroupID) + } + for _, l := range logs { + l.GroupIDs = byLog[l.ID] + } + return nil +} + +// agentNetworkSessionKeyExpr is the SQL group key for session-grouped access +// logs: the row's session id, or — when the client sent none — the row id, so +// session-less requests each form their own singleton group. COALESCE/NULLIF +// are standard SQL, so this stays portable across SQLite and Postgres. +const agentNetworkSessionKeyExpr = "COALESCE(NULLIF(session_id, ''), id)" + +// GetAgentNetworkAccessLogSessions retrieves agent-network access logs grouped +// by session, with server-side pagination, filtering and sorting at the session +// level. It paginates over the distinct session keys (ordered by the requested +// session-level aggregate), fetches every entry for the page's sessions, and +// folds them into per-session summaries. The returned count is the number of +// matching sessions. Filters apply to the entries, so a session's summary +// reflects only its filter-matching requests. +func (s *SqlStore) GetAgentNetworkAccessLogSessions(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLogSession, int64, error) { + // Count distinct sessions via a grouped subquery — portable and avoids + // relying on COUNT(DISTINCT ) quoting quirks. + sessionsSubquery := s.applyAgentNetworkAccessLogFilters( + s.db.Model(&agentNetworkTypes.AgentNetworkAccessLog{}).Where(accountIDCondition, accountID), + filter, + ). + Select(agentNetworkSessionKeyExpr + " AS session_key"). + Group(agentNetworkSessionKeyExpr) + + var totalCount int64 + if err := s.db.Table("(?) AS sessions", sessionsSubquery).Count(&totalCount).Error; err != nil { + log.WithContext(ctx).Errorf("failed to count agent-network access-log sessions: %v", err) + return nil, 0, status.Errorf(status.Internal, "failed to count agent-network access-log sessions") + } + + // The page of session keys, ordered by the session-level aggregate. The + // session-key tiebreaker keeps pagination deterministic when the primary + // aggregate ties. + type sessionKeyRow struct { + SessionKey string + } + var keyRows []sessionKeyRow + keyQuery := s.applyAgentNetworkAccessLogFilters( + s.db.Model(&agentNetworkTypes.AgentNetworkAccessLog{}).Where(accountIDCondition, accountID), + filter, + ). + Select(agentNetworkSessionKeyExpr + " AS session_key"). + Group(agentNetworkSessionKeyExpr). + Order(filter.GetSessionSortExpr() + " " + filter.GetSortOrder()). + Order("session_key ASC"). + Limit(filter.GetLimit()). + Offset(filter.GetOffset()) + if err := keyQuery.Scan(&keyRows).Error; err != nil { + log.WithContext(ctx).Errorf("failed to list agent-network access-log session keys: %v", err) + return nil, 0, status.Errorf(status.Internal, "failed to list agent-network access-log session keys") + } + if len(keyRows) == 0 { + return nil, totalCount, nil + } + + keys := make([]string, 0, len(keyRows)) + for _, r := range keyRows { + keys = append(keys, r.SessionKey) + } + + // All entries for the page's sessions, contiguous per session and oldest + // first within each — the fold relies on that ordering. + var entries []*agentNetworkTypes.AgentNetworkAccessLog + entriesQuery := s.applyAgentNetworkAccessLogFilters( + s.db.Where(accountIDCondition, accountID), + filter, + ). + Where(agentNetworkSessionKeyExpr+" IN ?", keys). + Order(agentNetworkSessionKeyExpr + ", timestamp ASC") + + if lockStrength != LockingStrengthNone { + entriesQuery = entriesQuery.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + if err := entriesQuery.Find(&entries).Error; err != nil { + log.WithContext(ctx).Errorf("failed to get agent-network access-log session entries: %v", err) + return nil, 0, status.Errorf(status.Internal, "failed to get agent-network access-log session entries") + } + + if err := s.hydrateAgentNetworkAccessLogGroups(ctx, accountID, entries); err != nil { + return nil, 0, err + } + + return agentNetworkTypes.FoldAccessLogSessions(keys, entries), totalCount, nil +} diff --git a/management/server/store/sql_store_agent_network_usage.go b/management/server/store/sql_store_agent_network_usage.go new file mode 100644 index 000000000..c34b31e0e --- /dev/null +++ b/management/server/store/sql_store_agent_network_usage.go @@ -0,0 +1,91 @@ +package store + +import ( + "context" + + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +// CreateAgentNetworkUsage persists a stripped agent-network usage record +// together with its authorising-group child rows in a single transaction. +func (s *SqlStore) CreateAgentNetworkUsage(ctx context.Context, usage *agentNetworkTypes.AgentNetworkUsage, groups []agentNetworkTypes.AgentNetworkUsageGroup) error { + err := s.db.Transaction(func(tx *gorm.DB) error { + // Idempotent on the usage id / (usage_id, group_id) so a proxy resend of + // the same entry can't fail the request. + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(usage).Error; err != nil { + return err + } + if len(groups) > 0 { + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&groups).Error; err != nil { + return err + } + } + return nil + }) + if err != nil { + log.WithContext(ctx).WithFields(log.Fields{ + "account_id": usage.AccountID, + "model": usage.Model, + }).Errorf("failed to create agent-network usage record in store: %v", err) + return status.Errorf(status.Internal, "failed to create agent-network usage record in store") + } + return nil +} + +// GetAgentNetworkUsageRows returns the stripped usage rows for an account that +// match the filter (date / user / group / provider / model). Aggregation into +// time buckets happens in the manager so granularities stay engine-portable. +func (s *SqlStore) GetAgentNetworkUsageRows(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkUsage, error) { + var rows []*agentNetworkTypes.AgentNetworkUsage + + query := s.applyAgentNetworkUsageFilters( + s.db.Where(accountIDCondition, accountID), + filter, + ).Order("timestamp ASC") + + if lockStrength != LockingStrengthNone { + query = query.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + if err := query.Find(&rows).Error; err != nil { + log.WithContext(ctx).Errorf("failed to get agent-network usage rows from store: %v", err) + return nil, status.Errorf(status.Internal, "failed to get agent-network usage rows from store") + } + return rows, nil +} + +// applyAgentNetworkUsageFilters applies the shared access-log filter's +// date/user/group/provider/model conditions to a usage-table query. Pagination, +// sort and free-text search are ignored — the overview is an aggregate. +func (s *SqlStore) applyAgentNetworkUsageFilters(query *gorm.DB, filter agentNetworkTypes.AgentNetworkAccessLogFilter) *gorm.DB { + if filter.UserID != nil { + query = query.Where("user_id = ?", *filter.UserID) + } + if filter.SessionID != nil { + query = query.Where("session_id = ?", *filter.SessionID) + } + if len(filter.ProviderIDs) > 0 { + query = query.Where("resolved_provider_id IN ?", filter.ProviderIDs) + } + if len(filter.Models) > 0 { + query = query.Where("model IN ?", filter.Models) + } + if len(filter.GroupIDs) > 0 { + query = query.Where( + "id IN (SELECT usage_id FROM agent_network_request_usage_group WHERE group_id IN ?)", + filter.GroupIDs, + ) + } + if filter.StartDate != nil { + query = query.Where("timestamp >= ?", *filter.StartDate) + } + if filter.EndDate != nil { + query = query.Where("timestamp <= ?", *filter.EndDate) + } + return query +} diff --git a/management/server/store/sql_store_custom_domain.go b/management/server/store/sql_store_custom_domain.go new file mode 100644 index 000000000..9eaaf2b10 --- /dev/null +++ b/management/server/store/sql_store_custom_domain.go @@ -0,0 +1,146 @@ +package store + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/rs/xid" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + "github.com/netbirdio/netbird/shared/management/status" +) + +// GetCustomDomainsCounts returns the total and validated custom domain counts. +func (s *SqlStore) GetCustomDomainsCounts(ctx context.Context) (int64, int64, error) { + var total, validated int64 + if err := s.db.Model(&domain.Domain{}).Count(&total).Error; err != nil { + return 0, 0, err + } + if err := s.db.Model(&domain.Domain{}).Where("validated = ?", true).Count(&validated).Error; err != nil { + return 0, 0, err + } + return total, validated, nil +} + +func (s *SqlStore) GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error) { + tx := s.db + + customDomain := &domain.Domain{} + result := tx.Take(&customDomain, accountAndIDQueryCondition, accountID, domainID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "custom domain %s not found", domainID) + } + + log.WithContext(ctx).Errorf("failed to get custom domain from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get custom domain from store") + } + + return customDomain, nil +} + +func (s *SqlStore) ListFreeDomains(ctx context.Context, accountID string) ([]string, error) { + return nil, nil +} + +func (s *SqlStore) ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error) { + tx := s.db + + var domains []*domain.Domain + result := tx.Find(&domains, accountIDCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get reverse proxy custom domains from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get reverse proxy custom domains from store") + } + + return domains, nil +} + +// GetCustomDomainByName returns the custom domain row holding the given name, +// regardless of which account owns it. +func (s *SqlStore) GetCustomDomainByName(ctx context.Context, domainName string) (*domain.Domain, error) { + customDomain := &domain.Domain{} + result := s.db.Take(customDomain, "domain = ?", domainName) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "custom domain %s not found", domainName) + } + + log.WithContext(ctx).Errorf("failed to get custom domain by name from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get custom domain from store") + } + + return customDomain, nil +} + +func (s *SqlStore) CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error) { + newDomain := &domain.Domain{ + ID: xid.New().String(), // Generate our own ID because gorm doesn't always configure the database to handle this for us. + Domain: domainName, + AccountID: accountID, + TargetCluster: targetCluster, + Type: domain.TypeCustom, + Validated: validated, + } + if !validated { + expiresAt := time.Now().UTC().Add(domain.ValidationTTL) + newDomain.ValidationExpiresAt = &expiresAt + } + result := s.db.Create(newDomain) + if result.Error != nil { + // The unique index is the last guard when two requests clear the + // manager's availability check at the same time. The one that loses the + // insert is a conflict, not an internal failure. + var count int64 + if err := s.db.Model(&domain.Domain{}).Where("domain = ?", domainName).Count(&count).Error; err == nil && count > 0 { + // The insert error is logged even on this path: the name being taken + // is what the caller has to act on, but if the insert also failed for + // an unrelated reason the operator still needs to see it. + log.WithContext(ctx).Warnf("create reverse proxy custom domain %s rejected, name already registered: %v", domainName, result.Error) + return nil, status.Errorf(status.AlreadyExists, "domain %s is already registered", domainName) + } + + log.WithContext(ctx).Errorf("failed to create reverse proxy custom domain to store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to create reverse proxy custom domain to store") + } + + return newDomain, nil +} + +// UpdateCustomDomain completes validation only while the original registration is pending. +func (s *SqlStore) UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error) { + if !d.Validated { + return nil, status.Errorf(status.InvalidArgument, "custom domain update must complete validation") + } + result := s.db.WithContext(ctx).Model(&domain.Domain{}). + Where(accountAndIDQueryCondition, accountID, d.ID). + Where("domain = ? AND target_cluster = ?", d.Domain, d.TargetCluster). + Where("validated = ? AND validation_expires_at > ?", false, time.Now().UTC()). + Update("validated", true) + if result.Error != nil { + return nil, fmt.Errorf("validate custom domain in store: %w", result.Error) + } + if result.RowsAffected == 0 { + return nil, status.Errorf(status.PreconditionFailed, "custom domain registration is no longer pending validation") + } + + return d, nil +} + +func (s *SqlStore) DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error { + result := s.db.Delete(domain.Domain{}, accountAndIDQueryCondition, accountID, domainID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete reverse proxy custom domain from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete reverse proxy custom domain from store") + } + + if result.RowsAffected == 0 { + return status.Errorf(status.NotFound, "reverse proxy custom domain %s not found", domainID) + } + + return nil +} diff --git a/management/server/store/sql_store_dns_record.go b/management/server/store/sql_store_dns_record.go new file mode 100644 index 000000000..8a8fc00a1 --- /dev/null +++ b/management/server/store/sql_store_dns_record.go @@ -0,0 +1,109 @@ +package store + +import ( + "context" + "errors" + + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/netbirdio/netbird/management/internals/modules/zones/records" + "github.com/netbirdio/netbird/shared/management/status" +) + +func (s *SqlStore) CreateDNSRecord(ctx context.Context, record *records.Record) error { + result := s.db.Create(record) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to create dns record to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to create dns record to store") + } + + return nil +} + +func (s *SqlStore) UpdateDNSRecord(ctx context.Context, record *records.Record) error { + result := s.db.Select("*").Save(record) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to update dns record to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to update dns record to store") + } + + return nil +} + +func (s *SqlStore) DeleteDNSRecord(ctx context.Context, accountID, zoneID, recordID string) error { + result := s.db.Delete(&records.Record{}, "account_id = ? AND zone_id = ? AND id = ?", accountID, zoneID, recordID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete dns record from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete dns record from store") + } + + if result.RowsAffected == 0 { + return status.NewDNSRecordNotFoundError(recordID) + } + + return nil +} + +func (s *SqlStore) GetDNSRecordByID(ctx context.Context, lockStrength LockingStrength, accountID, zoneID, recordID string) (*records.Record, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var record *records.Record + result := tx.Where("account_id = ? AND zone_id = ? AND id = ?", accountID, zoneID, recordID).Take(&record) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewDNSRecordNotFoundError(recordID) + } + + log.WithContext(ctx).Errorf("failed to get dns record from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get dns record from store") + } + + return record, nil +} + +func (s *SqlStore) GetZoneDNSRecords(ctx context.Context, lockStrength LockingStrength, accountID, zoneID string) ([]*records.Record, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var recordsList []*records.Record + result := tx.Where("account_id = ? AND zone_id = ?", accountID, zoneID).Find(&recordsList) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get zone dns records from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get zone dns records from store") + } + + return recordsList, nil +} + +func (s *SqlStore) GetZoneDNSRecordsByName(ctx context.Context, lockStrength LockingStrength, accountID, zoneID, name string) ([]*records.Record, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var recordsList []*records.Record + result := tx.Where("account_id = ? AND zone_id = ? AND name = ?", accountID, zoneID, name).Find(&recordsList) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get zone dns records by name from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get zone dns records by name from store") + } + + return recordsList, nil +} + +func (s *SqlStore) DeleteZoneDNSRecords(ctx context.Context, accountID, zoneID string) error { + result := s.db.Delete(&records.Record{}, "account_id = ? AND zone_id = ?", accountID, zoneID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete zone dns records from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete zone dns records from store") + } + + return nil +} diff --git a/management/server/store/sql_store_dns_record_test.go b/management/server/store/sql_store_dns_record_test.go new file mode 100644 index 000000000..045dca1e9 --- /dev/null +++ b/management/server/store/sql_store_dns_record_test.go @@ -0,0 +1,260 @@ +package store + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/zones" + "github.com/netbirdio/netbird/management/internals/modules/zones/records" + "github.com/netbirdio/netbird/shared/management/status" +) + +func TestSqlStore_CreateDNSRecord(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) + err = store.CreateZone(context.Background(), zone) + require.NoError(t, err) + + record := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300) + + err = store.CreateDNSRecord(context.Background(), record) + require.NoError(t, err) + + savedRecord, err := store.GetDNSRecordByID(context.Background(), LockingStrengthNone, accountID, zone.ID, record.ID) + require.NoError(t, err) + require.NotNil(t, savedRecord) + assert.Equal(t, record.ID, savedRecord.ID) + assert.Equal(t, record.Name, savedRecord.Name) + assert.Equal(t, record.Type, savedRecord.Type) + assert.Equal(t, record.Content, savedRecord.Content) + assert.Equal(t, record.TTL, savedRecord.TTL) + assert.Equal(t, zone.ID, savedRecord.ZoneID) +} + +func TestSqlStore_GetDNSRecordByID(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) + err = store.CreateZone(context.Background(), zone) + require.NoError(t, err) + + record := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300) + err = store.CreateDNSRecord(context.Background(), record) + require.NoError(t, err) + + tests := []struct { + name string + accountID string + zoneID string + recordID string + expectError bool + }{ + { + name: "retrieve existing record", + accountID: accountID, + zoneID: zone.ID, + recordID: record.ID, + expectError: false, + }, + { + name: "retrieve non-existing record", + accountID: accountID, + zoneID: zone.ID, + recordID: "non-existing", + expectError: true, + }, + { + name: "retrieve with empty record ID", + accountID: accountID, + zoneID: zone.ID, + recordID: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savedRecord, err := store.GetDNSRecordByID(context.Background(), LockingStrengthNone, tt.accountID, tt.zoneID, tt.recordID) + if tt.expectError { + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) + require.Nil(t, savedRecord) + } else { + require.NoError(t, err) + require.NotNil(t, savedRecord) + assert.Equal(t, tt.recordID, savedRecord.ID) + } + }) + } +} + +func TestSqlStore_GetZoneDNSRecords(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) + err = store.CreateZone(context.Background(), zone) + require.NoError(t, err) + + recordA := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300) + err = store.CreateDNSRecord(context.Background(), recordA) + require.NoError(t, err) + + recordAAAA := records.NewRecord(accountID, zone.ID, "ipv6.example.com", records.RecordTypeAAAA, "2001:db8::1", 300) + err = store.CreateDNSRecord(context.Background(), recordAAAA) + require.NoError(t, err) + + recordCNAME := records.NewRecord(accountID, zone.ID, "alias.example.com", records.RecordTypeCNAME, "www.example.com", 300) + err = store.CreateDNSRecord(context.Background(), recordCNAME) + require.NoError(t, err) + + allRecords, err := store.GetZoneDNSRecords(context.Background(), LockingStrengthNone, accountID, zone.ID) + require.NoError(t, err) + require.NotNil(t, allRecords) + assert.Equal(t, 3, len(allRecords)) + + recordIDs := make(map[string]bool) + for _, r := range allRecords { + recordIDs[r.ID] = true + } + assert.True(t, recordIDs[recordA.ID]) + assert.True(t, recordIDs[recordAAAA.ID]) + assert.True(t, recordIDs[recordCNAME.ID]) +} + +func TestSqlStore_GetZoneDNSRecordsByName(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) + err = store.CreateZone(context.Background(), zone) + require.NoError(t, err) + + record1 := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300) + err = store.CreateDNSRecord(context.Background(), record1) + require.NoError(t, err) + + record2 := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeAAAA, "2001:db8::1", 300) + err = store.CreateDNSRecord(context.Background(), record2) + require.NoError(t, err) + + record3 := records.NewRecord(accountID, zone.ID, "mail.example.com", records.RecordTypeA, "192.168.1.2", 600) + err = store.CreateDNSRecord(context.Background(), record3) + require.NoError(t, err) + + recordsByName, err := store.GetZoneDNSRecordsByName(context.Background(), LockingStrengthNone, accountID, zone.ID, "www.example.com") + require.NoError(t, err) + require.NotNil(t, recordsByName) + assert.Equal(t, 2, len(recordsByName)) + + for _, r := range recordsByName { + assert.Equal(t, "www.example.com", r.Name) + } +} + +func TestSqlStore_UpdateDNSRecord(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) + err = store.CreateZone(context.Background(), zone) + require.NoError(t, err) + + record := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300) + err = store.CreateDNSRecord(context.Background(), record) + require.NoError(t, err) + + record.Name = "api.example.com" + record.Content = "192.168.1.100" + record.TTL = 600 + + err = store.UpdateDNSRecord(context.Background(), record) + require.NoError(t, err) + + updatedRecord, err := store.GetDNSRecordByID(context.Background(), LockingStrengthNone, accountID, zone.ID, record.ID) + require.NoError(t, err) + require.NotNil(t, updatedRecord) + assert.Equal(t, "api.example.com", updatedRecord.Name) + assert.Equal(t, "192.168.1.100", updatedRecord.Content) + assert.Equal(t, 600, updatedRecord.TTL) +} + +func TestSqlStore_DeleteDNSRecord(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) + err = store.CreateZone(context.Background(), zone) + require.NoError(t, err) + + record := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300) + err = store.CreateDNSRecord(context.Background(), record) + require.NoError(t, err) + + err = store.DeleteDNSRecord(context.Background(), accountID, zone.ID, record.ID) + require.NoError(t, err) + + deletedRecord, err := store.GetDNSRecordByID(context.Background(), LockingStrengthNone, accountID, zone.ID, record.ID) + require.Error(t, err) + require.Nil(t, deletedRecord) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) +} + +func TestSqlStore_DeleteZoneDNSRecords(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) + err = store.CreateZone(context.Background(), zone) + require.NoError(t, err) + + record1 := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300) + err = store.CreateDNSRecord(context.Background(), record1) + require.NoError(t, err) + + record2 := records.NewRecord(accountID, zone.ID, "mail.example.com", records.RecordTypeA, "192.168.1.2", 600) + err = store.CreateDNSRecord(context.Background(), record2) + require.NoError(t, err) + + allRecords, err := store.GetZoneDNSRecords(context.Background(), LockingStrengthNone, accountID, zone.ID) + require.NoError(t, err) + assert.Equal(t, 2, len(allRecords)) + + err = store.DeleteZoneDNSRecords(context.Background(), accountID, zone.ID) + require.NoError(t, err) + + remainingRecords, err := store.GetZoneDNSRecords(context.Background(), LockingStrengthNone, accountID, zone.ID) + require.NoError(t, err) + assert.Equal(t, 0, len(remainingRecords)) +} diff --git a/management/server/store/sql_store_group.go b/management/server/store/sql_store_group.go new file mode 100644 index 000000000..16e812048 --- /dev/null +++ b/management/server/store/sql_store_group.go @@ -0,0 +1,356 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + + "github.com/jackc/pgx/v5" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +// CreateGroups creates the given list of groups to the database. +// groupUpsertColumns is the explicit allowlist of columns that get updated when +// CreateGroups / UpdateGroups hit a PK conflict. public_id is intentionally +// omitted so a caller passing an entity with the zero value (e.g. an HTTP +// handler-built struct) cannot reset the persisted public_id during an upsert. +// Keep this in sync with the Group schema in management/server/types/group.go. +func groupUpsertColumns() clause.Set { + return clause.AssignmentColumns([]string{ + "account_id", + "name", + "issued", + "integration_ref_id", + "integration_ref_integration_type", + "resources", + }) +} + +func (s *SqlStore) CreateGroups(ctx context.Context, accountID string, groups []*types.Group) error { + if len(groups) == 0 { + return nil + } + + return s.db.Transaction(func(tx *gorm.DB) error { + result := tx. + Clauses( + clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + Where: clause.Where{Exprs: []clause.Expression{clause.Eq{Column: "groups.account_id", Value: accountID}}}, + DoUpdates: groupUpsertColumns(), + }, + ). + Omit(clause.Associations). + Create(&groups) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save groups to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to save groups to store") + } + + return nil + }) +} + +// UpdateGroups updates the given list of groups to the database. +func (s *SqlStore) UpdateGroups(ctx context.Context, accountID string, groups []*types.Group) error { + if len(groups) == 0 { + return nil + } + + return s.db.Transaction(func(tx *gorm.DB) error { + result := tx. + Clauses( + clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + Where: clause.Where{Exprs: []clause.Expression{clause.Eq{Column: "groups.account_id", Value: accountID}}}, + DoUpdates: groupUpsertColumns(), + }, + ). + Omit(clause.Associations). + Create(&groups) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save groups to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to save groups to store") + } + + return nil + }) +} + +func (s *SqlStore) GetAccountGroups(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.Group, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var groups []*types.Group + result := tx.Preload(clause.Associations).Find(&groups, accountIDCondition, accountID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "accountID not found: index lookup failed") + } + log.WithContext(ctx).Errorf("failed to get account groups from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get account groups from the store") + } + + for _, g := range groups { + g.LoadGroupPeers() + } + + return groups, nil +} + +func (s *SqlStore) GetResourceGroups(ctx context.Context, lockStrength LockingStrength, accountID, resourceID string) ([]*types.Group, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var groups []*types.Group + + likePattern := `%"ID":"` + resourceID + `"%` + + result := tx. + Preload(clause.Associations). + Where("resources LIKE ?", likePattern). + Find(&groups) + + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, result.Error + } + + for _, g := range groups { + g.LoadGroupPeers() + } + + return groups, nil +} + +func (s *SqlStore) getGroups(ctx context.Context, accountID string) ([]*types.Group, error) { + const query = `SELECT id, account_id, public_id, name, issued, resources, integration_ref_id, integration_ref_integration_type FROM groups WHERE account_id = $1` + rows, err := s.pool.Query(ctx, query, accountID) + if err != nil { + return nil, err + } + groups, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (*types.Group, error) { + var g types.Group + var resources []byte + var refID sql.NullInt64 + var refType sql.NullString + err := row.Scan(&g.ID, &g.AccountID, &g.PublicID, &g.Name, &g.Issued, &resources, &refID, &refType) + if err == nil { + if refID.Valid { + g.IntegrationReference.ID = int(refID.Int64) + } + if refType.Valid { + g.IntegrationReference.IntegrationType = refType.String + } + if resources != nil { + _ = json.Unmarshal(resources, &g.Resources) + } else { + g.Resources = []types.Resource{} + } + g.GroupPeers = []types.GroupPeer{} + g.Peers = []string{} + } + return &g, err + }) + if err != nil { + return nil, err + } + return groups, nil +} + +// AddResourceToGroup adds a resource to a group. Method always needs to run n a transaction +func (s *SqlStore) AddResourceToGroup(ctx context.Context, accountId string, groupID string, resource *types.Resource) error { + var group types.Group + result := s.db.Where(accountAndIDQueryCondition, accountId, groupID).Take(&group) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return status.NewGroupNotFoundError(groupID) + } + + return status.Errorf(status.Internal, "issue finding group: %s", result.Error) + } + + for _, res := range group.Resources { + if res.ID == resource.ID { + return nil + } + } + + group.Resources = append(group.Resources, *resource) + + if err := s.db.Save(&group).Error; err != nil { + return status.Errorf(status.Internal, "issue updating group: %s", err) + } + + return nil +} + +// RemoveResourceFromGroup removes a resource from a group. Method always needs to run in a transaction +func (s *SqlStore) RemoveResourceFromGroup(ctx context.Context, accountId string, groupID string, resourceID string) error { + var group types.Group + result := s.db.Where(accountAndIDQueryCondition, accountId, groupID).Take(&group) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return status.NewGroupNotFoundError(groupID) + } + + return status.Errorf(status.Internal, "issue finding group: %s", result.Error) + } + + for i, res := range group.Resources { + if res.ID == resourceID { + group.Resources = append(group.Resources[:i], group.Resources[i+1:]...) + break + } + } + + if err := s.db.Save(&group).Error; err != nil { + return status.Errorf(status.Internal, "issue updating group: %s", err) + } + + return nil +} + +// GetGroupByID retrieves a group by ID and account ID. +func (s *SqlStore) GetGroupByID(ctx context.Context, lockStrength LockingStrength, accountID, groupID string) (*types.Group, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var group *types.Group + result := tx.Preload(clause.Associations).Take(&group, accountAndIDQueryCondition, accountID, groupID) + if err := result.Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, status.NewGroupNotFoundError(groupID) + } + log.WithContext(ctx).Errorf("failed to get group from store: %s", err) + return nil, status.Errorf(status.Internal, "failed to get group from store") + } + + group.LoadGroupPeers() + + return group, nil +} + +// GetGroupByName retrieves a group by name and account ID. +func (s *SqlStore) GetGroupByName(ctx context.Context, lockStrength LockingStrength, accountID, groupName string) (*types.Group, error) { + tx := s.db + + var group types.Group + + // TODO: This fix is accepted for now, but if we need to handle this more frequently + // we may need to reconsider changing the types. + query := tx.Preload(clause.Associations) + + result := query. + Model(&types.Group{}). + Joins("LEFT JOIN group_peers ON group_peers.group_id = groups.id"). + Where("groups.account_id = ? AND groups.name = ?", accountID, groupName). + Group("groups.id"). + Order("COUNT(group_peers.peer_id) DESC"). + Limit(1). + First(&group) + if err := result.Error; err != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewGroupNotFoundError(groupName) + } + log.WithContext(ctx).Errorf("failed to get group by name from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get group by name from store") + } + + group.LoadGroupPeers() + + return &group, nil +} + +// GetGroupsByIDs retrieves groups by their IDs and account ID. +func (s *SqlStore) GetGroupsByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, groupIDs []string) (map[string]*types.Group, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var groups []*types.Group + result := tx.Preload(clause.Associations).Find(&groups, accountAndIDsQueryCondition, accountID, groupIDs) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get groups by ID's from store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get groups by ID's from store") + } + + groupsMap := make(map[string]*types.Group) + for _, group := range groups { + group.LoadGroupPeers() + groupsMap[group.ID] = group + } + + return groupsMap, nil +} + +// CreateGroup creates a group in the store. +func (s *SqlStore) CreateGroup(ctx context.Context, group *types.Group) error { + if group == nil { + return status.Errorf(status.InvalidArgument, "group is nil") + } + + if err := s.db.Omit(clause.Associations).Create(group).Error; err != nil { + log.WithContext(ctx).Errorf("failed to save group to store: %v", err) + return status.Errorf(status.Internal, "failed to save group to store") + } + + return nil +} + +// UpdateGroup updates a group in the store. +func (s *SqlStore) UpdateGroup(ctx context.Context, group *types.Group) error { + if group == nil { + return status.Errorf(status.InvalidArgument, "group is nil") + } + + if err := s.db.Omit(clause.Associations, "public_id").Save(group).Error; err != nil { + log.WithContext(ctx).Errorf("failed to save group to store: %v", err) + return status.Errorf(status.Internal, "failed to save group to store") + } + + return nil +} + +// DeleteGroup deletes a group from the database. +func (s *SqlStore) DeleteGroup(ctx context.Context, accountID, groupID string) error { + result := s.db.Select(clause.Associations). + Delete(&types.Group{}, accountAndIDQueryCondition, accountID, groupID) + if err := result.Error; err != nil { + log.WithContext(ctx).Errorf("failed to delete group from store: %s", result.Error) + return status.Errorf(status.Internal, "failed to delete group from store") + } + + if result.RowsAffected == 0 { + return status.NewGroupNotFoundError(groupID) + } + + return nil +} + +// DeleteGroups deletes groups from the database. +func (s *SqlStore) DeleteGroups(ctx context.Context, accountID string, groupIDs []string) error { + result := s.db.Select(clause.Associations). + Delete(&types.Group{}, accountAndIDsQueryCondition, accountID, groupIDs) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete groups from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete groups from store") + } + + return nil +} diff --git a/management/server/store/sql_store_group_peer.go b/management/server/store/sql_store_group_peer.go new file mode 100644 index 000000000..6b0339816 --- /dev/null +++ b/management/server/store/sql_store_group_peer.go @@ -0,0 +1,229 @@ +package store + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +func (s *SqlStore) getGroupPeers(ctx context.Context, groupIDs []string) ([]types.GroupPeer, error) { + if len(groupIDs) == 0 { + return nil, nil + } + const query = `SELECT account_id, group_id, peer_id FROM group_peers WHERE group_id = ANY($1)` + rows, err := s.pool.Query(ctx, query, groupIDs) + if err != nil { + return nil, err + } + groupPeers, err := pgx.CollectRows(rows, pgx.RowToStructByName[types.GroupPeer]) + if err != nil { + return nil, err + } + return groupPeers, nil +} + +// AddPeerToAllGroup adds a peer to the 'All' group. Method always needs to run in a transaction +func (s *SqlStore) AddPeerToAllGroup(ctx context.Context, accountID string, peerID string) error { + var groupID string + _ = s.db.Model(types.Group{}). + Select("id"). + Where("account_id = ? AND name = ?", accountID, "All"). + Limit(1). + Scan(&groupID) + + if groupID == "" { + return status.Errorf(status.NotFound, "group 'All' not found for account %s", accountID) + } + + err := s.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "group_id"}, {Name: "peer_id"}}, + DoNothing: true, + }).Create(&types.GroupPeer{ + AccountID: accountID, + GroupID: groupID, + PeerID: peerID, + }).Error + if err != nil { + return status.Errorf(status.Internal, "error adding peer to group 'All': %v", err) + } + + return nil +} + +// AddPeerToGroup adds a peer to a group +func (s *SqlStore) AddPeerToGroup(ctx context.Context, accountID, peerID, groupID string) error { + peer := &types.GroupPeer{ + AccountID: accountID, + GroupID: groupID, + PeerID: peerID, + } + + err := s.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "group_id"}, {Name: "peer_id"}}, + DoNothing: true, + }).Create(peer).Error + if err != nil { + log.WithContext(ctx).Errorf("failed to add peer %s to group %s for account %s: %v", peerID, groupID, accountID, err) + return status.Errorf(status.Internal, "failed to add peer to group") + } + + return nil +} + +// RemovePeerFromGroup removes a peer from a group +func (s *SqlStore) RemovePeerFromGroup(ctx context.Context, peerID string, groupID string) error { + err := s.db. + Delete(&types.GroupPeer{}, "group_id = ? AND peer_id = ?", groupID, peerID).Error + if err != nil { + log.WithContext(ctx).Errorf("failed to remove peer %s from group %s: %v", peerID, groupID, err) + return status.Errorf(status.Internal, "failed to remove peer from group") + } + + return nil +} + +// RemovePeerFromAllGroups removes a peer from all groups +func (s *SqlStore) RemovePeerFromAllGroups(ctx context.Context, peerID string) error { + err := s.db. + Delete(&types.GroupPeer{}, "peer_id = ?", peerID).Error + if err != nil { + log.WithContext(ctx).Errorf("failed to remove peer %s from all groups: %v", peerID, err) + return status.Errorf(status.Internal, "failed to remove peer from all groups") + } + + return nil +} + +// GetPeerGroups retrieves all groups assigned to a specific peer in a given account. +func (s *SqlStore) GetPeerGroups(ctx context.Context, lockStrength LockingStrength, accountId string, peerId string) ([]*types.Group, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var groups []*types.Group + query := tx. + Joins("JOIN group_peers ON group_peers.group_id = groups.id"). + Where("groups.account_id = ? AND group_peers.peer_id = ?", accountId, peerId). + Preload(clause.Associations). + Find(&groups) + + if query.Error != nil { + return nil, query.Error + } + + for _, group := range groups { + group.LoadGroupPeers() + } + + return groups, nil +} + +// GetPeerGroupIDs retrieves all group IDs assigned to a specific peer in a given account. +func (s *SqlStore) GetPeerGroupIDs(ctx context.Context, lockStrength LockingStrength, accountId string, peerId string) ([]string, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var groupIDs []string + query := tx. + Model(&types.GroupPeer{}). + Where("account_id = ? AND peer_id = ?", accountId, peerId). + Pluck("group_id", &groupIDs) + + if query.Error != nil { + if errors.Is(query.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "no groups found for peer %s in account %s", peerId, accountId) + } + log.WithContext(ctx).Errorf("failed to get group IDs for peer %s in account %s: %v", peerId, accountId, query.Error) + return nil, status.Errorf(status.Internal, "failed to get group IDs for peer from store") + } + + return groupIDs, nil +} + +func (s *SqlStore) GetAccountGroupPeers(ctx context.Context, lockStrength LockingStrength, accountID string) (map[string]map[string]struct{}, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var peers []types.GroupPeer + result := tx.Find(&peers, accountIDCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get account group peers from store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get account group peers from store") + } + + groupPeers := make(map[string]map[string]struct{}) + for _, peer := range peers { + if _, exists := groupPeers[peer.GroupID]; !exists { + groupPeers[peer.GroupID] = make(map[string]struct{}) + } + groupPeers[peer.GroupID][peer.PeerID] = struct{}{} + } + + return groupPeers, nil +} + +func (s *SqlStore) GetPeersByGroupIDs(ctx context.Context, accountID string, groupIDs []string) ([]*nbpeer.Peer, error) { + if len(groupIDs) == 0 { + return []*nbpeer.Peer{}, nil + } + + var peers []*nbpeer.Peer + peerIDsSubquery := s.db.Model(&types.GroupPeer{}). + Select("DISTINCT peer_id"). + Where("account_id = ? AND group_id IN ?", accountID, groupIDs) + + result := s.db.Where("account_id = ? AND id IN (?)", accountID, peerIDsSubquery).Find(&peers) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get peers by group IDs: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get peers by group IDs") + } + + return peers, nil +} + +func (s *SqlStore) GetPeerIDsByGroups(ctx context.Context, accountID string, groupIDs []string) ([]string, error) { + if len(groupIDs) == 0 { + return nil, nil + } + + var peerIDs []string + result := s.db.Model(&types.GroupPeer{}). + Select("DISTINCT peer_id"). + Where("account_id = ? AND group_id IN ?", accountID, groupIDs). + Pluck("peer_id", &peerIDs) + if result.Error != nil { + return nil, status.Errorf(status.Internal, "failed to get peer IDs by groups: %s", result.Error) + } + + return peerIDs, nil +} + +func (s *SqlStore) GetGroupIDsByPeerIDs(ctx context.Context, accountID string, peerIDs []string) ([]string, error) { + if len(peerIDs) == 0 { + return nil, nil + } + + var groupIDs []string + result := s.db.Model(&types.GroupPeer{}). + Select("DISTINCT group_id"). + Where("account_id = ? AND peer_id IN ?", accountID, peerIDs). + Pluck("group_id", &groupIDs) + if result.Error != nil { + return nil, status.Errorf(status.Internal, "failed to get group IDs by peers: %s", result.Error) + } + + return groupIDs, nil +} diff --git a/management/server/store/sql_store_group_peer_test.go b/management/server/store/sql_store_group_peer_test.go new file mode 100644 index 000000000..9f9a9e483 --- /dev/null +++ b/management/server/store/sql_store_group_peer_test.go @@ -0,0 +1,210 @@ +package store + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" +) + +func TestSqlStore_AddPeerToGroup(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_policy_migrate.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + peerID := "cfefqs706sqkneg59g4g" + groupID := "cfefqs706sqkneg59g4h" + + group, err := store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, groupID) + require.NoError(t, err, "failed to get group") + require.Len(t, group.Peers, 0, "group should have 0 peers") + + err = store.AddPeerToGroup(context.Background(), accountID, peerID, groupID) + require.NoError(t, err, "failed to add peer to group") + + group, err = store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, groupID) + require.NoError(t, err, "failed to get group") + require.Len(t, group.Peers, 1, "group should have 1 peers") + require.Contains(t, group.Peers, peerID) +} + +func TestSqlStore_AddPeerToAllGroup(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_policy_migrate.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + groupID := "cfefqs706sqkneg59g3g" + + peer := &nbpeer.Peer{ + ID: "peer1", + AccountID: accountID, + DNSLabel: "peer1.domain.test", + } + + group, err := store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, groupID) + require.NoError(t, err, "failed to get group") + require.Len(t, group.Peers, 2, "group should have 2 peers") + require.NotContains(t, group.Peers, peer.ID) + + err = store.AddPeerToAccount(context.Background(), peer) + require.NoError(t, err, "failed to add peer to account") + + err = store.AddPeerToAllGroup(context.Background(), accountID, peer.ID) + require.NoError(t, err, "failed to add peer to all group") + + group, err = store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, groupID) + require.NoError(t, err, "failed to get group") + require.Len(t, group.Peers, 3, "group should have peers") + require.Contains(t, group.Peers, peer.ID) +} + +func TestSqlStore_GetPeerGroups(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_policy_migrate.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + peerID := "cfefqs706sqkneg59g4g" + + groups, err := store.GetPeerGroups(context.Background(), LockingStrengthNone, accountID, peerID) + require.NoError(t, err) + assert.Len(t, groups, 1) + assert.Equal(t, groups[0].Name, "All") + + err = store.AddPeerToGroup(context.Background(), accountID, peerID, "cfefqs706sqkneg59g4h") + require.NoError(t, err) + + groups, err = store.GetPeerGroups(context.Background(), LockingStrengthNone, accountID, peerID) + require.NoError(t, err) + assert.Len(t, groups, 2) + + foreignPeerID := "foreign-peer" + err = store.AddPeerToGroup(context.Background(), accountID, foreignPeerID, "cfefqs706sqkneg59g4h") + require.NoError(t, err) + + groups, err = store.GetPeerGroups(context.Background(), LockingStrengthNone, "other-account", foreignPeerID) + require.NoError(t, err) + assert.Empty(t, groups, "groups of another account must not be returned") +} + +func TestSqlStore_GetPeersByGroupIDs(t *testing.T) { + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + group1ID := "test-group-1" + group2ID := "test-group-2" + emptyGroupID := "empty-group" + + peer1 := "cfefqs706sqkneg59g4g" + peer2 := "cfeg6sf06sqkneg59g50" + + tests := []struct { + name string + groupIDs []string + expectedPeers []string + expectedCount int + }{ + { + name: "retrieve peers from single group with multiple peers", + groupIDs: []string{group1ID}, + expectedPeers: []string{peer1, peer2}, + expectedCount: 2, + }, + { + name: "retrieve peers from single group with one peer", + groupIDs: []string{group2ID}, + expectedPeers: []string{peer1}, + expectedCount: 1, + }, + { + name: "retrieve peers from multiple groups (with overlap)", + groupIDs: []string{group1ID, group2ID}, + expectedPeers: []string{peer1, peer2}, // should deduplicate + expectedCount: 2, + }, + { + name: "retrieve peers from existing 'All' group", + groupIDs: []string{"cfefqs706sqkneg59g3g"}, // All group from test data + expectedPeers: []string{peer1, peer2}, + expectedCount: 2, + }, + { + name: "retrieve peers from empty group", + groupIDs: []string{emptyGroupID}, + expectedPeers: []string{}, + expectedCount: 0, + }, + { + name: "retrieve peers from non-existing group", + groupIDs: []string{"non-existing-group"}, + expectedPeers: []string{}, + expectedCount: 0, + }, + { + name: "empty group IDs list", + groupIDs: []string{}, + expectedPeers: []string{}, + expectedCount: 0, + }, + { + name: "mix of existing and non-existing groups", + groupIDs: []string{group1ID, "non-existing-group"}, + expectedPeers: []string{peer1, peer2}, + expectedCount: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_policy_migrate.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + ctx := context.Background() + + groups := []*types.Group{ + { + ID: group1ID, + AccountID: accountID, + }, + { + ID: group2ID, + AccountID: accountID, + }, + } + require.NoError(t, store.CreateGroups(ctx, accountID, groups)) + + otherAccount := newAccountWithId(ctx, "other-account", "other-user", "") + require.NoError(t, store.SaveAccount(ctx, otherAccount)) + foreignPeer := &nbpeer.Peer{ID: "foreign-peer", AccountID: otherAccount.Id} + require.NoError(t, store.AddPeerToAccount(ctx, foreignPeer)) + + require.NoError(t, store.AddPeerToGroup(ctx, accountID, peer1, group1ID)) + require.NoError(t, store.AddPeerToGroup(ctx, accountID, peer2, group1ID)) + require.NoError(t, store.AddPeerToGroup(ctx, accountID, peer1, group2ID)) + require.NoError(t, store.AddPeerToGroup(ctx, accountID, foreignPeer.ID, group1ID)) + + peers, err := store.GetPeersByGroupIDs(ctx, accountID, tt.groupIDs) + require.NoError(t, err) + require.Len(t, peers, tt.expectedCount) + + if tt.expectedCount > 0 { + actualPeerIDs := make([]string, len(peers)) + for i, peer := range peers { + actualPeerIDs[i] = peer.ID + } + assert.ElementsMatch(t, tt.expectedPeers, actualPeerIDs) + + // Verify all returned peers belong to the correct account + for _, peer := range peers { + assert.Equal(t, accountID, peer.AccountID) + } + } + }) + } +} diff --git a/management/server/store/sql_store_group_test.go b/management/server/store/sql_store_group_test.go new file mode 100644 index 000000000..58291ab44 --- /dev/null +++ b/management/server/store/sql_store_group_test.go @@ -0,0 +1,289 @@ +package store + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +func TestSqlite_GetGroupByName(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + if err != nil { + t.Fatal(err) + } + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + group, err := store.GetGroupByName(context.Background(), LockingStrengthNone, accountID, "All") + require.NoError(t, err) + require.True(t, group.IsGroupAll()) +} + +func TestSqlStore_GetGroupsByIDs(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + tests := []struct { + name string + groupIDs []string + expectedCount int + }{ + { + name: "retrieve existing groups by existing IDs", + groupIDs: []string{"cfefqs706sqkneg59g4g", "cfefqs706sqkneg59g3g"}, + expectedCount: 2, + }, + { + name: "empty group IDs list", + groupIDs: []string{}, + expectedCount: 0, + }, + { + name: "non-existing group IDs", + groupIDs: []string{"nonexistent1", "nonexistent2"}, + expectedCount: 0, + }, + { + name: "mixed existing and non-existing group IDs", + groupIDs: []string{"cfefqs706sqkneg59g4g", "nonexistent"}, + expectedCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + groups, err := store.GetGroupsByIDs(context.Background(), LockingStrengthNone, accountID, tt.groupIDs) + require.NoError(t, err) + require.Len(t, groups, tt.expectedCount) + }) + } +} + +func TestSqlStore_CreateGroup(t *testing.T) { + if os.Getenv("CI") == "true" { + t.Log("Skipping MySQL test on CI") + } + t.Setenv("NETBIRD_STORE_ENGINE", string(types.MysqlStoreEngine)) + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + group := &types.Group{ + ID: "group-id", + AccountID: accountID, + Issued: "api", + Peers: []string{}, + Resources: []types.Resource{}, + GroupPeers: []types.GroupPeer{}, + } + err = store.CreateGroup(context.Background(), group) + require.NoError(t, err) + + savedGroup, err := store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, "group-id") + require.NoError(t, err) + require.Equal(t, savedGroup, group) +} + +func TestSqlStore_CreateUpdateGroups(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + groups := []*types.Group{ + { + ID: "group-1", + AccountID: accountID, + Issued: "api", + Peers: []string{}, + Resources: []types.Resource{}, + GroupPeers: []types.GroupPeer{}, + }, + { + ID: "group-2", + AccountID: accountID, + Issued: "integration", + Peers: []string{}, + Resources: []types.Resource{}, + GroupPeers: []types.GroupPeer{}, + }, + } + err = store.CreateGroups(context.Background(), accountID, groups) + require.NoError(t, err) + + groups[1].Peers = []string{} + err = store.UpdateGroups(context.Background(), accountID, groups) + require.NoError(t, err) + + group, err := store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, groups[1].ID) + require.NoError(t, err) + require.Equal(t, groups[1], group) +} + +func TestSqlStore_DeleteGroup(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + tests := []struct { + name string + groupID string + expectError bool + }{ + { + name: "delete existing group", + groupID: "cfefqs706sqkneg59g4g", + expectError: false, + }, + { + name: "delete non-existing group", + groupID: "non-existing-group-id", + expectError: true, + }, + { + name: "delete with empty group ID", + groupID: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := store.DeleteGroup(context.Background(), accountID, tt.groupID) + if tt.expectError { + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) + } else { + require.NoError(t, err) + + group, err := store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, tt.groupID) + require.Error(t, err) + require.Nil(t, group) + } + }) + } +} + +func TestSqlStore_DeleteGroups(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + tests := []struct { + name string + groupIDs []string + expectError bool + }{ + { + name: "delete multiple existing groups", + groupIDs: []string{"cfefqs706sqkneg59g4g", "cfefqs706sqkneg59g3g"}, + expectError: false, + }, + { + name: "delete non-existing groups", + groupIDs: []string{"non-existing-id-1", "non-existing-id-2"}, + expectError: false, + }, + { + name: "delete with empty group IDs list", + groupIDs: []string{}, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := store.DeleteGroups(context.Background(), accountID, tt.groupIDs) + if tt.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + + for _, groupID := range tt.groupIDs { + group, err := store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, groupID) + require.Error(t, err) + require.Nil(t, group) + } + } + }) + } +} + +func TestSqlStore_AddAndRemoveResourceFromGroup(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + require.NoError(t, err) + t.Cleanup(cleanup) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + resourceId := "ctc4nci7qv9061u6ilfg" + groupID := "cs1tnh0hhcjnqoiuebeg" + + res := &types.Resource{ + ID: resourceId, + Type: "host", + } + err = store.AddResourceToGroup(context.Background(), accountID, groupID, res) + require.NoError(t, err) + + group, err := store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, groupID) + require.NoError(t, err) + require.Contains(t, group.Resources, *res) + + groups, err := store.GetResourceGroups(context.Background(), LockingStrengthNone, accountID, resourceId) + require.NoError(t, err) + require.Len(t, groups, 1) + + err = store.RemoveResourceFromGroup(context.Background(), accountID, groupID, res.ID) + require.NoError(t, err) + + group, err = store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, groupID) + require.NoError(t, err) + require.NotContains(t, group.Resources, *res) +} + +func TestSqlStore_SaveGroups_LargeBatch(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + accountGroups, err := store.GetAccountGroups(context.Background(), LockingStrengthNone, accountID) + require.NoError(t, err) + require.Len(t, accountGroups, 3) + + groupsToSave := make([]*types.Group, 0) + + for i := 1; i <= 8000; i++ { + groupsToSave = append(groupsToSave, &types.Group{ + ID: fmt.Sprintf("%d", i), + AccountID: accountID, + Name: fmt.Sprintf("group-%d", i), + }) + } + + err = store.CreateGroups(context.Background(), accountID, groupsToSave) + require.NoError(t, err) + + accountGroups, err = store.GetAccountGroups(context.Background(), LockingStrengthNone, accountID) + require.NoError(t, err) + require.Equal(t, 8003, len(accountGroups)) +} diff --git a/management/server/store/sql_store_installation.go b/management/server/store/sql_store_installation.go new file mode 100644 index 000000000..2bdfb9af1 --- /dev/null +++ b/management/server/store/sql_store_installation.go @@ -0,0 +1,29 @@ +package store + +import ( + "context" + + "gorm.io/gorm/clause" +) + +type installation struct { + ID uint `gorm:"primaryKey"` + InstallationIDValue string +} + +func (s *SqlStore) SaveInstallationID(_ context.Context, ID string) error { + installation := installation{InstallationIDValue: ID} + installation.ID = uint(s.installationPK) + + return s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&installation).Error +} + +func (s *SqlStore) GetInstallationID() string { + var installation installation + + if result := s.db.Take(&installation, idQueryCondition, s.installationPK); result.Error != nil { + return "" + } + + return installation.InstallationIDValue +} diff --git a/management/server/store/sql_store_job.go b/management/server/store/sql_store_job.go new file mode 100644 index 000000000..b5cc6c603 --- /dev/null +++ b/management/server/store/sql_store_job.go @@ -0,0 +1,103 @@ +package store + +import ( + "context" + "errors" + "time" + + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +// SaveJob persists a job in DB +func (s *SqlStore) CreatePeerJob(ctx context.Context, job *types.Job) error { + result := s.db.Create(job) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to create job in store: %s", result.Error) + return status.Errorf(status.Internal, "failed to create job in store") + } + return nil +} + +func (s *SqlStore) CompletePeerJob(ctx context.Context, job *types.Job) error { + result := s.db. + Model(&types.Job{}). + Where(idQueryCondition, job.ID). + Updates(job) + + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to update job in store: %s", result.Error) + return status.Errorf(status.Internal, "failed to update job in store") + } + return nil +} + +// job was pending for too long and has been cancelled +func (s *SqlStore) MarkPendingJobsAsFailed(ctx context.Context, accountID, peerID, jobID, reason string) error { + now := time.Now().UTC() + result := s.db. + Model(&types.Job{}). + Where(accountAndPeerIDQueryCondition+" AND id = ?"+" AND status = ?", accountID, peerID, jobID, types.JobStatusPending). + Updates(types.Job{ + Status: types.JobStatusFailed, + FailedReason: reason, + CompletedAt: &now, + }) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to mark pending jobs as Failed job in store: %s", result.Error) + return status.Errorf(status.Internal, "failed to mark pending job as Failed in store") + } + return nil +} + +// job was pending for too long and has been cancelled +func (s *SqlStore) MarkAllPendingJobsAsFailed(ctx context.Context, accountID, peerID, reason string) error { + now := time.Now().UTC() + result := s.db. + Model(&types.Job{}). + Where(accountAndPeerIDQueryCondition+" AND status = ?", accountID, peerID, types.JobStatusPending). + Updates(types.Job{ + Status: types.JobStatusFailed, + FailedReason: reason, + CompletedAt: &now, + }) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to mark pending jobs as Failed job in store: %s", result.Error) + return status.Errorf(status.Internal, "failed to mark pending job as Failed in store") + } + return nil +} + +// GetJobByID fetches job by ID +func (s *SqlStore) GetPeerJobByID(ctx context.Context, accountID, jobID string) (*types.Job, error) { + var job types.Job + err := s.db. + Where(accountAndIDQueryCondition, accountID, jobID). + First(&job).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "job %s not found", jobID) + } + if err != nil { + log.WithContext(ctx).Errorf("failed to fetch job from store: %s", err) + return nil, err + } + return &job, nil +} + +// get all jobs +func (s *SqlStore) GetPeerJobs(ctx context.Context, accountID, peerID string) ([]*types.Job, error) { + var jobs []*types.Job + err := s.db. + Where(accountAndPeerIDQueryCondition, accountID, peerID). + Order("created_at DESC"). + Find(&jobs).Error + if err != nil { + log.WithContext(ctx).Errorf("failed to fetch jobs from store: %s", err) + return nil, err + } + + return jobs, nil +} diff --git a/management/server/store/sql_store_name_server_group.go b/management/server/store/sql_store_name_server_group.go new file mode 100644 index 000000000..4af831148 --- /dev/null +++ b/management/server/store/sql_store_name_server_group.go @@ -0,0 +1,124 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + + "github.com/jackc/pgx/v5" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/shared/management/status" +) + +func (s *SqlStore) getNameServerGroups(ctx context.Context, accountID string) ([]nbdns.NameServerGroup, error) { + const query = `SELECT id, account_id, public_id, name, description, name_servers, groups, "primary", domains, enabled, search_domains_enabled FROM name_server_groups WHERE account_id = $1` + rows, err := s.pool.Query(ctx, query, accountID) + if err != nil { + return nil, err + } + nsgs, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (nbdns.NameServerGroup, error) { + var n nbdns.NameServerGroup + var ns, groups, domains []byte + var primary, enabled, searchDomainsEnabled sql.NullBool + err := row.Scan(&n.ID, &n.AccountID, &n.PublicID, &n.Name, &n.Description, &ns, &groups, &primary, &domains, &enabled, &searchDomainsEnabled) + if err == nil { + if primary.Valid { + n.Primary = primary.Bool + } + if enabled.Valid { + n.Enabled = enabled.Bool + } + if searchDomainsEnabled.Valid { + n.SearchDomainsEnabled = searchDomainsEnabled.Bool + } + if ns != nil { + _ = json.Unmarshal(ns, &n.NameServers) + } else { + n.NameServers = []nbdns.NameServer{} + } + if groups != nil { + _ = json.Unmarshal(groups, &n.Groups) + } else { + n.Groups = []string{} + } + if domains != nil { + _ = json.Unmarshal(domains, &n.Domains) + } else { + n.Domains = []string{} + } + } + return n, err + }) + if err != nil { + return nil, err + } + return nsgs, nil +} + +// GetAccountNameServerGroups retrieves name server groups for an account. +func (s *SqlStore) GetAccountNameServerGroups(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*nbdns.NameServerGroup, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var nsGroups []*nbdns.NameServerGroup + result := tx.Find(&nsGroups, accountIDCondition, accountID) + if err := result.Error; err != nil { + log.WithContext(ctx).Errorf("failed to get name server groups from the store: %s", err) + return nil, status.Errorf(status.Internal, "failed to get name server groups from store") + } + + return nsGroups, nil +} + +// GetNameServerGroupByID retrieves a name server group by its ID and account ID. +func (s *SqlStore) GetNameServerGroupByID(ctx context.Context, lockStrength LockingStrength, accountID, nsGroupID string) (*nbdns.NameServerGroup, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var nsGroup *nbdns.NameServerGroup + result := tx. + Take(&nsGroup, accountAndIDQueryCondition, accountID, nsGroupID) + if err := result.Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, status.NewNameServerGroupNotFoundError(nsGroupID) + } + log.WithContext(ctx).Errorf("failed to get name server group from the store: %s", err) + return nil, status.Errorf(status.Internal, "failed to get name server group from store") + } + + return nsGroup, nil +} + +// SaveNameServerGroup saves a name server group to the database. +func (s *SqlStore) SaveNameServerGroup(ctx context.Context, nameServerGroup *nbdns.NameServerGroup) error { + result := s.db.Save(nameServerGroup) + if err := result.Error; err != nil { + log.WithContext(ctx).Errorf("failed to save name server group to the store: %s", err) + return status.Errorf(status.Internal, "failed to save name server group to store") + } + return nil +} + +// DeleteNameServerGroup deletes a name server group from the database. +func (s *SqlStore) DeleteNameServerGroup(ctx context.Context, accountID, nsGroupID string) error { + result := s.db.Delete(&nbdns.NameServerGroup{}, accountAndIDQueryCondition, accountID, nsGroupID) + if err := result.Error; err != nil { + log.WithContext(ctx).Errorf("failed to delete name server group from the store: %s", err) + return status.Errorf(status.Internal, "failed to delete name server group from store") + } + + if result.RowsAffected == 0 { + return status.NewNameServerGroupNotFoundError(nsGroupID) + } + + return nil +} diff --git a/management/server/store/sql_store_name_server_group_test.go b/management/server/store/sql_store_name_server_group_test.go new file mode 100644 index 000000000..ae849c383 --- /dev/null +++ b/management/server/store/sql_store_name_server_group_test.go @@ -0,0 +1,143 @@ +package store + +import ( + "context" + "net/netip" + "testing" + + "github.com/stretchr/testify/require" + + nbdns "github.com/netbirdio/netbird/dns" + "github.com/netbirdio/netbird/shared/management/status" +) + +func TestSqlStore_GetAccountNameServerGroups(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + tests := []struct { + name string + accountID string + expectedCount int + }{ + { + name: "retrieve name server groups by existing account ID", + accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", + expectedCount: 1, + }, + { + name: "non-existing account ID", + accountID: "nonexistent", + expectedCount: 0, + }, + { + name: "empty account ID", + accountID: "", + expectedCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + peers, err := store.GetAccountNameServerGroups(context.Background(), LockingStrengthNone, tt.accountID) + require.NoError(t, err) + require.Len(t, peers, tt.expectedCount) + }) + } + +} + +func TestSqlStore_GetNameServerByID(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + tests := []struct { + name string + nsGroupID string + expectError bool + }{ + { + name: "retrieve existing nameserver group", + nsGroupID: "csqdelq7qv97ncu7d9t0", + expectError: false, + }, + { + name: "retrieve non-existing nameserver group", + nsGroupID: "non-existing", + expectError: true, + }, + { + name: "retrieve with empty nameserver group ID", + nsGroupID: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + nsGroup, err := store.GetNameServerGroupByID(context.Background(), LockingStrengthNone, accountID, tt.nsGroupID) + if tt.expectError { + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) + require.Nil(t, nsGroup) + } else { + require.NoError(t, err) + require.NotNil(t, nsGroup) + require.Equal(t, tt.nsGroupID, nsGroup.ID) + } + }) + } +} + +func TestSqlStore_SaveNameServerGroup(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + nsGroup := &nbdns.NameServerGroup{ + ID: "ns-group-id", + AccountID: accountID, + Name: "NS Group", + NameServers: []nbdns.NameServer{ + { + IP: netip.MustParseAddr("8.8.8.8"), + NSType: 1, + Port: 53, + }, + }, + Groups: []string{"groupA"}, + Primary: true, + Enabled: true, + SearchDomainsEnabled: false, + } + + err = store.SaveNameServerGroup(context.Background(), nsGroup) + require.NoError(t, err) + + saveNSGroup, err := store.GetNameServerGroupByID(context.Background(), LockingStrengthNone, accountID, nsGroup.ID) + require.NoError(t, err) + require.Equal(t, saveNSGroup, nsGroup) +} + +func TestSqlStore_DeleteNameServerGroup(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + nsGroupID := "csqdelq7qv97ncu7d9t0" + + err = store.DeleteNameServerGroup(context.Background(), accountID, nsGroupID) + require.NoError(t, err) + + nsGroup, err := store.GetNameServerGroupByID(context.Background(), LockingStrengthNone, accountID, nsGroupID) + require.Error(t, err) + require.Nil(t, nsGroup) +} diff --git a/management/server/store/sql_store_network.go b/management/server/store/sql_store_network.go new file mode 100644 index 000000000..963d67e8f --- /dev/null +++ b/management/server/store/sql_store_network.go @@ -0,0 +1,91 @@ +package store + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + networkTypes "github.com/netbirdio/netbird/management/server/networks/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +func (s *SqlStore) getNetworks(ctx context.Context, accountID string) ([]*networkTypes.Network, error) { + const query = `SELECT id, account_id, public_id, name, description FROM networks WHERE account_id = $1` + rows, err := s.pool.Query(ctx, query, accountID) + if err != nil { + return nil, err + } + networks, err := pgx.CollectRows(rows, pgx.RowToStructByName[networkTypes.Network]) + if err != nil { + return nil, err + } + result := make([]*networkTypes.Network, len(networks)) + for i := range networks { + result[i] = &networks[i] + } + return result, nil +} + +func (s *SqlStore) GetAccountNetworks(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*networkTypes.Network, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var networks []*networkTypes.Network + result := tx.Find(&networks, accountIDCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get networks from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get networks from store") + } + + return networks, nil +} + +func (s *SqlStore) GetNetworkByID(ctx context.Context, lockStrength LockingStrength, accountID, networkID string) (*networkTypes.Network, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var network *networkTypes.Network + result := tx.Take(&network, accountAndIDQueryCondition, accountID, networkID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewNetworkNotFoundError(networkID) + } + + log.WithContext(ctx).Errorf("failed to get network from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get network from store") + } + + return network, nil +} + +func (s *SqlStore) SaveNetwork(ctx context.Context, network *networkTypes.Network) error { + result := s.db.Save(network) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save network to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to save network to store") + } + + return nil +} + +func (s *SqlStore) DeleteNetwork(ctx context.Context, accountID, networkID string) error { + result := s.db.Delete(&networkTypes.Network{}, accountAndIDQueryCondition, accountID, networkID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete network from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete network from store") + } + + if result.RowsAffected == 0 { + return status.NewNetworkNotFoundError(networkID) + } + + return nil +} diff --git a/management/server/store/sql_store_network_resource.go b/management/server/store/sql_store_network_resource.go new file mode 100644 index 000000000..659382c61 --- /dev/null +++ b/management/server/store/sql_store_network_resource.go @@ -0,0 +1,167 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + + "github.com/jackc/pgx/v5" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +func (s *SqlStore) getNetworkResources(ctx context.Context, accountID string) ([]*resourceTypes.NetworkResource, error) { + const query = `SELECT id, network_id, account_id, public_id, name, description, type, domain, prefix, enabled FROM network_resources WHERE account_id = $1` + rows, err := s.pool.Query(ctx, query, accountID) + if err != nil { + return nil, err + } + resources, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (resourceTypes.NetworkResource, error) { + var r resourceTypes.NetworkResource + var prefix []byte + var enabled sql.NullBool + err := row.Scan(&r.ID, &r.NetworkID, &r.AccountID, &r.PublicID, &r.Name, &r.Description, &r.Type, &r.Domain, &prefix, &enabled) + if err == nil { + if enabled.Valid { + r.Enabled = enabled.Bool + } + if prefix != nil { + _ = json.Unmarshal(prefix, &r.Prefix) + } + } + return r, err + }) + if err != nil { + return nil, err + } + result := make([]*resourceTypes.NetworkResource, len(resources)) + for i := range resources { + result[i] = &resources[i] + } + return result, nil +} + +func (s *SqlStore) GetNetworkResourcesByNetID(ctx context.Context, lockStrength LockingStrength, accountID, networkID string) ([]*resourceTypes.NetworkResource, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var netResources []*resourceTypes.NetworkResource + result := tx. + Find(&netResources, "account_id = ? AND network_id = ?", accountID, networkID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get network resources from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get network resources from store") + } + + return netResources, nil +} + +func (s *SqlStore) GetNetworkResourcesByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*resourceTypes.NetworkResource, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var netResources []*resourceTypes.NetworkResource + result := tx. + Find(&netResources, accountIDCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get network resources from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get network resources from store") + } + + return netResources, nil +} + +func (s *SqlStore) GetNetworkResourceByID(ctx context.Context, lockStrength LockingStrength, accountID, resourceID string) (*resourceTypes.NetworkResource, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var netResources *resourceTypes.NetworkResource + result := tx. + Take(&netResources, accountAndIDQueryCondition, accountID, resourceID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewNetworkResourceNotFoundError(resourceID) + } + log.WithContext(ctx).Errorf("failed to get network resource from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get network resource from store") + } + + return netResources, nil +} + +// GetNetworkResourceByIDOrPublicID retrieves a network resource by either its ID or its +// PublicID. See GetPolicyByIDOrPublicID for why peer-reported references need both. +func (s *SqlStore) GetNetworkResourceByIDOrPublicID(ctx context.Context, lockStrength LockingStrength, accountID, resourceID string) (*resourceTypes.NetworkResource, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var netResources *resourceTypes.NetworkResource + result := tx. + Take(&netResources, accountAndAnyIDQueryCondition, accountID, resourceID, resourceID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewNetworkResourceNotFoundError(resourceID) + } + log.WithContext(ctx).Errorf("failed to get network resource from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get network resource from store") + } + + return netResources, nil +} + +func (s *SqlStore) GetNetworkResourceByName(ctx context.Context, lockStrength LockingStrength, accountID, resourceName string) (*resourceTypes.NetworkResource, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var netResources *resourceTypes.NetworkResource + result := tx. + Take(&netResources, "account_id = ? AND name = ?", accountID, resourceName) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewNetworkResourceNotFoundError(resourceName) + } + log.WithContext(ctx).Errorf("failed to get network resource from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get network resource from store") + } + + return netResources, nil +} + +func (s *SqlStore) SaveNetworkResource(ctx context.Context, resource *resourceTypes.NetworkResource) error { + result := s.db.Save(resource) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save network resource to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to save network resource to store") + } + + return nil +} + +func (s *SqlStore) DeleteNetworkResource(ctx context.Context, accountID, resourceID string) error { + result := s.db.Delete(&resourceTypes.NetworkResource{}, accountAndIDQueryCondition, accountID, resourceID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete network resource from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete network resource from store") + } + + if result.RowsAffected == 0 { + return status.NewNetworkResourceNotFoundError(resourceID) + } + + return nil +} diff --git a/management/server/store/sql_store_network_resource_test.go b/management/server/store/sql_store_network_resource_test.go new file mode 100644 index 000000000..620874e54 --- /dev/null +++ b/management/server/store/sql_store_network_resource_test.go @@ -0,0 +1,161 @@ +package store + +import ( + "context" + "net/netip" + "testing" + + "github.com/stretchr/testify/require" + + resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +func TestSqlStore_GetNetworkResourcesByNetID(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + tests := []struct { + name string + networkID string + expectedCount int + }{ + { + name: "retrieve resources by existing network ID", + networkID: "ct286bi7qv930dsrrug0", + expectedCount: 1, + }, + { + name: "retrieve resources by non-existing network ID", + networkID: "non-existent", + expectedCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + netResources, err := store.GetNetworkResourcesByNetID(context.Background(), LockingStrengthNone, accountID, tt.networkID) + require.NoError(t, err) + require.Len(t, netResources, tt.expectedCount) + }) + } +} + +func TestSqlStore_GetNetworkResourceByID(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + tests := []struct { + name string + netResourceID string + expectError bool + }{ + { + name: "retrieve existing network resource ID", + netResourceID: "ctc4nci7qv9061u6ilfg", + expectError: false, + }, + { + name: "retrieve non-existing network resource ID", + netResourceID: "non-existing", + expectError: true, + }, + { + name: "retrieve network with empty resource ID", + netResourceID: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + netResource, err := store.GetNetworkResourceByID(context.Background(), LockingStrengthNone, accountID, tt.netResourceID) + if tt.expectError { + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) + require.Nil(t, netResource) + } else { + require.NoError(t, err) + require.NotNil(t, netResource) + require.Equal(t, tt.netResourceID, netResource.ID) + } + }) + } +} + +func TestSqlStore_GetNetworkResourceByIDOrPublicID(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + netResourceID := "ctc4nci7qv9061u6ilfg" + + netResource, err := store.GetNetworkResourceByID(context.Background(), LockingStrengthNone, accountID, netResourceID) + require.NoError(t, err) + require.NotEmpty(t, netResource.PublicID) + + for _, id := range []string{netResourceID, netResource.PublicID} { + netResource, err := store.GetNetworkResourceByIDOrPublicID(context.Background(), LockingStrengthNone, accountID, id) + require.NoError(t, err) + require.Equal(t, netResourceID, netResource.ID) + } + + netResource, err = store.GetNetworkResourceByIDOrPublicID(context.Background(), LockingStrengthNone, accountID, "non-existing") + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) + require.Nil(t, netResource) +} + +func TestSqlStore_SaveNetworkResource(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + networkID := "ct286bi7qv930dsrrug0" + + netResource, err := resourceTypes.NewNetworkResource(accountID, networkID, "resource-name", "", "example.com", []string{}, true) + require.NoError(t, err) + + err = store.SaveNetworkResource(context.Background(), netResource) + require.NoError(t, err) + + savedNetResource, err := store.GetNetworkResourceByID(context.Background(), LockingStrengthNone, accountID, netResource.ID) + require.NoError(t, err) + require.Equal(t, netResource.ID, savedNetResource.ID) + require.Equal(t, netResource.Name, savedNetResource.Name) + require.Equal(t, netResource.NetworkID, savedNetResource.NetworkID) + require.Equal(t, netResource.Type, resourceTypes.NetworkResourceType("domain")) + require.Equal(t, netResource.Domain, "example.com") + require.Equal(t, netResource.AccountID, savedNetResource.AccountID) + require.Equal(t, netResource.Prefix, netip.Prefix{}) +} + +func TestSqlStore_DeleteNetworkResource(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + netResourceID := "ctc4nci7qv9061u6ilfg" + + err = store.DeleteNetworkResource(context.Background(), accountID, netResourceID) + require.NoError(t, err) + + netResource, err := store.GetNetworkByID(context.Background(), LockingStrengthNone, accountID, netResourceID) + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, status.NotFound, sErr.Type()) + require.Nil(t, netResource) +} diff --git a/management/server/store/sql_store_network_router.go b/management/server/store/sql_store_network_router.go new file mode 100644 index 000000000..b2594d483 --- /dev/null +++ b/management/server/store/sql_store_network_router.go @@ -0,0 +1,208 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + + "github.com/jackc/pgx/v5" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + 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/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +func (s *SqlStore) getNetworkRouters(ctx context.Context, accountID string) ([]*routerTypes.NetworkRouter, error) { + const query = `SELECT id, network_id, account_id, public_id, peer, peer_groups, masquerade, metric, enabled FROM network_routers WHERE account_id = $1` + rows, err := s.pool.Query(ctx, query, accountID) + if err != nil { + return nil, err + } + routers, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (routerTypes.NetworkRouter, error) { + var r routerTypes.NetworkRouter + var peerGroups []byte + var masquerade, enabled sql.NullBool + var metric sql.NullInt64 + err := row.Scan(&r.ID, &r.NetworkID, &r.AccountID, &r.PublicID, &r.Peer, &peerGroups, &masquerade, &metric, &enabled) + if err == nil { + if masquerade.Valid { + r.Masquerade = masquerade.Bool + } + if enabled.Valid { + r.Enabled = enabled.Bool + } + if metric.Valid { + r.Metric = int(metric.Int64) + } + if peerGroups != nil { + _ = json.Unmarshal(peerGroups, &r.PeerGroups) + } + } + return r, err + }) + if err != nil { + return nil, err + } + result := make([]*routerTypes.NetworkRouter, len(routers)) + for i := range routers { + result[i] = &routers[i] + } + return result, nil +} + +func (s *SqlStore) GetNetworkRoutersByNetID(ctx context.Context, lockStrength LockingStrength, accountID, netID string) ([]*routerTypes.NetworkRouter, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var netRouters []*routerTypes.NetworkRouter + result := tx. + Find(&netRouters, "account_id = ? AND network_id = ?", accountID, netID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get network routers from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get network routers from store") + } + + return netRouters, nil +} + +func (s *SqlStore) GetNetworkRoutersByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*routerTypes.NetworkRouter, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var netRouters []*routerTypes.NetworkRouter + result := tx. + Find(&netRouters, accountIDCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get network routers from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get network routers from store") + } + + return netRouters, nil +} + +func (s *SqlStore) GetNetworkRouterByID(ctx context.Context, lockStrength LockingStrength, accountID, routerID string) (*routerTypes.NetworkRouter, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var netRouter *routerTypes.NetworkRouter + result := tx. + Take(&netRouter, accountAndIDQueryCondition, accountID, routerID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewNetworkRouterNotFoundError(routerID) + } + log.WithContext(ctx).Errorf("failed to get network router from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get network router from store") + } + + return netRouter, nil +} + +func (s *SqlStore) CreateNetworkRouter(ctx context.Context, router *routerTypes.NetworkRouter) error { + if err := s.db.Create(router).Error; err != nil { + log.WithContext(ctx).Errorf("failed to create network router in store: %v", err) + return status.Errorf(status.Internal, "failed to create network router in store") + } + + return nil +} + +func (s *SqlStore) UpdateNetworkRouter(ctx context.Context, router *routerTypes.NetworkRouter) error { + result := s.db. + Select("*"). + Where(accountAndIDQueryCondition, router.AccountID, router.ID). + Updates(router) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to update network router in store: %v", result.Error) + return status.Errorf(status.Internal, "failed to update network router in store") + } + + if result.RowsAffected == 0 { + return status.NewNetworkRouterNotFoundError(router.ID) + } + + return nil +} + +func (s *SqlStore) DeleteNetworkRouter(ctx context.Context, accountID, routerID string) error { + result := s.db.Delete(&routerTypes.NetworkRouter{}, accountAndIDQueryCondition, accountID, routerID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete network router from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete network router from store") + } + + if result.RowsAffected == 0 { + return status.NewNetworkRouterNotFoundError(routerID) + } + + return nil +} + +// GetRoutingPeerNetworks returns the distinct network names where the peer is assigned as a routing peer +// in an enabled network router, either directly or via peer groups. +func (s *SqlStore) GetRoutingPeerNetworks(_ context.Context, accountID, peerID string) ([]string, error) { + var routers []*routerTypes.NetworkRouter + if err := s.db.Select("peer, peer_groups, network_id").Where("account_id = ? AND enabled = true", accountID).Find(&routers).Error; err != nil { + return nil, status.Errorf(status.Internal, "failed to get enabled routers: %v", err) + } + + if len(routers) == 0 { + return nil, nil + } + + var groupPeers []types.GroupPeer + if err := s.db.Select("group_id").Where("account_id = ? AND peer_id = ?", accountID, peerID).Find(&groupPeers).Error; err != nil { + return nil, status.Errorf(status.Internal, "failed to get peer group memberships: %v", err) + } + + groupSet := make(map[string]struct{}, len(groupPeers)) + for _, gp := range groupPeers { + groupSet[gp.GroupID] = struct{}{} + } + + networkIDs := make(map[string]struct{}) + for _, r := range routers { + if r.Peer == peerID { + networkIDs[r.NetworkID] = struct{}{} + } else if r.Peer == "" { + for _, pg := range r.PeerGroups { + if _, ok := groupSet[pg]; ok { + networkIDs[r.NetworkID] = struct{}{} + break + } + } + } + } + + if len(networkIDs) == 0 { + return nil, nil + } + + ids := make([]string, 0, len(networkIDs)) + for id := range networkIDs { + ids = append(ids, id) + } + + var networks []*networkTypes.Network + if err := s.db.Select("name").Where("account_id = ? AND id IN ?", accountID, ids).Find(&networks).Error; err != nil { + return nil, status.Errorf(status.Internal, "failed to get networks: %v", err) + } + + names := make([]string, 0, len(networks)) + for _, n := range networks { + names = append(names, n.Name) + } + + return names, nil +} diff --git a/management/server/store/sql_store_network_router_test.go b/management/server/store/sql_store_network_router_test.go new file mode 100644 index 000000000..c4e5280e6 --- /dev/null +++ b/management/server/store/sql_store_network_router_test.go @@ -0,0 +1,161 @@ +package store + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +func TestSqlStore_GetNetworkRoutersByNetID(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + tests := []struct { + name string + networkID string + expectedCount int + }{ + { + name: "retrieve routers by existing network ID", + networkID: "ct286bi7qv930dsrrug0", + expectedCount: 1, + }, + { + name: "retrieve routers by non-existing network ID", + networkID: "non-existent", + expectedCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + routers, err := store.GetNetworkRoutersByNetID(context.Background(), LockingStrengthNone, accountID, tt.networkID) + require.NoError(t, err) + require.Len(t, routers, tt.expectedCount) + }) + } +} + +func TestSqlStore_GetNetworkRouterByID(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + tests := []struct { + name string + networkRouterID string + expectError bool + }{ + { + name: "retrieve existing network router ID", + networkRouterID: "ctc20ji7qv9ck2sebc80", + expectError: false, + }, + { + name: "retrieve non-existing network router ID", + networkRouterID: "non-existing", + expectError: true, + }, + { + name: "retrieve network with empty router ID", + networkRouterID: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + networkRouter, err := store.GetNetworkRouterByID(context.Background(), LockingStrengthNone, accountID, tt.networkRouterID) + if tt.expectError { + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) + require.Nil(t, networkRouter) + } else { + require.NoError(t, err) + require.NotNil(t, networkRouter) + require.Equal(t, tt.networkRouterID, networkRouter.ID) + } + }) + } +} + +func TestSqlStore_CreateNetworkRouter(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + networkID := "ct286bi7qv930dsrrug0" + + netRouter, err := routerTypes.NewNetworkRouter(accountID, networkID, "", []string{"net-router-grp"}, true, 0, true) + require.NoError(t, err) + + err = store.CreateNetworkRouter(context.Background(), netRouter) + require.NoError(t, err) + + savedNetRouter, err := store.GetNetworkRouterByID(context.Background(), LockingStrengthNone, accountID, netRouter.ID) + require.NoError(t, err) + require.Equal(t, netRouter, savedNetRouter) +} + +func TestSqlStore_UpdateNetworkRouter(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + networkID := "ct286bi7qv930dsrrug0" + routerID := "ctc20ji7qv9ck2sebc80" + + netRouter := &routerTypes.NetworkRouter{ + ID: routerID, + AccountID: accountID, + NetworkID: networkID, + Peer: "", + PeerGroups: []string{"net-router-grp"}, + Masquerade: true, + Metric: 42, + Enabled: true, + } + + err = store.UpdateNetworkRouter(context.Background(), netRouter) + require.NoError(t, err) + + savedNetRouter, err := store.GetNetworkRouterByID(context.Background(), LockingStrengthNone, accountID, routerID) + require.NoError(t, err) + require.Equal(t, netRouter, savedNetRouter) + + // Updating a router under a different account must not match any row. + netRouter.AccountID = "non-existent-account" + err = store.UpdateNetworkRouter(context.Background(), netRouter) + require.Error(t, err) +} + +func TestSqlStore_DeleteNetworkRouter(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + netRouterID := "ctc20ji7qv9ck2sebc80" + + err = store.DeleteNetworkRouter(context.Background(), accountID, netRouterID) + require.NoError(t, err) + + netRouter, err := store.GetNetworkByID(context.Background(), LockingStrengthNone, accountID, netRouterID) + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, status.NotFound, sErr.Type()) + require.Nil(t, netRouter) +} diff --git a/management/server/store/sql_store_network_test.go b/management/server/store/sql_store_network_test.go new file mode 100644 index 000000000..1eeda7cfc --- /dev/null +++ b/management/server/store/sql_store_network_test.go @@ -0,0 +1,128 @@ +package store + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + networkTypes "github.com/netbirdio/netbird/management/server/networks/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +func TestSqlStore_GetAccountNetworks(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + tests := []struct { + name string + accountID string + expectedCount int + }{ + { + name: "retrieve networks by existing account ID", + accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", + expectedCount: 1, + }, + + { + name: "retrieve networks by non-existing account ID", + accountID: "non-existent", + expectedCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + networks, err := store.GetAccountNetworks(context.Background(), LockingStrengthNone, tt.accountID) + require.NoError(t, err) + require.Len(t, networks, tt.expectedCount) + }) + } +} + +func TestSqlStore_GetNetworkByID(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + tests := []struct { + name string + networkID string + expectError bool + }{ + { + name: "retrieve existing network ID", + networkID: "ct286bi7qv930dsrrug0", + expectError: false, + }, + { + name: "retrieve non-existing network ID", + networkID: "non-existing", + expectError: true, + }, + { + name: "retrieve network with empty ID", + networkID: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + network, err := store.GetNetworkByID(context.Background(), LockingStrengthNone, accountID, tt.networkID) + if tt.expectError { + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) + require.Nil(t, network) + } else { + require.NoError(t, err) + require.NotNil(t, network) + require.Equal(t, tt.networkID, network.ID) + } + }) + } +} + +func TestSqlStore_SaveNetwork(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + network := &networkTypes.Network{ + ID: "net-id", + AccountID: accountID, + Name: "net", + } + + err = store.SaveNetwork(context.Background(), network) + require.NoError(t, err) + + savedNet, err := store.GetNetworkByID(context.Background(), LockingStrengthNone, accountID, network.ID) + require.NoError(t, err) + require.Equal(t, network, savedNet) +} + +func TestSqlStore_DeleteNetwork(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + networkID := "ct286bi7qv930dsrrug0" + + err = store.DeleteNetwork(context.Background(), accountID, networkID) + require.NoError(t, err) + + network, err := store.GetNetworkByID(context.Background(), LockingStrengthNone, accountID, networkID) + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, status.NotFound, sErr.Type()) + require.Nil(t, network) +} diff --git a/management/server/store/sql_store_peer.go b/management/server/store/sql_store_peer.go new file mode 100644 index 000000000..90c742a72 --- /dev/null +++ b/management/server/store/sql_store_peer.go @@ -0,0 +1,781 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net" + "net/netip" + "time" + + "github.com/jackc/pgx/v5" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +func (s *SqlStore) SavePeer(ctx context.Context, accountID string, peer *nbpeer.Peer) error { + // To maintain data integrity, we create a copy of the peer's to prevent unintended updates to other fields. + peerCopy := peer.Copy() + peerCopy.AccountID = accountID + + err := s.transaction(func(tx *gorm.DB) error { + // check if peer exists before saving + var peerID string + result := tx.Model(&nbpeer.Peer{}).Select("id").Take(&peerID, accountAndIDQueryCondition, accountID, peer.ID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return status.Errorf(status.NotFound, peerNotFoundFMT, peer.ID) + } + return result.Error + } + + if peerID == "" { + return status.Errorf(status.NotFound, peerNotFoundFMT, peer.ID) + } + + result = tx.Model(&nbpeer.Peer{}).Where(accountAndIDQueryCondition, accountID, peer.ID).Save(peerCopy) + if result.Error != nil { + return status.Errorf(status.Internal, "failed to save peer to store: %v", result.Error) + } + + return nil + }) + if err != nil { + return err + } + + return nil +} + +func (s *SqlStore) SavePeerStatus(ctx context.Context, accountID, peerID string, peerStatus nbpeer.PeerStatus) error { + var peerCopy nbpeer.Peer + peerCopy.Status = &peerStatus + + fieldsToUpdate := []string{ + "peer_status_last_seen", "peer_status_session_started_at", + "peer_status_connected", "peer_status_login_expired", + "peer_status_requires_approval", + } + result := s.db.Model(&nbpeer.Peer{}). + Select(fieldsToUpdate). + Where(accountAndIDQueryCondition, accountID, peerID). + Updates(&peerCopy) + if result.Error != nil { + return status.Errorf(status.Internal, "failed to save peer status to store: %v", result.Error) + } + + if result.RowsAffected == 0 { + return status.Errorf(status.NotFound, peerNotFoundFMT, peerID) + } + + return nil +} + +// MarkPeerConnectedIfNewerSession is an atomic optimistic-locked update. +// The peer is marked connected with the given session token only when +// the stored SessionStartedAt is strictly smaller than the incoming +// one — equivalently, when no newer stream has already taken ownership. +// The sentinel zero (set on peer creation or after a disconnect) counts +// as the smallest possible token. This is the write half of the +// fencing protocol described on PeerStatus.SessionStartedAt. +// +// The post-write side effects in the caller — geo lookup, +// schedulePeerLoginExpiration, checkAndSchedulePeerInactivityExpiration, +// OnPeersUpdated — all run AFTER this method returns and are deliberately +// outside the database write so they cannot extend the row-lock window. +// +// LastSeen is set to the database's clock (CURRENT_TIMESTAMP) at the +// moment the row is written. The caller never supplies LastSeen because +// the value would otherwise drift under lock contention — a Go-side +// time.Now() taken before the write can land minutes later than the +// actual UPDATE under load, which previously caused real ordering bugs. +func (s *SqlStore) MarkPeerConnectedIfNewerSession(ctx context.Context, accountID, peerID string, newSessionStartedAt int64) (bool, error) { + result := s.db.WithContext(ctx). + Model(&nbpeer.Peer{}). + Where(accountAndIDQueryCondition, accountID, peerID). + Where("peer_status_session_started_at < ?", newSessionStartedAt). + Updates(map[string]any{ + "peer_status_connected": true, + "peer_status_last_seen": gorm.Expr("CURRENT_TIMESTAMP"), + "peer_status_session_started_at": newSessionStartedAt, + "peer_status_login_expired": false, + }) + if result.Error != nil { + return false, status.Errorf(status.Internal, "mark peer connected: %v", result.Error) + } + return result.RowsAffected > 0, nil +} + +// MarkPeerDisconnectedIfSameSession is an atomic optimistic-locked update. +// The peer is marked disconnected only when the stored SessionStartedAt +// matches the incoming token — meaning the stream that owns the current +// session is the one ending. If a newer stream has already replaced the +// session, the update is skipped. LastSeen is set to CURRENT_TIMESTAMP at +// write time; see MarkPeerConnectedIfNewerSession for the rationale. +// +// A zero sessionStartedAt is rejected at the call site; the underlying +// WHERE on equality would otherwise match every never-connected peer. +func (s *SqlStore) MarkPeerDisconnectedIfSameSession(ctx context.Context, accountID, peerID string, sessionStartedAt int64) (bool, error) { + if sessionStartedAt == 0 { + return false, nil + } + result := s.db.WithContext(ctx). + Model(&nbpeer.Peer{}). + Where(accountAndIDQueryCondition, accountID, peerID). + Where("peer_status_session_started_at = ?", sessionStartedAt). + Updates(map[string]any{ + "peer_status_connected": false, + "peer_status_last_seen": gorm.Expr("CURRENT_TIMESTAMP"), + "peer_status_session_started_at": int64(0), + }) + if result.Error != nil { + return false, status.Errorf(status.Internal, "mark peer disconnected: %v", result.Error) + } + return result.RowsAffected > 0, nil +} + +// ApproveAccountPeers marks all peers that currently require approval in the given account as approved. +func (s *SqlStore) ApproveAccountPeers(ctx context.Context, accountID string) (int, error) { + result := s.db.Model(&nbpeer.Peer{}). + Where("account_id = ? AND peer_status_requires_approval = ?", accountID, true). + Update("peer_status_requires_approval", false) + if result.Error != nil { + return 0, status.Errorf(status.Internal, "failed to approve pending account peers: %v", result.Error) + } + + return int(result.RowsAffected), nil +} + +// RefreshPeerLastSeen updates only peer_status_last_seen. Every other status +// column is left untouched: peer_status_connected and +// peer_status_session_started_at belong to the sync stream that owns the +// session, and a blind write here would corrupt the fencing +// MarkPeerConnectedIfNewerSession relies on. +// +// LastSeen comes from the database clock for the same reason it does there: a +// Go-side timestamp is taken before the write and can land after a connect that +// used CURRENT_TIMESTAMP, dragging the column backwards. +// +// staleBefore carries the caller's throttle into the same statement, so +// concurrent requests for one peer collapse into a single write instead of +// each racing on its own stale read. The column is nullable — Status is an +// embedded pointer, so a peer stored without one leaves it NULL — and NULL +// loses every comparison, hence the explicit branch for a peer never seen. +func (s *SqlStore) RefreshPeerLastSeen(ctx context.Context, accountID, peerID string, staleBefore time.Time) (bool, error) { + result := s.db.WithContext(ctx). + Model(&nbpeer.Peer{}). + Where(accountAndIDQueryCondition, accountID, peerID). + Where("(peer_status_last_seen IS NULL OR peer_status_last_seen < ?)", staleBefore). + Update("peer_status_last_seen", gorm.Expr("CURRENT_TIMESTAMP")) + if result.Error != nil { + return false, status.Errorf(status.Internal, "refresh peer last seen: %v", result.Error) + } + + return result.RowsAffected > 0, nil +} + +func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Peer, error) { + const query = `SELECT id, account_id, key, ip, name, dns_label, user_id, ssh_key, ssh_enabled, login_expiration_enabled, + inactivity_expiration_enabled, last_login, created_at, ephemeral, extra_dns_labels, allow_extra_dns_labels, meta_hostname, + meta_go_os, meta_kernel, meta_core, meta_platform, meta_os, meta_os_version, meta_wt_version, meta_ui_version, + meta_kernel_version, meta_network_addresses, meta_system_serial_number, meta_system_product_name, meta_system_manufacturer, + meta_environment, meta_flags, meta_files, meta_capabilities, peer_status_last_seen, peer_status_session_started_at, + peer_status_connected, peer_status_login_expired, peer_status_requires_approval, location_connection_ip, + location_country_code, location_city_name, location_geo_name_id, proxy_meta_embedded, proxy_meta_cluster, ipv6, meta_sync_message_version + FROM peers WHERE account_id = $1` + rows, err := s.pool.Query(ctx, query, accountID) + if err != nil { + return nil, err + } + + peers, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (nbpeer.Peer, error) { + var p nbpeer.Peer + p.Status = &nbpeer.PeerStatus{} + var ( + lastLogin, createdAt sql.NullTime + sshEnabled, loginExpirationEnabled, inactivityExpirationEnabled, ephemeral, allowExtraDNSLabels sql.NullBool + peerStatusLastSeen sql.NullTime + peerStatusSessionStartedAt sql.NullInt64 + peerStatusConnected, peerStatusLoginExpired, peerStatusRequiresApproval, proxyEmbedded sql.NullBool + ip, extraDNS, netAddr, env, flags, files, capabilities, connIP, ipv6 []byte + metaHostname, metaGoOS, metaKernel, metaCore, metaPlatform sql.NullString + metaOS, metaOSVersion, metaWtVersion, metaUIVersion, metaKernelVersion sql.NullString + metaSystemSerialNumber, metaSystemProductName, metaSystemManufacturer sql.NullString + locationCountryCode, locationCityName, proxyCluster sql.NullString + locationGeoNameID sql.NullInt64 + metaSyncMessageVersion sql.NullInt32 + ) + + err := row.Scan(&p.ID, &p.AccountID, &p.Key, &ip, &p.Name, &p.DNSLabel, &p.UserID, &p.SSHKey, &sshEnabled, + &loginExpirationEnabled, &inactivityExpirationEnabled, &lastLogin, &createdAt, &ephemeral, &extraDNS, + &allowExtraDNSLabels, &metaHostname, &metaGoOS, &metaKernel, &metaCore, &metaPlatform, + &metaOS, &metaOSVersion, &metaWtVersion, &metaUIVersion, &metaKernelVersion, &netAddr, + &metaSystemSerialNumber, &metaSystemProductName, &metaSystemManufacturer, &env, &flags, &files, &capabilities, + &peerStatusLastSeen, &peerStatusSessionStartedAt, &peerStatusConnected, &peerStatusLoginExpired, + &peerStatusRequiresApproval, &connIP, &locationCountryCode, &locationCityName, &locationGeoNameID, + &proxyEmbedded, &proxyCluster, &ipv6, &metaSyncMessageVersion) + + if err == nil { + if lastLogin.Valid { + p.LastLogin = &lastLogin.Time + } + if createdAt.Valid { + p.CreatedAt = createdAt.Time + } + if sshEnabled.Valid { + p.SSHEnabled = sshEnabled.Bool + } + if loginExpirationEnabled.Valid { + p.LoginExpirationEnabled = loginExpirationEnabled.Bool + } + if inactivityExpirationEnabled.Valid { + p.InactivityExpirationEnabled = inactivityExpirationEnabled.Bool + } + if ephemeral.Valid { + p.Ephemeral = ephemeral.Bool + } + if allowExtraDNSLabels.Valid { + p.AllowExtraDNSLabels = allowExtraDNSLabels.Bool + } + if peerStatusLastSeen.Valid { + p.Status.LastSeen = peerStatusLastSeen.Time + } + if peerStatusSessionStartedAt.Valid { + p.Status.SessionStartedAt = peerStatusSessionStartedAt.Int64 + } + if peerStatusConnected.Valid { + p.Status.Connected = peerStatusConnected.Bool + } + if peerStatusLoginExpired.Valid { + p.Status.LoginExpired = peerStatusLoginExpired.Bool + } + if peerStatusRequiresApproval.Valid { + p.Status.RequiresApproval = peerStatusRequiresApproval.Bool + } + if metaHostname.Valid { + p.Meta.Hostname = metaHostname.String + } + if metaGoOS.Valid { + p.Meta.GoOS = metaGoOS.String + } + if metaKernel.Valid { + p.Meta.Kernel = metaKernel.String + } + if metaCore.Valid { + p.Meta.Core = metaCore.String + } + if metaPlatform.Valid { + p.Meta.Platform = metaPlatform.String + } + if metaOS.Valid { + p.Meta.OS = metaOS.String + } + if metaOSVersion.Valid { + p.Meta.OSVersion = metaOSVersion.String + } + if metaWtVersion.Valid { + p.Meta.WtVersion = metaWtVersion.String + } + if metaUIVersion.Valid { + p.Meta.UIVersion = metaUIVersion.String + } + if metaKernelVersion.Valid { + p.Meta.KernelVersion = metaKernelVersion.String + } + if metaSystemSerialNumber.Valid { + p.Meta.SystemSerialNumber = metaSystemSerialNumber.String + } + if metaSystemProductName.Valid { + p.Meta.SystemProductName = metaSystemProductName.String + } + if metaSystemManufacturer.Valid { + p.Meta.SystemManufacturer = metaSystemManufacturer.String + } + if locationCountryCode.Valid { + p.Location.CountryCode = locationCountryCode.String + } + if locationCityName.Valid { + p.Location.CityName = locationCityName.String + } + if locationGeoNameID.Valid { + p.Location.GeoNameID = uint(locationGeoNameID.Int64) + } + if proxyEmbedded.Valid { + p.ProxyMeta.Embedded = proxyEmbedded.Bool + } + if proxyCluster.Valid { + p.ProxyMeta.Cluster = proxyCluster.String + } + if ip != nil { + _ = json.Unmarshal(ip, &p.IP) + } + if ipv6 != nil { + _ = json.Unmarshal(ipv6, &p.IPv6) + } + if extraDNS != nil { + _ = json.Unmarshal(extraDNS, &p.ExtraDNSLabels) + } + if netAddr != nil { + _ = json.Unmarshal(netAddr, &p.Meta.NetworkAddresses) + } + if env != nil { + _ = json.Unmarshal(env, &p.Meta.Environment) + } + if flags != nil { + _ = json.Unmarshal(flags, &p.Meta.Flags) + } + if files != nil { + _ = json.Unmarshal(files, &p.Meta.Files) + } + if capabilities != nil { + _ = json.Unmarshal(capabilities, &p.Meta.Capabilities) + } + if connIP != nil { + _ = json.Unmarshal(connIP, &p.Location.ConnectionIP) + } + if metaSyncMessageVersion.Valid { + p.Meta.SyncMessageVersion = int(metaSyncMessageVersion.Int32) + } + } + return p, err + }) + if err != nil { + return nil, err + } + return peers, nil +} + +func (s *SqlStore) GetAccountByPeerID(ctx context.Context, peerID string) (*types.Account, error) { + var peer nbpeer.Peer + result := s.db.Select("account_id").Take(&peer, idQueryCondition, peerID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") + } + return nil, status.NewGetAccountFromStoreError(result.Error) + } + + if peer.AccountID == "" { + return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") + } + + return s.GetAccount(ctx, peer.AccountID) +} + +func (s *SqlStore) GetAccountByPeerPubKey(ctx context.Context, peerKey string) (*types.Account, error) { + var peer nbpeer.Peer + result := s.db.Select("account_id").Take(&peer, GetKeyQueryCondition(s), peerKey) + + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") + } + return nil, status.NewGetAccountFromStoreError(result.Error) + } + + if peer.AccountID == "" { + return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") + } + + return s.GetAccount(ctx, peer.AccountID) +} + +func (s *SqlStore) GetAccountIDByPeerPubKey(ctx context.Context, peerKey string) (string, error) { + var peer nbpeer.Peer + var accountID string + result := s.db.Model(&peer).Select("account_id").Where(GetKeyQueryCondition(s), peerKey).Take(&accountID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return "", status.Errorf(status.NotFound, "account not found: index lookup failed") + } + return "", status.NewGetAccountFromStoreError(result.Error) + } + + return accountID, nil +} + +func (s *SqlStore) GetAccountIDByPeerID(ctx context.Context, lockStrength LockingStrength, peerID string) (string, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var accountID string + result := tx.Model(&nbpeer.Peer{}). + Select("account_id").Where(idQueryCondition, peerID).Take(&accountID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return "", status.Errorf(status.NotFound, "peer %s account not found", peerID) + } + return "", status.NewGetAccountFromStoreError(result.Error) + } + + return accountID, nil +} + +func (s *SqlStore) GetTakenIPs(ctx context.Context, lockStrength LockingStrength, accountID string) ([]netip.Addr, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var ipJSONStrings []string + + result := tx.Model(&nbpeer.Peer{}). + Where("account_id = ?", accountID). + Pluck("ip", &ipJSONStrings) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "no peers found for the account") + } + return nil, status.Errorf(status.Internal, "issue getting IPs from store: %s", result.Error) + } + + ips := make([]netip.Addr, len(ipJSONStrings)) + for i, ipJSON := range ipJSONStrings { + var ip netip.Addr + if err := json.Unmarshal([]byte(ipJSON), &ip); err != nil { + return nil, status.Errorf(status.Internal, "issue parsing IP JSON from store") + } + ips[i] = ip.Unmap() + } + + return ips, nil +} + +func (s *SqlStore) GetPeerLabelsInAccount(ctx context.Context, lockStrength LockingStrength, accountID string, dnsLabel string) ([]string, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var labels []string + result := tx.Model(&nbpeer.Peer{}). + Where("account_id = ? AND dns_label LIKE ?", accountID, dnsLabel+"%"). + Pluck("dns_label", &labels) + + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "no peers found for the account") + } + log.WithContext(ctx).Errorf("error when getting dns labels from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "issue getting dns labels from store: %s", result.Error) + } + + return labels, nil +} + +func (s *SqlStore) GetPeerByPeerPubKey(ctx context.Context, lockStrength LockingStrength, peerKey string) (*nbpeer.Peer, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var peer nbpeer.Peer + result := tx.Take(&peer, GetKeyQueryCondition(s), peerKey) + + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewPeerNotFoundError(peerKey) + } + return nil, status.Errorf(status.Internal, "issue getting peer from store: %s", result.Error) + } + + return &peer, nil +} + +// GetAccountPeers retrieves peers for an account. +func (s *SqlStore) GetAccountPeers(ctx context.Context, lockStrength LockingStrength, accountID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error) { + var peers []*nbpeer.Peer + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + query := tx.Where(accountIDCondition, accountID) + + if nameFilter != "" { + query = query.Where("name LIKE ?", "%"+nameFilter+"%") + } + if ipFilter != "" { + query = query.Where("ip LIKE ? OR ipv6 LIKE ?", "%"+ipFilter+"%", "%"+ipFilter+"%") + } + + if err := query.Find(&peers).Error; err != nil { + log.WithContext(ctx).Errorf("failed to get peers from the store: %s", err) + return nil, status.Errorf(status.Internal, "failed to get peers from store") + } + + return peers, nil +} + +// GetUserPeers retrieves peers for a user. +func (s *SqlStore) GetUserPeers(ctx context.Context, lockStrength LockingStrength, accountID, userID string) ([]*nbpeer.Peer, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var peers []*nbpeer.Peer + + // Exclude peers added via setup keys, as they are not user-specific and have an empty user_id. + if userID == "" { + return peers, nil + } + + result := tx. + Find(&peers, "account_id = ? AND user_id = ?", accountID, userID) + if err := result.Error; err != nil { + log.WithContext(ctx).Errorf("failed to get peers from the store: %s", err) + return nil, status.Errorf(status.Internal, "failed to get peers from store") + } + + return peers, nil +} + +func (s *SqlStore) AddPeerToAccount(ctx context.Context, peer *nbpeer.Peer) error { + if err := s.db.Create(peer).Error; err != nil { + return status.Errorf(status.Internal, "issue adding peer to account: %s", err) + } + + return nil +} + +// GetPeerByID retrieves a peer by its ID and account ID. +func (s *SqlStore) GetPeerByID(ctx context.Context, lockStrength LockingStrength, accountID, peerID string) (*nbpeer.Peer, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var peer *nbpeer.Peer + result := tx. + Take(&peer, accountAndIDQueryCondition, accountID, peerID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewPeerNotFoundError(peerID) + } + return nil, status.Errorf(status.Internal, "failed to get peer from store") + } + + return peer, nil +} + +// GetPeersByIDs retrieves peers by their IDs and account ID. +func (s *SqlStore) GetPeersByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, peerIDs []string) (map[string]*nbpeer.Peer, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var peers []*nbpeer.Peer + result := tx.Find(&peers, accountAndIDsQueryCondition, accountID, peerIDs) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get peers by ID's from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get peers by ID's from the store") + } + + peersMap := make(map[string]*nbpeer.Peer) + for _, peer := range peers { + peersMap[peer.ID] = peer + } + + return peersMap, nil +} + +// GetAccountPeersWithExpiration retrieves a list of peers that have login expiration enabled and added by a user. +func (s *SqlStore) GetAccountPeersWithExpiration(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*nbpeer.Peer, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var peers []*nbpeer.Peer + result := tx. + Where("login_expiration_enabled = ? AND peer_status_login_expired != ? AND user_id IS NOT NULL AND user_id != ''", true, true). + Find(&peers, accountIDCondition, accountID) + if err := result.Error; err != nil { + log.WithContext(ctx).Errorf("failed to get peers with expiration from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get peers with expiration from store") + } + + return peers, nil +} + +// GetAccountPeersWithInactivity retrieves a list of peers that have login expiration enabled and added by a user. +func (s *SqlStore) GetAccountPeersWithInactivity(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*nbpeer.Peer, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var peers []*nbpeer.Peer + result := tx. + Where("inactivity_expiration_enabled = ? AND user_id IS NOT NULL AND user_id != ''", true). + Find(&peers, accountIDCondition, accountID) + if err := result.Error; err != nil { + log.WithContext(ctx).Errorf("failed to get peers with inactivity from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get peers with inactivity from store") + } + + return peers, nil +} + +// GetAllEphemeralPeers retrieves all peers with Ephemeral set to true across all accounts, optimized for batch processing. +func (s *SqlStore) GetAllEphemeralPeers(ctx context.Context, lockStrength LockingStrength) ([]*nbpeer.Peer, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var allEphemeralPeers, batchPeers []*nbpeer.Peer + result := tx. + Where("ephemeral = ?", true). + FindInBatches(&batchPeers, 1000, func(tx *gorm.DB, batch int) error { + allEphemeralPeers = append(allEphemeralPeers, batchPeers...) + return nil + }) + + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to retrieve ephemeral peers: %s", result.Error) + return nil, fmt.Errorf("failed to retrieve ephemeral peers") + } + + return allEphemeralPeers, nil +} + +// DeletePeer removes a peer from the store. +func (s *SqlStore) DeletePeer(ctx context.Context, accountID string, peerID string) error { + result := s.db.Delete(&nbpeer.Peer{}, accountAndIDQueryCondition, accountID, peerID) + if err := result.Error; err != nil { + log.WithContext(ctx).Errorf("failed to delete peer from the store: %s", err) + return status.Errorf(status.Internal, "failed to delete peer from store") + } + + if result.RowsAffected == 0 { + return status.NewPeerNotFoundError(peerID) + } + + return nil +} + +func (s *SqlStore) GetPeerByIP(ctx context.Context, lockStrength LockingStrength, accountID string, ip net.IP) (*nbpeer.Peer, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + column := "ip" + if ip.To4() == nil { + column = "ipv6" + } + jsonValue := fmt.Sprintf(`"%s"`, ip.String()) + + var peer nbpeer.Peer + result := tx. + Take(&peer, fmt.Sprintf("account_id = ? AND %s = ?", column), accountID, jsonValue) + if result.Error != nil { + // A tunnel-IP miss is an expected outcome (e.g. the proxy's + // ValidateTunnelPeer probing an address that isn't in the + // account roster); surface it as NotFound so callers can tell + // it apart from a real store failure. + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "peer with ip %s not found", ip.String()) + } + return nil, status.Errorf(status.Internal, "failed to get peer from store") + } + + return &peer, nil +} + +func (s *SqlStore) GetPeerIdByLabel(ctx context.Context, lockStrength LockingStrength, accountID string, hostname string) (string, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var peerID string + result := tx.Model(&nbpeer.Peer{}). + Select("id"). + // Where(" = ?", hostname). + Where("account_id = ? AND dns_label = ?", accountID, hostname). + Limit(1). + Scan(&peerID) + + if peerID == "" { + return "", gorm.ErrRecordNotFound + } + + return peerID, result.Error +} + +// GetEmbeddedProxyPeerIDsByCluster returns peer IDs of all embedded proxy peers +// in the account, grouped by their ProxyCluster. The map is nil when no embedded +// proxy peers exist. +func (s *SqlStore) GetEmbeddedProxyPeerIDsByCluster(ctx context.Context, accountID string) (map[string][]string, error) { + type row struct { + ID string + Cluster string + } + var rows []row + result := s.db.Model(&nbpeer.Peer{}). + Select("id, proxy_meta_cluster AS cluster"). + Where("account_id = ? AND proxy_meta_embedded = ?", accountID, true). + Scan(&rows) + if result.Error != nil { + return nil, status.Errorf(status.Internal, "failed to get embedded proxy peers: %s", result.Error) + } + + out := make(map[string][]string, len(rows)) + for _, r := range rows { + out[r.Cluster] = append(out[r.Cluster], r.ID) + } + return out, nil +} + +func (s *SqlStore) GetUserIDByPeerKey(ctx context.Context, lockStrength LockingStrength, peerKey string) (string, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var userID string + result := tx.Model(&nbpeer.Peer{}). + Select("user_id"). + Take(&userID, GetKeyQueryCondition(s), peerKey) + + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return "", status.Errorf(status.NotFound, "peer not found: index lookup failed") + } + return "", status.Errorf(status.Internal, "failed to get user ID by peer key") + } + + return userID, nil +} + +func (s *SqlStore) GetPeerIDByKey(ctx context.Context, lockStrength LockingStrength, key string) (string, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var peerID string + result := tx.Model(&nbpeer.Peer{}). + Select("id"). + Where(GetKeyQueryCondition(s), key). + Limit(1). + Scan(&peerID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get peer ID by key: %s", result.Error) + return "", status.Errorf(status.Internal, "failed to get peer ID by key") + } + + return peerID, nil +} diff --git a/management/server/store/sql_store_peer_test.go b/management/server/store/sql_store_peer_test.go new file mode 100644 index 000000000..b49e04f2f --- /dev/null +++ b/management/server/store/sql_store_peer_test.go @@ -0,0 +1,901 @@ +package store + +import ( + "context" + "encoding/binary" + "fmt" + "net" + "net/netip" + "reflect" + "sort" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + nbpeer "github.com/netbirdio/netbird/management/server/peer" + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/management/server/util" + "github.com/netbirdio/netbird/shared/management/status" + "github.com/netbirdio/netbird/shared/testing_helpers" +) + +// TestSqlStore_GetPeerByIP_NotFound pins the not-found semantics the +// proxy's ValidateTunnelPeer relies on: a tunnel-IP that isn't in the +// account roster must surface as a NotFound error (not a generic +// Internal) so callers can distinguish an expected miss from a real +// store failure. A known IP still resolves. +func TestSqlStore_GetPeerByIP_NotFound(t *testing.T) { + runTestForAllEngines(t, "../testdata/store.sql", func(t *testing.T, store Store) { + const accountID = "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + peer, err := store.GetPeerByIP(context.Background(), LockingStrengthNone, accountID, net.ParseIP("192.168.0.0")) + require.NoError(t, err, "known tunnel IP must resolve") + require.NotNil(t, peer) + + _, err = store.GetPeerByIP(context.Background(), LockingStrengthNone, accountID, net.ParseIP("100.65.0.99")) + require.Error(t, err, "unknown tunnel IP must error") + parsedErr, ok := status.FromError(err) + require.True(t, ok, "error must be a status error") + require.Equal(t, status.NotFound, parsedErr.Type(), "tunnel-IP miss must be NotFound, not Internal") + }) +} + +func TestSqlStore_SavePeer(t *testing.T) { + populateFields := testing_helpers.NewPopulateFields() + + runTestForAllEngines(t, "../testdata/store.sql", func(t *testing.T, store Store) { + account, err := store.GetAccount(context.Background(), "bf1c8084-ba50-4ce7-9439-34653001fc3b") + require.NoError(t, err) + + metadata := nbpeer.PeerSystemMeta{} + reflectedMetadata := reflect.ValueOf(&metadata).Elem() + + numOfFields, err := populateFields.PopulateAll(reflectedMetadata) + assert.NoError(t, err) + assert.Equal(t, 33, numOfFields) + + // save status of non-existing peer + peer := &nbpeer.Peer{ + Key: "peerkey", + ID: "testpeer", + IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::1"), + Meta: metadata, //nbpeer.PeerSystemMeta{Hostname: "testingpeer"}, + Name: "peer name", + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, + CreatedAt: time.Now().UTC(), + } + ctx := context.Background() + err = store.SavePeer(ctx, account.Id, peer) + assert.Error(t, err) + parsedErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") + + // save new status of existing peer + account.Peers[peer.ID] = peer + + err = store.SaveAccount(context.Background(), account) + require.NoError(t, err) + + updatedPeer := peer.Copy() + updatedPeer.Status.Connected = false + updatedPeer.Meta.Hostname = "updatedpeer" + + err = store.SavePeer(ctx, account.Id, updatedPeer) + require.NoError(t, err) + + account, err = store.GetAccount(context.Background(), account.Id) + require.NoError(t, err) + + actual := account.Peers[peer.ID] + assert.Equal(t, updatedPeer.Meta, actual.Meta) + assert.Equal(t, updatedPeer.Status.Connected, actual.Status.Connected) + assert.Equal(t, updatedPeer.Status.LoginExpired, actual.Status.LoginExpired) + assert.Equal(t, updatedPeer.Status.RequiresApproval, actual.Status.RequiresApproval) + assert.WithinDurationf(t, updatedPeer.Status.LastSeen, actual.Status.LastSeen.UTC(), time.Millisecond, "LastSeen should be equal") + }) +} + +func TestSqlStore_SavePeerStatus(t *testing.T) { + store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanUp) + assert.NoError(t, err) + + account, err := store.GetAccount(context.Background(), "bf1c8084-ba50-4ce7-9439-34653001fc3b") + require.NoError(t, err) + + // save status of non-existing peer + newStatus := nbpeer.PeerStatus{Connected: false, LastSeen: time.Now().UTC()} + err = store.SavePeerStatus(context.Background(), account.Id, "non-existing-peer", newStatus) + assert.Error(t, err) + parsedErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") + + // save new status of existing peer + account.Peers["testpeer"] = &nbpeer.Peer{ + Key: "peerkey", + ID: "testpeer", + IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::1"), + Meta: nbpeer.PeerSystemMeta{}, + Name: "peer name", + Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, + } + + err = store.SaveAccount(context.Background(), account) + require.NoError(t, err) + + err = store.SavePeerStatus(context.Background(), account.Id, "testpeer", newStatus) + require.NoError(t, err) + + account, err = store.GetAccount(context.Background(), account.Id) + require.NoError(t, err) + + actual := account.Peers["testpeer"].Status + assert.Equal(t, newStatus.Connected, actual.Connected) + assert.Equal(t, newStatus.LoginExpired, actual.LoginExpired) + assert.Equal(t, newStatus.RequiresApproval, actual.RequiresApproval) + assert.WithinDurationf(t, newStatus.LastSeen, actual.LastSeen.UTC(), time.Millisecond, "LastSeen should be equal") + + newStatus.Connected = true + + err = store.SavePeerStatus(context.Background(), account.Id, "testpeer", newStatus) + require.NoError(t, err) + + account, err = store.GetAccount(context.Background(), account.Id) + require.NoError(t, err) + + actual = account.Peers["testpeer"].Status + assert.Equal(t, newStatus.Connected, actual.Connected) + assert.Equal(t, newStatus.LoginExpired, actual.LoginExpired) + assert.Equal(t, newStatus.RequiresApproval, actual.RequiresApproval) + assert.WithinDurationf(t, newStatus.LastSeen, actual.LastSeen.UTC(), time.Millisecond, "LastSeen should be equal") +} + +func TestSqlite_GetTakenIPs(t *testing.T) { + t.Setenv("NETBIRD_STORE_ENGINE", string(types.SqliteStoreEngine)) + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + defer cleanup() + if err != nil { + t.Fatal(err) + } + + existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + _, err = store.GetAccount(context.Background(), existingAccountID) + require.NoError(t, err) + + takenIPs, err := store.GetTakenIPs(context.Background(), LockingStrengthNone, existingAccountID) + require.NoError(t, err) + assert.Equal(t, []netip.Addr{}, takenIPs) + + peer1 := &nbpeer.Peer{ + ID: "peer1", + AccountID: existingAccountID, + Key: "key1", + DNSLabel: "peer1", + IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), + IPv6: netip.MustParseAddr("fd00::1:1:1:1"), + } + err = store.AddPeerToAccount(context.Background(), peer1) + require.NoError(t, err) + + takenIPs, err = store.GetTakenIPs(context.Background(), LockingStrengthNone, existingAccountID) + require.NoError(t, err) + ip1 := netip.AddrFrom4([4]byte{1, 1, 1, 1}) + assert.Equal(t, []netip.Addr{ip1}, takenIPs) + + peer2 := &nbpeer.Peer{ + ID: "peer1second", + AccountID: existingAccountID, + Key: "key2", + DNSLabel: "peer1-1", + IP: netip.AddrFrom4([4]byte{2, 2, 2, 2}), + IPv6: netip.MustParseAddr("fd00::2:2:2:2"), + } + err = store.AddPeerToAccount(context.Background(), peer2) + require.NoError(t, err) + + takenIPs, err = store.GetTakenIPs(context.Background(), LockingStrengthNone, existingAccountID) + require.NoError(t, err) + ip2 := netip.AddrFrom4([4]byte{2, 2, 2, 2}) + assert.Equal(t, []netip.Addr{ip1, ip2}, takenIPs) +} + +func TestSqlite_GetPeerLabelsInAccount(t *testing.T) { + runTestForAllEngines(t, "../testdata/extended-store.sql", func(t *testing.T, store Store) { + existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + peerHostname := "peer1" + + _, err := store.GetAccount(context.Background(), existingAccountID) + require.NoError(t, err) + + labels, err := store.GetPeerLabelsInAccount(context.Background(), LockingStrengthNone, existingAccountID, peerHostname) + require.NoError(t, err) + assert.Equal(t, []string{}, labels) + + peer1 := &nbpeer.Peer{ + ID: "peer1", + AccountID: existingAccountID, + Key: "key1", + DNSLabel: "peer1", + IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), + IPv6: netip.MustParseAddr("fd00::1:1:1:1"), + } + err = store.AddPeerToAccount(context.Background(), peer1) + require.NoError(t, err) + + labels, err = store.GetPeerLabelsInAccount(context.Background(), LockingStrengthNone, existingAccountID, peerHostname) + require.NoError(t, err) + assert.Equal(t, []string{"peer1"}, labels) + + peer2 := &nbpeer.Peer{ + ID: "peer1second", + AccountID: existingAccountID, + Key: "key2", + DNSLabel: "peer1-1", + IP: netip.AddrFrom4([4]byte{2, 2, 2, 2}), + IPv6: netip.MustParseAddr("fd00::2:2:2:2"), + } + err = store.AddPeerToAccount(context.Background(), peer2) + require.NoError(t, err) + + labels, err = store.GetPeerLabelsInAccount(context.Background(), LockingStrengthNone, existingAccountID, peerHostname) + require.NoError(t, err) + + expected := []string{"peer1", "peer1-1"} + sort.Strings(expected) + sort.Strings(labels) + assert.Equal(t, expected, labels) + }) +} + +func Test_AddPeerWithSameDnsLabel(t *testing.T) { + runTestForAllEngines(t, "../testdata/extended-store.sql", func(t *testing.T, store Store) { + existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + _, err := store.GetAccount(context.Background(), existingAccountID) + require.NoError(t, err) + + peer1 := &nbpeer.Peer{ + ID: "peer1", + AccountID: existingAccountID, + Key: "key1", + DNSLabel: "peer1.domain.test", + } + err = store.AddPeerToAccount(context.Background(), peer1) + require.NoError(t, err) + + peer2 := &nbpeer.Peer{ + ID: "peer1second", + AccountID: existingAccountID, + Key: "key2", + DNSLabel: "peer1.domain.test", + } + err = store.AddPeerToAccount(context.Background(), peer2) + require.Error(t, err) + }) +} + +func Test_AddPeerWithSameIP(t *testing.T) { + runTestForAllEngines(t, "../testdata/extended-store.sql", func(t *testing.T, store Store) { + existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + _, err := store.GetAccount(context.Background(), existingAccountID) + require.NoError(t, err) + + peer1 := &nbpeer.Peer{ + ID: "peer1", + AccountID: existingAccountID, + Key: "key1", + IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), + IPv6: netip.MustParseAddr("fd00::1:1:1:1"), + } + err = store.AddPeerToAccount(context.Background(), peer1) + require.NoError(t, err) + + peer2 := &nbpeer.Peer{ + ID: "peer1second", + AccountID: existingAccountID, + Key: "key2", + IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), + IPv6: netip.MustParseAddr("fd00::2:2:2:2"), + } + err = store.AddPeerToAccount(context.Background(), peer2) + require.Error(t, err) + }) +} + +func TestSqlStore_GetPeerByID(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_policy_migrate.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + tests := []struct { + name string + peerID string + expectError bool + }{ + { + name: "retrieve existing peer", + peerID: "cfefqs706sqkneg59g4g", + expectError: false, + }, + { + name: "retrieve non-existing peer", + peerID: "non-existing", + expectError: true, + }, + { + name: "retrieve with empty peer ID", + peerID: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + peer, err := store.GetPeerByID(context.Background(), LockingStrengthNone, accountID, tt.peerID) + if tt.expectError { + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) + require.Nil(t, peer) + } else { + require.NoError(t, err) + require.NotNil(t, peer) + require.Equal(t, tt.peerID, peer.ID) + } + }) + } +} + +func TestSqlStore_GetPeersByIDs(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_policy_migrate.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + tests := []struct { + name string + peerIDs []string + expectedCount int + }{ + { + name: "retrieve existing peers by existing IDs", + peerIDs: []string{"cfefqs706sqkneg59g4g", "cfeg6sf06sqkneg59g50"}, + expectedCount: 2, + }, + { + name: "empty peer IDs list", + peerIDs: []string{}, + expectedCount: 0, + }, + { + name: "non-existing peer IDs", + peerIDs: []string{"nonexistent1", "nonexistent2"}, + expectedCount: 0, + }, + { + name: "mixed existing and non-existing peer IDs", + peerIDs: []string{"cfeg6sf06sqkneg59g50", "nonexistent"}, + expectedCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + peers, err := store.GetPeersByIDs(context.Background(), LockingStrengthNone, accountID, tt.peerIDs) + require.NoError(t, err) + require.Len(t, peers, tt.expectedCount) + }) + } +} + +func TestSqlStore_AddPeerToAccount(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_policy_migrate.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + peer := &nbpeer.Peer{ + ID: "peer1", + AccountID: accountID, + Key: "key", + IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), + IPv6: netip.MustParseAddr("fd00::1:1:1:1"), + Meta: nbpeer.PeerSystemMeta{ + Hostname: "hostname", + GoOS: "linux", + Kernel: "Linux", + Core: "21.04", + Platform: "x86_64", + OS: "Ubuntu", + WtVersion: "development", + UIVersion: "development", + }, + Name: "peer.test", + DNSLabel: "peer", + Status: &nbpeer.PeerStatus{ + LastSeen: time.Now().UTC(), + Connected: true, + LoginExpired: false, + RequiresApproval: false, + }, + SSHKey: "ssh-key", + SSHEnabled: false, + LoginExpirationEnabled: true, + InactivityExpirationEnabled: false, + LastLogin: util.ToPtr(time.Now().UTC()), + CreatedAt: time.Now().UTC(), + Ephemeral: true, + } + err = store.AddPeerToAccount(context.Background(), peer) + require.NoError(t, err, "failed to add peer to account") + + storedPeer, err := store.GetPeerByID(context.Background(), LockingStrengthNone, accountID, peer.ID) + require.NoError(t, err, "failed to get peer") + + assert.Equal(t, peer.ID, storedPeer.ID) + assert.Equal(t, peer.AccountID, storedPeer.AccountID) + assert.Equal(t, peer.Key, storedPeer.Key) + assert.Equal(t, peer.IP.String(), storedPeer.IP.String()) + assert.Equal(t, peer.Meta, storedPeer.Meta) + assert.Equal(t, peer.Name, storedPeer.Name) + assert.Equal(t, peer.DNSLabel, storedPeer.DNSLabel) + assert.Equal(t, peer.SSHKey, storedPeer.SSHKey) + assert.Equal(t, peer.SSHEnabled, storedPeer.SSHEnabled) + assert.Equal(t, peer.LoginExpirationEnabled, storedPeer.LoginExpirationEnabled) + assert.Equal(t, peer.InactivityExpirationEnabled, storedPeer.InactivityExpirationEnabled) + assert.WithinDurationf(t, peer.GetLastLogin(), storedPeer.GetLastLogin().UTC(), time.Millisecond, "LastLogin should be equal") + assert.WithinDurationf(t, peer.CreatedAt, storedPeer.CreatedAt.UTC(), time.Millisecond, "CreatedAt should be equal") + assert.Equal(t, peer.Ephemeral, storedPeer.Ephemeral) + assert.Equal(t, peer.Status.Connected, storedPeer.Status.Connected) + assert.Equal(t, peer.Status.LoginExpired, storedPeer.Status.LoginExpired) + assert.Equal(t, peer.Status.RequiresApproval, storedPeer.Status.RequiresApproval) + assert.WithinDurationf(t, peer.Status.LastSeen, storedPeer.Status.LastSeen.UTC(), time.Millisecond, "LastSeen should be equal") +} + +func TestSqlStore_GetAccountPeers(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_with_expired_peers.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + tests := []struct { + name string + accountID string + nameFilter string + ipFilter string + expectedCount int + }{ + { + name: "should retrieve peers for an existing account ID", + accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", + expectedCount: 5, + }, + { + name: "should return no peers for a non-existing account ID", + accountID: "nonexistent", + expectedCount: 0, + }, + { + name: "should return no peers for an empty account ID", + accountID: "", + expectedCount: 0, + }, + { + name: "should filter peers by name", + accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", + nameFilter: "expiredhost", + expectedCount: 1, + }, + { + name: "should filter peers by partial name", + accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", + nameFilter: "host", + expectedCount: 4, + }, + { + name: "should filter peers by ip", + accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", + ipFilter: "100.64.39.54", + expectedCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + peers, err := store.GetAccountPeers(context.Background(), LockingStrengthNone, tt.accountID, tt.nameFilter, tt.ipFilter) + require.NoError(t, err) + require.Len(t, peers, tt.expectedCount) + }) + } + +} + +func TestSqlStore_GetAccountPeersWithExpiration(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_with_expired_peers.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + tests := []struct { + name string + accountID string + expectedCount int + expectedPeerIDs []string + }{ + { + name: "should retrieve only non-expired peers with expiration enabled", + accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", + expectedCount: 1, + expectedPeerIDs: []string{"notexpired01"}, + }, + { + name: "should return no peers with expiration for a non-existing account ID", + accountID: "nonexistent", + expectedCount: 0, + }, + { + name: "should return no peers with expiration for a empty account ID", + accountID: "", + expectedCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + peers, err := store.GetAccountPeersWithExpiration(context.Background(), LockingStrengthNone, tt.accountID) + require.NoError(t, err) + require.Len(t, peers, tt.expectedCount) + for i, peer := range peers { + assert.Equal(t, tt.expectedPeerIDs[i], peer.ID) + } + }) + } +} + +func TestSqlStore_GetAccountPeersWithExpiration_ExcludesAlreadyExpired(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_with_expired_peers.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + peers, err := store.GetAccountPeersWithExpiration(context.Background(), LockingStrengthNone, accountID) + require.NoError(t, err) + + // Verify the already-expired peer (cg05lnblo1hkg2j514p0) is not returned + for _, peer := range peers { + assert.NotEqual(t, "cg05lnblo1hkg2j514p0", peer.ID, "already expired peer should not be returned") + assert.False(t, peer.Status.LoginExpired, "returned peers should not have LoginExpired set") + } +} + +func TestSqlStore_GetAccountPeersWithInactivity(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_with_expired_peers.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + tests := []struct { + name string + accountID string + expectedCount int + }{ + { + name: "should retrieve peers with inactivity for an existing account ID", + accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", + expectedCount: 1, + }, + { + name: "should return no peers with inactivity for a non-existing account ID", + accountID: "nonexistent", + expectedCount: 0, + }, + { + name: "should return no peers with inactivity for an empty account ID", + accountID: "", + expectedCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + peers, err := store.GetAccountPeersWithInactivity(context.Background(), LockingStrengthNone, tt.accountID) + require.NoError(t, err) + require.Len(t, peers, tt.expectedCount) + }) + } +} + +func TestSqlStore_GetAllEphemeralPeers(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/storev1.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + peers, err := store.GetAllEphemeralPeers(context.Background(), LockingStrengthNone) + require.NoError(t, err) + require.Len(t, peers, 1) + require.True(t, peers[0].Ephemeral) +} + +func TestSqlStore_GetUserPeers(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_with_expired_peers.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + tests := []struct { + name string + accountID string + userID string + expectedCount int + }{ + { + name: "should retrieve peers for existing account ID and user ID", + accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", + userID: "f4f6d672-63fb-11ec-90d6-0242ac120003", + expectedCount: 1, + }, + { + name: "should return no peers for non-existing account ID with existing user ID", + accountID: "nonexistent", + userID: "f4f6d672-63fb-11ec-90d6-0242ac120003", + expectedCount: 0, + }, + { + name: "should return no peers for non-existing user ID with existing account ID", + accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", + userID: "nonexistent_user", + expectedCount: 0, + }, + { + name: "should retrieve peers for another valid account ID and user ID", + accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", + userID: "edafee4e-63fb-11ec-90d6-0242ac120003", + expectedCount: 3, + }, + { + name: "should return no peers for existing account ID with empty user ID", + accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", + userID: "", + expectedCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + peers, err := store.GetUserPeers(context.Background(), LockingStrengthNone, tt.accountID, tt.userID) + require.NoError(t, err) + require.Len(t, peers, tt.expectedCount) + }) + } +} + +func TestSqlStore_DeletePeer(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_with_expired_peers.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + peerID := "csrnkiq7qv9d8aitqd50" + + err = store.DeletePeer(context.Background(), accountID, peerID) + require.NoError(t, err) + + peer, err := store.GetPeerByID(context.Background(), LockingStrengthNone, accountID, peerID) + require.Error(t, err) + require.Nil(t, peer) +} + +func BenchmarkGetAccountPeers(b *testing.B) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_with_expired_peers.sql", b.TempDir()) + if err != nil { + b.Fatal(err) + } + b.Cleanup(cleanup) + + numberOfPeers := 1000 + numberOfGroups := 200 + numberOfPeersPerGroup := 500 + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + peers := make([]*nbpeer.Peer, 0, numberOfPeers) + for i := 0; i < numberOfPeers; i++ { + peer := &nbpeer.Peer{ + ID: fmt.Sprintf("peer-%d", i), + AccountID: accountID, + Key: fmt.Sprintf("key-%d", i), + DNSLabel: fmt.Sprintf("peer%d.example.com", i), + IP: intToIPv4(uint32(i)), + } + err = store.AddPeerToAccount(context.Background(), peer) + if err != nil { + b.Fatalf("Failed to add peer: %v", err) + } + peers = append(peers, peer) + } + + for i := 0; i < numberOfGroups; i++ { + groupID := fmt.Sprintf("group-%d", i) + group := &types.Group{ + ID: groupID, + AccountID: accountID, + } + err = store.CreateGroup(context.Background(), group) + if err != nil { + b.Fatalf("Failed to create group: %v", err) + } + for j := 0; j < numberOfPeersPerGroup; j++ { + peerIndex := (i*numberOfPeersPerGroup + j) % numberOfPeers + err = store.AddPeerToGroup(context.Background(), accountID, peers[peerIndex].ID, groupID) + if err != nil { + b.Fatalf("Failed to add peer to group: %v", err) + } + } + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := store.GetPeerGroups(context.Background(), LockingStrengthNone, accountID, peers[i%numberOfPeers].ID) + if err != nil { + b.Fatal(err) + } + } +} + +func intToIPv4(n uint32) netip.Addr { + var b [4]byte + binary.BigEndian.PutUint32(b[:], n) + return netip.AddrFrom4(b) +} + +func TestSqlStore_GetUserIDByPeerKey(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + userID := "test-user-123" + peerKey := "peer-key-abc" + + peer := &nbpeer.Peer{ + ID: "test-peer-1", + Key: peerKey, + AccountID: existingAccountID, + UserID: userID, + IP: netip.AddrFrom4([4]byte{10, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::a00:1"), + DNSLabel: "test-peer-1", + } + + err = store.AddPeerToAccount(context.Background(), peer) + require.NoError(t, err) + + retrievedUserID, err := store.GetUserIDByPeerKey(context.Background(), LockingStrengthNone, peerKey) + require.NoError(t, err) + assert.Equal(t, userID, retrievedUserID) +} + +func TestSqlStore_GetUserIDByPeerKey_NotFound(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + nonExistentPeerKey := "non-existent-peer-key" + + userID, err := store.GetUserIDByPeerKey(context.Background(), LockingStrengthNone, nonExistentPeerKey) + require.Error(t, err) + assert.Equal(t, "", userID) +} + +func TestSqlStore_GetUserIDByPeerKey_NoUserID(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + peerKey := "peer-key-abc" + + peer := &nbpeer.Peer{ + ID: "test-peer-1", + Key: peerKey, + AccountID: existingAccountID, + UserID: "", + IP: netip.AddrFrom4([4]byte{10, 0, 0, 1}), + IPv6: netip.MustParseAddr("fd00::a00:1"), + DNSLabel: "test-peer-1", + } + + err = store.AddPeerToAccount(context.Background(), peer) + require.NoError(t, err) + + retrievedUserID, err := store.GetUserIDByPeerKey(context.Background(), LockingStrengthNone, peerKey) + require.NoError(t, err) + assert.Equal(t, "", retrievedUserID) +} + +func TestSqlStore_ApproveAccountPeers(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + accountID := "test-account" + ctx := context.Background() + + account := newAccountWithId(ctx, accountID, "testuser", "example.com") + err := store.SaveAccount(ctx, account) + require.NoError(t, err) + + peers := []*nbpeer.Peer{ + { + ID: "peer1", + AccountID: accountID, + DNSLabel: "peer1.netbird.cloud", + Key: "peer1-key", + IP: netip.MustParseAddr("100.64.0.1"), + IPv6: netip.MustParseAddr("fd00::1"), + Status: &nbpeer.PeerStatus{ + RequiresApproval: true, + LastSeen: time.Now().UTC(), + }, + }, + { + ID: "peer2", + AccountID: accountID, + DNSLabel: "peer2.netbird.cloud", + Key: "peer2-key", + IP: netip.MustParseAddr("100.64.0.2"), + IPv6: netip.MustParseAddr("fd00::2"), + Status: &nbpeer.PeerStatus{ + RequiresApproval: true, + LastSeen: time.Now().UTC(), + }, + }, + { + ID: "peer3", + AccountID: accountID, + DNSLabel: "peer3.netbird.cloud", + Key: "peer3-key", + IP: netip.MustParseAddr("100.64.0.3"), + IPv6: netip.MustParseAddr("fd00::3"), + Status: &nbpeer.PeerStatus{ + RequiresApproval: false, + LastSeen: time.Now().UTC(), + }, + }, + } + + for _, peer := range peers { + err = store.AddPeerToAccount(ctx, peer) + require.NoError(t, err) + } + + t.Run("approve all pending peers", func(t *testing.T) { + count, err := store.ApproveAccountPeers(ctx, accountID) + require.NoError(t, err) + assert.Equal(t, 2, count) + + allPeers, err := store.GetAccountPeers(ctx, LockingStrengthNone, accountID, "", "") + require.NoError(t, err) + + for _, peer := range allPeers { + assert.False(t, peer.Status.RequiresApproval, "peer %s should not require approval", peer.ID) + } + }) + + t.Run("no peers to approve", func(t *testing.T) { + count, err := store.ApproveAccountPeers(ctx, accountID) + require.NoError(t, err) + assert.Equal(t, 0, count) + }) + + t.Run("non-existent account", func(t *testing.T) { + count, err := store.ApproveAccountPeers(ctx, "non-existent") + require.NoError(t, err) + assert.Equal(t, 0, count) + }) + }) +} diff --git a/management/server/store/sql_store_personal_access_token.go b/management/server/store/sql_store_personal_access_token.go new file mode 100644 index 000000000..351d122fd --- /dev/null +++ b/management/server/store/sql_store_personal_access_token.go @@ -0,0 +1,178 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/jackc/pgx/v5" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/management/server/util" + "github.com/netbirdio/netbird/shared/management/status" +) + +// DeleteHashedPAT2TokenIDIndex is noop in SqlStore +func (s *SqlStore) DeleteHashedPAT2TokenIDIndex(hashedToken string) error { + return nil +} + +// DeleteTokenID2UserIDIndex is noop in SqlStore +func (s *SqlStore) DeleteTokenID2UserIDIndex(tokenID string) error { + return nil +} + +func (s *SqlStore) GetTokenIDByHashedToken(ctx context.Context, hashedToken string) (string, error) { + var token types.PersonalAccessToken + result := s.db.Take(&token, "hashed_token = ?", hashedToken) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return "", status.Errorf(status.NotFound, "account not found: index lookup failed") + } + log.WithContext(ctx).Errorf("error when getting token from the store: %s", result.Error) + return "", status.NewGetAccountFromStoreError(result.Error) + } + + return token.ID, nil +} + +func (s *SqlStore) getPersonalAccessTokens(ctx context.Context, userIDs []string) ([]types.PersonalAccessToken, error) { + if len(userIDs) == 0 { + return nil, nil + } + const query = `SELECT id, user_id, name, hashed_token, expiration_date, created_by, created_at, last_used FROM personal_access_tokens WHERE user_id = ANY($1)` + rows, err := s.pool.Query(ctx, query, userIDs) + if err != nil { + return nil, err + } + pats, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (types.PersonalAccessToken, error) { + var pat types.PersonalAccessToken + var expirationDate, lastUsed, createdAt sql.NullTime + err := row.Scan(&pat.ID, &pat.UserID, &pat.Name, &pat.HashedToken, &expirationDate, &pat.CreatedBy, &createdAt, &lastUsed) + if err == nil { + if expirationDate.Valid { + pat.ExpirationDate = &expirationDate.Time + } + if createdAt.Valid { + pat.CreatedAt = createdAt.Time + } + if lastUsed.Valid { + pat.LastUsed = &lastUsed.Time + } + } + return pat, err + }) + if err != nil { + return nil, err + } + return pats, nil +} + +// GetPATByHashedToken returns a PersonalAccessToken by its hashed token. +func (s *SqlStore) GetPATByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken string) (*types.PersonalAccessToken, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var pat types.PersonalAccessToken + result := tx.Take(&pat, "hashed_token = ?", hashedToken) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewPATNotFoundError(hashedToken) + } + log.WithContext(ctx).Errorf("failed to get pat by hash from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get pat by hash from store") + } + + return &pat, nil +} + +// GetPATByID retrieves a personal access token by its ID and user ID. +func (s *SqlStore) GetPATByID(ctx context.Context, lockStrength LockingStrength, userID string, patID string) (*types.PersonalAccessToken, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var pat types.PersonalAccessToken + result := tx. + Take(&pat, "id = ? AND user_id = ?", patID, userID) + if err := result.Error; err != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewPATNotFoundError(patID) + } + log.WithContext(ctx).Errorf("failed to get pat from the store: %s", err) + return nil, status.Errorf(status.Internal, "failed to get pat from store") + } + + return &pat, nil +} + +// GetUserPATs retrieves personal access tokens for a user. +func (s *SqlStore) GetUserPATs(ctx context.Context, lockStrength LockingStrength, userID string) ([]*types.PersonalAccessToken, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var pats []*types.PersonalAccessToken + result := tx.Find(&pats, "user_id = ?", userID) + if err := result.Error; err != nil { + log.WithContext(ctx).Errorf("failed to get user pat's from the store: %s", err) + return nil, status.Errorf(status.Internal, "failed to get user pat's from store") + } + + return pats, nil +} + +// MarkPATUsed marks a personal access token as used. +func (s *SqlStore) MarkPATUsed(ctx context.Context, patID string) error { + patCopy := types.PersonalAccessToken{ + LastUsed: util.ToPtr(time.Now().UTC()), + } + + fieldsToUpdate := []string{"last_used"} + result := s.db.Select(fieldsToUpdate). + Where(idQueryCondition, patID).Updates(&patCopy) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to mark pat as used: %s", result.Error) + return status.Errorf(status.Internal, "failed to mark pat as used") + } + + if result.RowsAffected == 0 { + return status.NewPATNotFoundError(patID) + } + + return nil +} + +// SavePAT saves a personal access token to the database. +func (s *SqlStore) SavePAT(ctx context.Context, pat *types.PersonalAccessToken) error { + result := s.db.Save(pat) + if err := result.Error; err != nil { + log.WithContext(ctx).Errorf("failed to save pat to the store: %s", err) + return status.Errorf(status.Internal, "failed to save pat to store") + } + + return nil +} + +// DeletePAT deletes a personal access token from the database. +func (s *SqlStore) DeletePAT(ctx context.Context, userID, patID string) error { + result := s.db.Delete(&types.PersonalAccessToken{}, "user_id = ? AND id = ?", userID, patID) + if err := result.Error; err != nil { + log.WithContext(ctx).Errorf("failed to delete pat from the store: %s", err) + return status.Errorf(status.Internal, "failed to delete pat from store") + } + + if result.RowsAffected == 0 { + return status.NewPATNotFoundError(patID) + } + + return nil +} diff --git a/management/server/store/sql_store_personal_access_token_test.go b/management/server/store/sql_store_personal_access_token_test.go new file mode 100644 index 000000000..f40e0d9c6 --- /dev/null +++ b/management/server/store/sql_store_personal_access_token_test.go @@ -0,0 +1,186 @@ +package store + +import ( + "context" + "os" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/management/server/util" + "github.com/netbirdio/netbird/shared/management/status" +) + +func Test_GetTokenIDByHashedToken(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("The SQLite store is not properly supported by Windows yet") + } + + runTestForAllEngines(t, "../testdata/store.sql", func(t *testing.T, store Store) { + hashed := "SoMeHaShEdToKeN" + id := "9dj38s35-63fb-11ec-90d6-0242ac120003" + + token, err := store.GetTokenIDByHashedToken(context.Background(), hashed) + require.NoError(t, err) + require.Equal(t, id, token) + + _, err = store.GetTokenIDByHashedToken(context.Background(), "non-existing-hash") + require.Error(t, err) + parsedErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") + }) +} + +func TestPostgresql_GetTokenIDByHashedToken(t *testing.T) { + if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" { + t.Skip("skip CI tests on darwin and windows") + } + + t.Setenv("NETBIRD_STORE_ENGINE", string(types.PostgresStoreEngine)) + store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanUp) + assert.NoError(t, err) + + hashed := "SoMeHaShEdToKeN" + id := "9dj38s35-63fb-11ec-90d6-0242ac120003" + + token, err := store.GetTokenIDByHashedToken(context.Background(), hashed) + require.NoError(t, err) + require.Equal(t, id, token) +} + +func TestSqlStore_GetPATByID(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + userID := "f4f6d672-63fb-11ec-90d6-0242ac120003" + + tests := []struct { + name string + patID string + expectError bool + }{ + { + name: "retrieve existing PAT", + patID: "9dj38s35-63fb-11ec-90d6-0242ac120003", + expectError: false, + }, + { + name: "retrieve non-existing PAT", + patID: "non-existing", + expectError: true, + }, + { + name: "retrieve with empty PAT ID", + patID: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pat, err := store.GetPATByID(context.Background(), LockingStrengthNone, userID, tt.patID) + if tt.expectError { + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) + require.Nil(t, pat) + } else { + require.NoError(t, err) + require.NotNil(t, pat) + require.Equal(t, tt.patID, pat.ID) + } + }) + } +} + +func TestSqlStore_GetUserPATs(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + userPATs, err := store.GetUserPATs(context.Background(), LockingStrengthNone, "f4f6d672-63fb-11ec-90d6-0242ac120003") + require.NoError(t, err) + require.Len(t, userPATs, 1) +} + +func TestSqlStore_GetPATByHashedToken(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + pat, err := store.GetPATByHashedToken(context.Background(), LockingStrengthNone, "SoMeHaShEdToKeN") + require.NoError(t, err) + require.Equal(t, "9dj38s35-63fb-11ec-90d6-0242ac120003", pat.ID) +} + +func TestSqlStore_MarkPATUsed(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + userID := "f4f6d672-63fb-11ec-90d6-0242ac120003" + patID := "9dj38s35-63fb-11ec-90d6-0242ac120003" + + err = store.MarkPATUsed(context.Background(), patID) + require.NoError(t, err) + + pat, err := store.GetPATByID(context.Background(), LockingStrengthNone, userID, patID) + require.NoError(t, err) + now := time.Now().UTC() + require.WithinRange(t, pat.LastUsed.UTC(), now.Add(-15*time.Second), now, "LastUsed should be within 1 second of now") +} + +func TestSqlStore_SavePAT(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + userID := "edafee4e-63fb-11ec-90d6-0242ac120003" + + pat := &types.PersonalAccessToken{ + ID: "pat-id", + UserID: userID, + Name: "token", + HashedToken: "SoMeHaShEdToKeN", + ExpirationDate: util.ToPtr(time.Now().UTC().Add(12 * time.Hour)), + CreatedBy: userID, + CreatedAt: time.Now().UTC().Add(time.Hour), + LastUsed: util.ToPtr(time.Now().UTC().Add(-15 * time.Minute)), + } + err = store.SavePAT(context.Background(), pat) + require.NoError(t, err) + + savePAT, err := store.GetPATByID(context.Background(), LockingStrengthNone, userID, pat.ID) + require.NoError(t, err) + require.Equal(t, pat.ID, savePAT.ID) + require.Equal(t, pat.UserID, savePAT.UserID) + require.Equal(t, pat.HashedToken, savePAT.HashedToken) + require.Equal(t, pat.CreatedBy, savePAT.CreatedBy) + require.WithinDurationf(t, pat.GetExpirationDate(), savePAT.ExpirationDate.UTC(), time.Millisecond, "ExpirationDate should be equal") + require.WithinDurationf(t, pat.CreatedAt, savePAT.CreatedAt.UTC(), time.Millisecond, "CreatedAt should be equal") + require.WithinDurationf(t, pat.GetLastUsed(), savePAT.LastUsed.UTC(), time.Millisecond, "LastUsed should be equal") +} + +func TestSqlStore_DeletePAT(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + userID := "f4f6d672-63fb-11ec-90d6-0242ac120003" + patID := "9dj38s35-63fb-11ec-90d6-0242ac120003" + + err = store.DeletePAT(context.Background(), userID, patID) + require.NoError(t, err) + + pat, err := store.GetPATByID(context.Background(), LockingStrengthNone, userID, patID) + require.Error(t, err) + require.Nil(t, pat) +} diff --git a/management/server/store/sql_store_policy.go b/management/server/store/sql_store_policy.go new file mode 100644 index 000000000..7725dede5 --- /dev/null +++ b/management/server/store/sql_store_policy.go @@ -0,0 +1,151 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +func (s *SqlStore) getPolicies(ctx context.Context, accountID string) ([]*types.Policy, error) { + const query = `SELECT id, account_id, public_id, name, description, enabled, source_posture_checks FROM policies WHERE account_id = $1` + rows, err := s.pool.Query(ctx, query, accountID) + if err != nil { + return nil, err + } + policies, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (*types.Policy, error) { + var p types.Policy + var checks []byte + var enabled sql.NullBool + err := row.Scan(&p.ID, &p.AccountID, &p.PublicID, &p.Name, &p.Description, &enabled, &checks) + if err == nil { + if enabled.Valid { + p.Enabled = enabled.Bool + } + if checks != nil { + _ = json.Unmarshal(checks, &p.SourcePostureChecks) + } + } + return &p, err + }) + if err != nil { + return nil, err + } + return policies, nil +} + +// GetAccountPolicies retrieves policies for an account. +func (s *SqlStore) GetAccountPolicies(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.Policy, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var policies []*types.Policy + result := tx. + Preload(clause.Associations).Find(&policies, accountIDCondition, accountID) + if err := result.Error; err != nil { + log.WithContext(ctx).Errorf("failed to get policies from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get policies from store") + } + + return policies, nil +} + +// GetPolicyByID retrieves a policy by its ID and account ID. +func (s *SqlStore) GetPolicyByID(ctx context.Context, lockStrength LockingStrength, accountID, policyID string) (*types.Policy, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var policy *types.Policy + + result := tx.Preload(clause.Associations). + Take(&policy, accountAndIDQueryCondition, accountID, policyID) + if err := result.Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, status.NewPolicyNotFoundError(policyID) + } + log.WithContext(ctx).Errorf("failed to get policy from store: %s", err) + return nil, status.Errorf(status.Internal, "failed to get policy from store") + } + + return policy, nil +} + +// GetPolicyByIDOrPublicID retrieves a policy by either its ID or its PublicID. Peers report +// whichever of the two the network map they were served carries, so callers resolving a +// peer-reported reference cannot know upfront which namespace it belongs to. +func (s *SqlStore) GetPolicyByIDOrPublicID(ctx context.Context, lockStrength LockingStrength, accountID, policyID string) (*types.Policy, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var policy *types.Policy + + result := tx.Preload(clause.Associations). + Take(&policy, accountAndAnyIDQueryCondition, accountID, policyID, policyID) + if err := result.Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, status.NewPolicyNotFoundError(policyID) + } + log.WithContext(ctx).Errorf("failed to get policy from store: %s", err) + return nil, status.Errorf(status.Internal, "failed to get policy from store") + } + + return policy, nil +} + +func (s *SqlStore) CreatePolicy(ctx context.Context, policy *types.Policy) error { + result := s.db.Create(policy) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to create policy in store: %s", result.Error) + return status.Errorf(status.Internal, "failed to create policy in store") + } + + return nil +} + +// SavePolicy saves a policy to the database. +func (s *SqlStore) SavePolicy(ctx context.Context, policy *types.Policy) error { + result := s.db.Session(&gorm.Session{FullSaveAssociations: true}).Omit("public_id").Save(policy) + if err := result.Error; err != nil { + log.WithContext(ctx).Errorf("failed to save policy to the store: %s", err) + return status.Errorf(status.Internal, "failed to save policy to store") + } + return nil +} + +func (s *SqlStore) DeletePolicy(ctx context.Context, accountID, policyID string) error { + return s.transaction(func(tx *gorm.DB) error { + if err := tx.Where("policy_id = ?", policyID).Delete(&types.PolicyRule{}).Error; err != nil { + return fmt.Errorf("delete policy rules: %w", err) + } + + result := tx. + Where(accountAndIDQueryCondition, accountID, policyID). + Delete(&types.Policy{}) + + if err := result.Error; err != nil { + log.WithContext(ctx).Errorf("failed to delete policy from store: %s", err) + return status.Errorf(status.Internal, "failed to delete policy from store") + } + + if result.RowsAffected == 0 { + return status.NewPolicyNotFoundError(policyID) + } + + return nil + }) +} diff --git a/management/server/store/sql_store_policy_rule.go b/management/server/store/sql_store_policy_rule.go new file mode 100644 index 000000000..d1d6ef3d3 --- /dev/null +++ b/management/server/store/sql_store_policy_rule.go @@ -0,0 +1,88 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + + "github.com/jackc/pgx/v5" + log "github.com/sirupsen/logrus" + "gorm.io/gorm/clause" + + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +func (s *SqlStore) getPolicyRules(ctx context.Context, policyIDs []string) ([]*types.PolicyRule, error) { + if len(policyIDs) == 0 { + return nil, nil + } + const query = `SELECT id, policy_id, name, description, enabled, action, destinations, destination_resource, sources, source_resource, bidirectional, protocol, ports, port_ranges, authorized_groups, authorized_user FROM policy_rules WHERE policy_id = ANY($1)` + rows, err := s.pool.Query(ctx, query, policyIDs) + if err != nil { + return nil, err + } + rules, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (*types.PolicyRule, error) { + var r types.PolicyRule + var dest, destRes, sources, sourceRes, ports, portRanges, authorizedGroups []byte + var enabled, bidirectional sql.NullBool + var authorizedUser sql.NullString + err := row.Scan(&r.ID, &r.PolicyID, &r.Name, &r.Description, &enabled, &r.Action, &dest, &destRes, &sources, &sourceRes, &bidirectional, &r.Protocol, &ports, &portRanges, &authorizedGroups, &authorizedUser) + if err == nil { + if enabled.Valid { + r.Enabled = enabled.Bool + } + if bidirectional.Valid { + r.Bidirectional = bidirectional.Bool + } + if dest != nil { + _ = json.Unmarshal(dest, &r.Destinations) + } + if destRes != nil { + _ = json.Unmarshal(destRes, &r.DestinationResource) + } + if sources != nil { + _ = json.Unmarshal(sources, &r.Sources) + } + if sourceRes != nil { + _ = json.Unmarshal(sourceRes, &r.SourceResource) + } + if ports != nil { + _ = json.Unmarshal(ports, &r.Ports) + } + if portRanges != nil { + _ = json.Unmarshal(portRanges, &r.PortRanges) + } + if authorizedGroups != nil { + _ = json.Unmarshal(authorizedGroups, &r.AuthorizedGroups) + } + if authorizedUser.Valid { + r.AuthorizedUser = authorizedUser.String + } + } + return &r, err + }) + if err != nil { + return nil, err + } + return rules, nil +} + +func (s *SqlStore) GetPolicyRulesByResourceID(ctx context.Context, lockStrength LockingStrength, accountID string, resourceID string) ([]*types.PolicyRule, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var policyRules []*types.PolicyRule + resourceIDPattern := `%"ID":"` + resourceID + `"%` + result := tx.Where("source_resource LIKE ? OR destination_resource LIKE ?", resourceIDPattern, resourceIDPattern). + Find(&policyRules) + + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get policy rules for resource id from store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get policy rules for resource id from store") + } + + return policyRules, nil +} diff --git a/management/server/store/sql_store_policy_test.go b/management/server/store/sql_store_policy_test.go new file mode 100644 index 000000000..68865b184 --- /dev/null +++ b/management/server/store/sql_store_policy_test.go @@ -0,0 +1,152 @@ +package store + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +func TestSqlStore_GetPolicyByID(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + tests := []struct { + name string + policyID string + expectError bool + }{ + { + name: "retrieve existing policy", + policyID: "cs1tnh0hhcjnqoiuebf0", + expectError: false, + }, + { + name: "retrieve non-existing policy checks", + policyID: "non-existing", + expectError: true, + }, + { + name: "retrieve with empty policy ID", + policyID: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + policy, err := store.GetPolicyByID(context.Background(), LockingStrengthNone, accountID, tt.policyID) + if tt.expectError { + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) + require.Nil(t, policy) + } else { + require.NoError(t, err) + require.NotNil(t, policy) + require.Equal(t, tt.policyID, policy.ID) + } + }) + } +} + +func TestSqlStore_GetPolicyByIDOrPublicID(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + policyID := "cs1tnh0hhcjnqoiuebf0" + + policy, err := store.GetPolicyByID(context.Background(), LockingStrengthNone, accountID, policyID) + require.NoError(t, err) + require.NotEmpty(t, policy.PublicID) + + for _, id := range []string{policyID, policy.PublicID} { + policy, err := store.GetPolicyByIDOrPublicID(context.Background(), LockingStrengthNone, accountID, id) + require.NoError(t, err) + require.Equal(t, policyID, policy.ID) + } + + policy, err = store.GetPolicyByIDOrPublicID(context.Background(), LockingStrengthNone, accountID, "non-existing") + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) + require.Nil(t, policy) +} + +func TestSqlStore_CreatePolicy(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + policy := &types.Policy{ + ID: "policy-id", + AccountID: accountID, + Enabled: true, + Rules: []*types.PolicyRule{ + { + Enabled: true, + Sources: []string{"groupA"}, + Destinations: []string{"groupC"}, + Bidirectional: true, + Action: types.PolicyTrafficActionAccept, + }, + }, + } + err = store.CreatePolicy(context.Background(), policy) + require.NoError(t, err) + + savePolicy, err := store.GetPolicyByID(context.Background(), LockingStrengthNone, accountID, policy.ID) + require.NoError(t, err) + require.Equal(t, savePolicy, policy) + +} + +func TestSqlStore_SavePolicy(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + policyID := "cs1tnh0hhcjnqoiuebf0" + + policy, err := store.GetPolicyByID(context.Background(), LockingStrengthNone, accountID, policyID) + require.NoError(t, err) + + policy.Enabled = false + policy.Description = "policy" + policy.Rules[0].Sources = []string{"group"} + policy.Rules[0].Ports = []string{"80", "443"} + err = store.SavePolicy(context.Background(), policy) + require.NoError(t, err) + + savePolicy, err := store.GetPolicyByID(context.Background(), LockingStrengthNone, accountID, policy.ID) + require.NoError(t, err) + require.Equal(t, savePolicy, policy) +} + +func TestSqlStore_DeletePolicy(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + policyID := "cs1tnh0hhcjnqoiuebf0" + + err = store.DeletePolicy(context.Background(), accountID, policyID) + require.NoError(t, err) + + policy, err := store.GetPolicyByID(context.Background(), LockingStrengthNone, accountID, policyID) + require.Error(t, err) + require.Nil(t, policy) +} diff --git a/management/server/store/sql_store_posture_checks.go b/management/server/store/sql_store_posture_checks.go new file mode 100644 index 000000000..71997ec73 --- /dev/null +++ b/management/server/store/sql_store_posture_checks.go @@ -0,0 +1,137 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + + "github.com/jackc/pgx/v5" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/netbirdio/netbird/management/server/posture" + "github.com/netbirdio/netbird/shared/management/status" +) + +func (s *SqlStore) getPostureChecks(ctx context.Context, accountID string) ([]*posture.Checks, error) { + const query = `SELECT id, account_id, public_id, name, description, checks FROM posture_checks WHERE account_id = $1` + rows, err := s.pool.Query(ctx, query, accountID) + if err != nil { + return nil, err + } + checks, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (*posture.Checks, error) { + var c posture.Checks + var checksDef []byte + err := row.Scan(&c.ID, &c.AccountID, &c.PublicID, &c.Name, &c.Description, &checksDef) + if err == nil && checksDef != nil { + _ = json.Unmarshal(checksDef, &c.Checks) + } + return &c, err + }) + if err != nil { + return nil, err + } + return checks, nil +} + +func (s *SqlStore) GetPostureCheckByChecksDefinition(accountID string, checks *posture.ChecksDefinition) (*posture.Checks, error) { + definitionJSON, err := json.Marshal(checks) + if err != nil { + return nil, err + } + + var postureCheck posture.Checks + err = s.db.Where("account_id = ? AND checks = ?", accountID, string(definitionJSON)).Take(&postureCheck).Error + if err != nil { + return nil, err + } + + return &postureCheck, nil +} + +// GetAccountPostureChecks retrieves posture checks for an account. +func (s *SqlStore) GetAccountPostureChecks(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*posture.Checks, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var postureChecks []*posture.Checks + result := tx.Find(&postureChecks, accountIDCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get posture checks from store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get posture checks from store") + } + + return postureChecks, nil +} + +// GetPostureChecksByID retrieves posture checks by their ID and account ID. +func (s *SqlStore) GetPostureChecksByID(ctx context.Context, lockStrength LockingStrength, accountID, postureChecksID string) (*posture.Checks, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var postureCheck *posture.Checks + result := tx. + Take(&postureCheck, accountAndIDQueryCondition, accountID, postureChecksID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewPostureChecksNotFoundError(postureChecksID) + } + log.WithContext(ctx).Errorf("failed to get posture check from store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get posture check from store") + } + + return postureCheck, nil +} + +// GetPostureChecksByIDs retrieves posture checks by their IDs and account ID. +func (s *SqlStore) GetPostureChecksByIDs(ctx context.Context, lockStrength LockingStrength, accountID string, postureChecksIDs []string) (map[string]*posture.Checks, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var postureChecks []*posture.Checks + result := tx.Find(&postureChecks, accountAndIDsQueryCondition, accountID, postureChecksIDs) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get posture checks by ID's from store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get posture checks by ID's from store") + } + + postureChecksMap := make(map[string]*posture.Checks) + for _, postureCheck := range postureChecks { + postureChecksMap[postureCheck.ID] = postureCheck + } + + return postureChecksMap, nil +} + +// SavePostureChecks saves a posture checks to the database. +func (s *SqlStore) SavePostureChecks(ctx context.Context, postureCheck *posture.Checks) error { + result := s.db.Save(postureCheck) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save posture checks to store: %s", result.Error) + return status.Errorf(status.Internal, "failed to save posture checks to store") + } + + return nil +} + +// DeletePostureChecks deletes a posture checks from the database. +func (s *SqlStore) DeletePostureChecks(ctx context.Context, accountID, postureChecksID string) error { + result := s.db.Delete(&posture.Checks{}, accountAndIDQueryCondition, accountID, postureChecksID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete posture checks from store: %s", result.Error) + return status.Errorf(status.Internal, "failed to delete posture checks from store") + } + + if result.RowsAffected == 0 { + return status.NewPostureChecksNotFoundError(postureChecksID) + } + + return nil +} diff --git a/management/server/store/sql_store_posture_checks_test.go b/management/server/store/sql_store_posture_checks_test.go new file mode 100644 index 000000000..7f0511517 --- /dev/null +++ b/management/server/store/sql_store_posture_checks_test.go @@ -0,0 +1,188 @@ +package store + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/posture" + "github.com/netbirdio/netbird/shared/management/status" +) + +func TestSqlStore_GetPostureChecksByID(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + tests := []struct { + name string + postureChecksID string + expectError bool + }{ + { + name: "retrieve existing posture checks", + postureChecksID: "csplshq7qv948l48f7t0", + expectError: false, + }, + { + name: "retrieve non-existing posture checks", + postureChecksID: "non-existing", + expectError: true, + }, + { + name: "retrieve with empty posture checks ID", + postureChecksID: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + postureChecks, err := store.GetPostureChecksByID(context.Background(), LockingStrengthNone, accountID, tt.postureChecksID) + if tt.expectError { + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) + require.Nil(t, postureChecks) + } else { + require.NoError(t, err) + require.NotNil(t, postureChecks) + require.Equal(t, tt.postureChecksID, postureChecks.ID) + } + }) + } +} + +func TestSqlStore_GetPostureChecksByIDs(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + tests := []struct { + name string + postureCheckIDs []string + expectedCount int + }{ + { + name: "retrieve existing posture checks by existing IDs", + postureCheckIDs: []string{"csplshq7qv948l48f7t0", "cspnllq7qv95uq1r4k90"}, + expectedCount: 2, + }, + { + name: "empty posture check IDs list", + postureCheckIDs: []string{}, + expectedCount: 0, + }, + { + name: "non-existing posture check IDs", + postureCheckIDs: []string{"nonexistent1", "nonexistent2"}, + expectedCount: 0, + }, + { + name: "mixed existing and non-existing posture check IDs", + postureCheckIDs: []string{"cspnllq7qv95uq1r4k90", "nonexistent"}, + expectedCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + groups, err := store.GetPostureChecksByIDs(context.Background(), LockingStrengthNone, accountID, tt.postureCheckIDs) + require.NoError(t, err) + require.Len(t, groups, tt.expectedCount) + }) + } +} + +func TestSqlStore_SavePostureChecks(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + postureChecks := &posture.Checks{ + ID: "posture-checks-id", + AccountID: accountID, + Checks: posture.ChecksDefinition{ + NBVersionCheck: &posture.NBVersionCheck{ + MinVersion: "0.31.0", + }, + OSVersionCheck: &posture.OSVersionCheck{ + Ios: &posture.MinVersionCheck{ + MinVersion: "13.0.1", + }, + Linux: &posture.MinKernelVersionCheck{ + MinKernelVersion: "5.3.3-dev", + }, + }, + GeoLocationCheck: &posture.GeoLocationCheck{ + Locations: []posture.Location{ + { + CountryCode: "DE", + CityName: "Berlin", + }, + }, + Action: posture.CheckActionAllow, + }, + }, + } + err = store.SavePostureChecks(context.Background(), postureChecks) + require.NoError(t, err) + + savePostureChecks, err := store.GetPostureChecksByID(context.Background(), LockingStrengthNone, accountID, "posture-checks-id") + require.NoError(t, err) + require.Equal(t, savePostureChecks, postureChecks) +} + +func TestSqlStore_DeletePostureChecks(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + tests := []struct { + name string + postureChecksID string + expectError bool + }{ + { + name: "delete existing posture checks", + postureChecksID: "csplshq7qv948l48f7t0", + expectError: false, + }, + { + name: "delete non-existing posture checks", + postureChecksID: "non-existing-posture-checks-id", + expectError: true, + }, + { + name: "delete with empty posture checks ID", + postureChecksID: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err = store.DeletePostureChecks(context.Background(), accountID, tt.postureChecksID) + if tt.expectError { + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) + } else { + require.NoError(t, err) + group, err := store.GetPostureChecksByID(context.Background(), LockingStrengthNone, accountID, tt.postureChecksID) + require.Error(t, err) + require.Nil(t, group) + } + }) + } +} diff --git a/management/server/store/sql_store_proxy.go b/management/server/store/sql_store_proxy.go new file mode 100644 index 000000000..5d1a9afba --- /dev/null +++ b/management/server/store/sql_store_proxy.go @@ -0,0 +1,467 @@ +package store + +import ( + "context" + "errors" + "fmt" + "time" + + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + "github.com/netbirdio/netbird/shared/management/status" +) + +// GetProxyMetrics aggregates per-cluster + per-proxy counts for the +// self-hosted telemetry payload. Single round-trip via conditional +// aggregations so a large proxies table doesn't fan out into multiple +// queries. +func (s *SqlStore) GetProxyMetrics(ctx context.Context) (ProxyMetrics, error) { + var m ProxyMetrics + activeCutoff := time.Now().Add(-proxyActiveThreshold) + + // COUNT(DISTINCT ... CASE WHEN ...) is portable across sqlite/postgres + // (MySQL too) and keeps the round-trip to one. proxy.StatusConnected + // is the same string the cluster-capability queries use; the active + // window matches the cluster-capability semantics (only proxies + // heartbeating within ~2 * heartbeat interval count as connected). + row := s.db.WithContext(ctx). + Model(&proxy.Proxy{}). + Select( + "COUNT(DISTINCT cluster_address) AS clusters, "+ + "COUNT(DISTINCT CASE WHEN account_id IS NOT NULL THEN cluster_address END) AS clusters_byop, "+ + "COUNT(DISTINCT CASE WHEN private = ? THEN cluster_address END) AS clusters_private, "+ + "COUNT(*) AS proxies, "+ + "COUNT(CASE WHEN status = ? AND last_seen > ? THEN 1 END) AS proxies_connected", + true, + proxy.StatusConnected, + activeCutoff, + ). + Row() + if err := row.Scan(&m.Clusters, &m.ClustersBYOP, &m.ClustersPrivate, &m.Proxies, &m.ProxiesConnected); err != nil { + return ProxyMetrics{}, fmt.Errorf("scan proxy metrics: %w", err) + } + return m, nil +} + +// SaveProxy saves or updates a proxy in the database +func (s *SqlStore) SaveProxy(ctx context.Context, p *proxy.Proxy) error { + result := s.db.Save(p) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save proxy: %v", result.Error) + return status.Errorf(status.Internal, "failed to save proxy") + } + return nil +} + +// DisconnectProxy marks a proxy as disconnected only if the session ID matches. +// This prevents a slow-to-close old session from overwriting a newer reconnection. +func (s *SqlStore) DisconnectProxy(ctx context.Context, proxyID, sessionID string) error { + now := time.Now() + result := s.db. + Model(&proxy.Proxy{}). + Where("id = ? AND session_id = ?", proxyID, sessionID). + Updates(map[string]any{ + "status": proxy.StatusDisconnected, + "disconnected_at": now, + "last_seen": now, + }) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to disconnect proxy %s session %s: %v", proxyID, sessionID, result.Error) + return status.Errorf(status.Internal, "failed to disconnect proxy") + } + if result.RowsAffected == 0 { + log.WithContext(ctx).Debugf("proxy %s session %s: no row updated (superseded by newer session)", proxyID, sessionID) + } + return nil +} + +// GetAllProxies returns all reverse proxy instance rows. +func (s *SqlStore) GetAllProxies(ctx context.Context) ([]*proxy.Proxy, error) { + var proxies []*proxy.Proxy + result := s.db.Order("cluster_address, id").Find(&proxies) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get proxies: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get proxies") + } + return proxies, nil +} + +// DisconnectAllProxies force-marks every proxy that is not already disconnected +// as disconnected, regardless of session ID. Unlike DisconnectProxy it is not +// session-guarded: it is an administrative repair helper, not part of the +// connection lifecycle. last_seen is left untouched so the stale-proxy reaper +// keeps working off the real last heartbeat. Returns the number of proxies updated. +func (s *SqlStore) DisconnectAllProxies(ctx context.Context) (int64, error) { + result := s.db. + Model(&proxy.Proxy{}). + Where("status != ?", proxy.StatusDisconnected). + Updates(map[string]any{ + "status": proxy.StatusDisconnected, + "disconnected_at": time.Now(), + }) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to disconnect all proxies: %v", result.Error) + return 0, status.Errorf(status.Internal, "failed to disconnect all proxies") + } + return result.RowsAffected, nil +} + +// UpdateProxyHeartbeat updates the last_seen timestamp for the proxy's current session. +func (s *SqlStore) UpdateProxyHeartbeat(ctx context.Context, p *proxy.Proxy) error { + now := time.Now() + + result := s.db. + Model(&proxy.Proxy{}). + Where("id = ? AND session_id = ?", p.ID, p.SessionID). + Updates(map[string]any{ + "last_seen": now, + "status": proxy.StatusConnected, + "disconnected_at": nil, + }) + + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to update proxy heartbeat: %v", result.Error) + return status.Errorf(status.Internal, "failed to update proxy heartbeat") + } + + if result.RowsAffected == 0 { + p.LastSeen = now + p.ConnectedAt = &now + p.Status = proxy.StatusConnected + if err := s.db.Create(p).Error; err != nil { + log.WithContext(ctx).Debugf("proxy %s session %s: heartbeat fallback insert skipped: %v", p.ID, p.SessionID, err) + } + } + + return nil +} + +// GetActiveProxyClusterAddresses returns the unique cluster addresses of active +// shared proxies (those without an account scope). BYOP cluster addresses are +// excluded; use GetActiveProxyClusterAddressesForAccount to retrieve them. +func (s *SqlStore) GetActiveProxyClusterAddresses(ctx context.Context) ([]string, error) { + var addresses []string + + result := s.db. + Model(&proxy.Proxy{}). + Where("account_id IS NULL AND status = ? AND last_seen > ?", proxy.StatusConnected, time.Now().Add(-proxyActiveThreshold)). + Distinct("cluster_address"). + Pluck("cluster_address", &addresses) + + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get active proxy cluster addresses: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get active proxy cluster addresses") + } + + return addresses, nil +} + +func (s *SqlStore) GetActiveProxyClusterAddressesForAccount(ctx context.Context, accountID string) ([]string, error) { + var addresses []string + + result := s.db. + Model(&proxy.Proxy{}). + Where("account_id = ? AND status = ? AND last_seen > ?", accountID, proxy.StatusConnected, time.Now().Add(-proxyActiveThreshold)). + Distinct("cluster_address"). + Pluck("cluster_address", &addresses) + + if result.Error != nil { + return nil, status.Errorf(status.Internal, "failed to get active proxy cluster addresses for account") + } + + return addresses, nil +} + +func (s *SqlStore) GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error) { + var p proxy.Proxy + result := s.db.Where("account_id = ?", accountID).Take(&p) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "proxy not found for account") + } + return nil, status.Errorf(status.Internal, "get proxy by account ID: %v", result.Error) + } + return &p, nil +} + +func (s *SqlStore) CountProxiesByAccountID(ctx context.Context, accountID string) (int64, error) { + var count int64 + result := s.db.Model(&proxy.Proxy{}).Where("account_id = ?", accountID).Count(&count) + if result.Error != nil { + return 0, status.Errorf(status.Internal, "count proxies by account ID: %v", result.Error) + } + return count, nil +} + +// HasActiveProxyAtClusterAddress reports whether any proxy — shared or +// account-scoped — is currently active at the given cluster address, using +// the same connected-within-threshold window as the other active-proxy +// queries. Backs the agent-network settings delete guard: settings cannot be +// deleted while a proxy declares the endpoint hostname as its address. +// +// The comparison folds case on both sides: the caller passes a normalized +// (lowercase) hostname, but proxies declare their cluster address verbatim +// and Connect stores it unchanged, so on case-sensitive collations a proxy +// declaring "GW.Example.com" would otherwise slip past the guard. Hostnames +// are case-insensitive per RFC 4343; the guard must be too. +func (s *SqlStore) HasActiveProxyAtClusterAddress(ctx context.Context, clusterAddress string) (bool, error) { + var count int64 + result := s.db. + Model(&proxy.Proxy{}). + Where("LOWER(cluster_address) = LOWER(?) AND status = ? AND last_seen > ?", clusterAddress, proxy.StatusConnected, time.Now().Add(-proxyActiveThreshold)). + Count(&count) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to count active proxies at cluster address: %v", result.Error) + return false, status.Errorf(status.Internal, "failed to count active proxies at cluster address") + } + return count > 0, nil +} + +func (s *SqlStore) IsClusterAddressConflicting(ctx context.Context, clusterAddress, accountID string) (bool, error) { + var count int64 + result := s.db. + Model(&proxy.Proxy{}). + Where("cluster_address = ? AND (account_id IS NULL OR account_id != ?)", clusterAddress, accountID). + Count(&count) + if result.Error != nil { + return false, status.Errorf(status.Internal, "check cluster address conflict: %v", result.Error) + } + return count > 0, nil +} + +// HasForeignAccountProxyAtHost reports whether a proxy owned by a different +// account declares this host. Shared proxies (account_id IS NULL) are not +// foreign: a shared cluster is what most accounts pin their agent network +// gateway to. The match folds case because proxies declare their address as +// the operator spelled it while the caller's host is normalised; that costs a +// scan of the proxies table, taken once per account when its gateway is +// bootstrapped, not on the per-connect path IsClusterAddressConflicting serves. +func (s *SqlStore) HasForeignAccountProxyAtHost(ctx context.Context, host, accountID string) (bool, error) { + var count int64 + result := s.db. + Model(&proxy.Proxy{}). + Where("LOWER(cluster_address) = LOWER(?) AND account_id IS NOT NULL AND account_id != ?", host, accountID). + Count(&count) + if result.Error != nil { + return false, status.Errorf(status.Internal, "check proxy host ownership: %v", result.Error) + } + return count > 0, nil +} + +func (s *SqlStore) DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error { + result := s.db. + Where("cluster_address = ? AND account_id = ?", clusterAddress, accountID). + Delete(&proxy.Proxy{}) + if result.Error != nil { + return status.Errorf(status.Internal, "delete account cluster: %v", result.Error) + } + if result.RowsAffected == 0 { + return status.Errorf(status.NotFound, "cluster not found") + } + return nil +} + +// GetProxyClusters returns every cluster the account can see (shared +// plus its own BYOP), regardless of whether any proxy in the cluster +// is currently heartbeating. Online and ConnectedProxies are derived +// from the 2-min active window so the dashboard can render offline +// clusters distinctly; the 1-hour heartbeat reaper still removes rows +// that go quiet for too long. +// +// AccountOwned is determined by whether any proxy row in the group +// carries a non-NULL account_id; the caller maps that to Cluster.Type. +// Capability flags are NOT filled here — the handler enriches them via +// the per-cluster capability lookups. +func (s *SqlStore) GetProxyClusters(ctx context.Context, accountID string) ([]proxy.Cluster, error) { + activeCutoff := time.Now().Add(-proxyActiveThreshold) + + type clusterRow struct { + ID string + Address string + ConnectedProxies int + Online bool + AccountOwned bool + } + + var rows []clusterRow + result := s.db.Model(&proxy.Proxy{}). + Select( + "MIN(id) AS id, "+ + "cluster_address AS address, "+ + // COUNT(CASE WHEN ... THEN 1 END) counts only non-NULL — i.e. only + // rows that satisfy the predicate — so it works portably across + // sqlite/postgres/mysql without dialect-specific FILTER syntax. + "COUNT(CASE WHEN status = ? AND last_seen > ? THEN 1 END) AS connected_proxies, "+ + // MAX(CASE …) > 0 expresses BOOL_OR in a way Postgres tolerates + // (Postgres can't MAX a boolean column). + "MAX(CASE WHEN status = ? AND last_seen > ? THEN 1 ELSE 0 END) > 0 AS online, "+ + "MAX(CASE WHEN account_id IS NOT NULL THEN 1 ELSE 0 END) > 0 AS account_owned", + proxy.StatusConnected, activeCutoff, + proxy.StatusConnected, activeCutoff, + ). + Where("account_id IS NULL OR account_id = ?", accountID). + Group("cluster_address"). + Scan(&rows) + + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get proxy clusters: %v", result.Error) + return nil, status.Errorf(status.Internal, "get proxy clusters") + } + + clusters := make([]proxy.Cluster, 0, len(rows)) + for _, r := range rows { + c := proxy.Cluster{ + ID: r.ID, + Address: r.Address, + Online: r.Online, + ConnectedProxies: r.ConnectedProxies, + } + if r.AccountOwned { + c.Type = proxy.ClusterTypeAccount + } else { + c.Type = proxy.ClusterTypeShared + } + clusters = append(clusters, c) + } + + return clusters, nil +} + +// proxyActiveThreshold is the maximum age of a heartbeat for a proxy to be +// considered active. Must be at least 2x the heartbeat interval (1 min). +const proxyActiveThreshold = 2 * time.Minute + +var validCapabilityColumns = map[string]struct{}{ + "supports_custom_ports": {}, + "require_subdomain": {}, + "supports_crowdsec": {}, + "private": {}, +} + +// GetClusterSupportsCustomPorts returns whether any active proxy in the cluster +// supports custom ports. Returns nil when no proxy reported the capability. +func (s *SqlStore) GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool { + return s.getClusterCapability(ctx, clusterAddr, "supports_custom_ports") +} + +// GetClusterRequireSubdomain returns whether any active proxy in the cluster +// requires a subdomain. Returns nil when no proxy reported the capability. +func (s *SqlStore) GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool { + return s.getClusterCapability(ctx, clusterAddr, "require_subdomain") +} + +// GetClusterSupportsPrivate reports whether any active proxy in the cluster +// has the private capability (nil = unreported). +func (s *SqlStore) GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool { + return s.getClusterCapability(ctx, clusterAddr, "private") +} + +// GetClusterSupportsCrowdSec returns whether all active proxies in the cluster +// have CrowdSec configured. Returns nil when no proxy reported the capability. +// Unlike other capabilities that use ANY-true (for rolling upgrades), CrowdSec +// requires unanimous support: a single unconfigured proxy would let requests +// bypass reputation checks. +func (s *SqlStore) GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool { + return s.getClusterUnanimousCapability(ctx, clusterAddr, "supports_crowdsec") +} + +// getClusterUnanimousCapability returns an aggregated boolean capability +// requiring all active proxies in the cluster to report true. +func (s *SqlStore) getClusterUnanimousCapability(ctx context.Context, clusterAddr, column string) *bool { + if _, ok := validCapabilityColumns[column]; !ok { + log.WithContext(ctx).Errorf("invalid capability column: %s", column) + return nil + } + + var result struct { + Total int64 + Reported int64 + AllTrue bool + } + + // All active proxies must have reported the capability (no NULLs) and all + // must report true. A single unreported or false proxy means the cluster + // does not unanimously support the capability. + err := s.db.WithContext(ctx). + Model(&proxy.Proxy{}). + Select("COUNT(*) AS total, "+ + "COUNT(CASE WHEN "+column+" IS NOT NULL THEN 1 END) AS reported, "+ + "COUNT(*) > 0 AND COUNT(*) = COUNT(CASE WHEN "+column+" = true THEN 1 END) AS all_true"). + Where("cluster_address = ? AND status = ? AND last_seen > ?", + clusterAddr, "connected", time.Now().Add(-proxyActiveThreshold)). + Scan(&result).Error + if err != nil { + log.WithContext(ctx).Errorf("query cluster capability %s for %s: %v", column, clusterAddr, err) + return nil + } + + if result.Total == 0 || result.Reported == 0 { + return nil + } + + // If any proxy has not reported (NULL), we can't confirm unanimous support. + if result.Reported < result.Total { + v := false + return &v + } + + return &result.AllTrue +} + +// getClusterCapability returns an aggregated boolean capability for the given +// cluster. It checks active (connected, recently seen) proxies and returns: +// - *true if any proxy in the cluster has the capability set to true, +// - *false if at least one proxy reported but none set it to true, +// - nil if no proxy reported the capability at all. +func (s *SqlStore) getClusterCapability(ctx context.Context, clusterAddr, column string) *bool { + if _, ok := validCapabilityColumns[column]; !ok { + log.WithContext(ctx).Errorf("invalid capability column: %s", column) + return nil + } + + var result struct { + HasCapability bool + AnyTrue bool + } + + err := s.db. + WithContext(ctx). + Model(&proxy.Proxy{}). + Select("COUNT(CASE WHEN "+column+" IS NOT NULL THEN 1 END) > 0 AS has_capability, "+ + "COALESCE(MAX(CASE WHEN "+column+" = true THEN 1 ELSE 0 END), 0) = 1 AS any_true"). + Where("cluster_address = ? AND status = ? AND last_seen > ?", + clusterAddr, "connected", time.Now().Add(-proxyActiveThreshold)). + Scan(&result).Error + if err != nil { + log.WithContext(ctx).Errorf("query cluster capability %s for %s: %v", column, clusterAddr, err) + return nil + } + + if !result.HasCapability { + return nil + } + + return &result.AnyTrue +} + +// CleanupStaleProxies deletes proxies that haven't sent heartbeat in the specified duration +func (s *SqlStore) CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error { + cutoffTime := time.Now().Add(-inactivityDuration) + + result := s.db. + Where("last_seen < ?", cutoffTime). + Delete(&proxy.Proxy{}) + + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to cleanup stale proxies: %v", result.Error) + return status.Errorf(status.Internal, "failed to cleanup stale proxies") + } + + if result.RowsAffected > 0 { + log.WithContext(ctx).Infof("Cleaned up %d stale proxies", result.RowsAffected) + } + + return nil +} diff --git a/management/server/store/sql_store_proxy_access_token.go b/management/server/store/sql_store_proxy_access_token.go new file mode 100644 index 000000000..b111c8f64 --- /dev/null +++ b/management/server/store/sql_store_proxy_access_token.go @@ -0,0 +1,127 @@ +package store + +import ( + "context" + "errors" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +// GetProxyAccessTokenByHashedToken retrieves a proxy access token by its hashed value. +func (s *SqlStore) GetProxyAccessTokenByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken types.HashedProxyToken) (*types.ProxyAccessToken, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var token types.ProxyAccessToken + result := tx.Take(&token, "hashed_token = ?", hashedToken) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "proxy access token not found") + } + return nil, status.Errorf(status.Internal, "get proxy access token: %v", result.Error) + } + + return &token, nil +} + +// GetAllProxyAccessTokens retrieves all proxy access tokens. +func (s *SqlStore) GetAllProxyAccessTokens(ctx context.Context, lockStrength LockingStrength) ([]*types.ProxyAccessToken, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var tokens []*types.ProxyAccessToken + result := tx.Find(&tokens) + if result.Error != nil { + return nil, status.Errorf(status.Internal, "get proxy access tokens: %v", result.Error) + } + + return tokens, nil +} + +// SaveProxyAccessToken saves a proxy access token to the database. +func (s *SqlStore) SaveProxyAccessToken(ctx context.Context, token *types.ProxyAccessToken) error { + if result := s.db.Create(token); result.Error != nil { + return status.Errorf(status.Internal, "save proxy access token: %v", result.Error) + } + return nil +} + +// RevokeProxyAccessToken revokes a proxy access token by its ID. +func (s *SqlStore) RevokeProxyAccessToken(ctx context.Context, tokenID string) error { + result := s.db.Model(&types.ProxyAccessToken{}).Where(idQueryCondition, tokenID).Update("revoked", true) + if result.Error != nil { + return status.Errorf(status.Internal, "revoke proxy access token: %v", result.Error) + } + + if result.RowsAffected == 0 { + return status.Errorf(status.NotFound, "proxy access token not found") + } + + return nil +} + +func (s *SqlStore) GetProxyAccessTokensByAccountID(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.ProxyAccessToken, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var tokens []*types.ProxyAccessToken + result := tx.Where("account_id = ?", accountID).Find(&tokens) + if result.Error != nil { + return nil, status.Errorf(status.Internal, "get proxy access tokens by account: %v", result.Error) + } + + return tokens, nil +} + +func (s *SqlStore) IsProxyAccessTokenValid(ctx context.Context, tokenID string) (bool, error) { + token, err := s.GetProxyAccessTokenByID(ctx, LockingStrengthNone, tokenID) + if err != nil { + return false, err + } + return token.IsValid(), nil +} + +func (s *SqlStore) GetProxyAccessTokenByID(ctx context.Context, lockStrength LockingStrength, tokenID string) (*types.ProxyAccessToken, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var token types.ProxyAccessToken + result := tx.Take(&token, idQueryCondition, tokenID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "proxy access token not found") + } + return nil, status.Errorf(status.Internal, "get proxy access token by ID: %v", result.Error) + } + + return &token, nil +} + +// MarkProxyAccessTokenUsed updates the last used timestamp for a proxy access token. +func (s *SqlStore) MarkProxyAccessTokenUsed(ctx context.Context, tokenID string) error { + result := s.db.Model(&types.ProxyAccessToken{}). + Where(idQueryCondition, tokenID). + Update("last_used", time.Now().UTC()) + if result.Error != nil { + return status.Errorf(status.Internal, "mark proxy access token as used: %v", result.Error) + } + + if result.RowsAffected == 0 { + return status.Errorf(status.NotFound, "proxy access token not found") + } + + return nil +} diff --git a/management/server/store/sql_store_route.go b/management/server/store/sql_store_route.go new file mode 100644 index 000000000..0aa0b399a --- /dev/null +++ b/management/server/store/sql_store_route.go @@ -0,0 +1,152 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + + "github.com/jackc/pgx/v5" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/status" +) + +func (s *SqlStore) getRoutes(ctx context.Context, accountID string) ([]route.Route, error) { + const query = `SELECT id, account_id, public_id, network, domains, keep_route, net_id, description, peer, peer_groups, network_type, masquerade, metric, enabled, groups, access_control_groups, skip_auto_apply FROM routes WHERE account_id = $1` + rows, err := s.pool.Query(ctx, query, accountID) + if err != nil { + return nil, err + } + routes, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (route.Route, error) { + var r route.Route + var network, domains, peerGroups, groups, accessGroups []byte + var keepRoute, masquerade, enabled, skipAutoApply sql.NullBool + var metric sql.NullInt64 + err := row.Scan(&r.ID, &r.AccountID, &r.PublicID, &network, &domains, &keepRoute, &r.NetID, &r.Description, &r.Peer, &peerGroups, &r.NetworkType, &masquerade, &metric, &enabled, &groups, &accessGroups, &skipAutoApply) + if err == nil { + if keepRoute.Valid { + r.KeepRoute = keepRoute.Bool + } + if masquerade.Valid { + r.Masquerade = masquerade.Bool + } + if enabled.Valid { + r.Enabled = enabled.Bool + } + if skipAutoApply.Valid { + r.SkipAutoApply = skipAutoApply.Bool + } + if metric.Valid { + r.Metric = int(metric.Int64) + } + if network != nil { + _ = json.Unmarshal(network, &r.Network) + } + if domains != nil { + _ = json.Unmarshal(domains, &r.Domains) + } + if peerGroups != nil { + _ = json.Unmarshal(peerGroups, &r.PeerGroups) + } + if groups != nil { + _ = json.Unmarshal(groups, &r.Groups) + } + if accessGroups != nil { + _ = json.Unmarshal(accessGroups, &r.AccessControlGroups) + } + } + return r, err + }) + if err != nil { + return nil, err + } + return routes, nil +} + +// GetAccountRoutes retrieves network routes for an account. +func (s *SqlStore) GetAccountRoutes(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*route.Route, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var routes []*route.Route + result := tx.Find(&routes, accountIDCondition, accountID) + if err := result.Error; err != nil { + log.WithContext(ctx).Errorf("failed to get routes from the store: %s", err) + return nil, status.Errorf(status.Internal, "failed to get routes from store") + } + + return routes, nil +} + +// GetRouteByID retrieves a route by its ID and account ID. +func (s *SqlStore) GetRouteByID(ctx context.Context, lockStrength LockingStrength, accountID string, routeID string) (*route.Route, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var route *route.Route + result := tx.Take(&route, accountAndIDQueryCondition, accountID, routeID) + if err := result.Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, status.NewRouteNotFoundError(routeID) + } + log.WithContext(ctx).Errorf("failed to get route from the store: %s", err) + return nil, status.Errorf(status.Internal, "failed to get route from store") + } + + return route, nil +} + +// GetRouteByIDOrPublicID retrieves a route by either its ID or its PublicID. See +// GetPolicyByIDOrPublicID for why peer-reported references need both. +func (s *SqlStore) GetRouteByIDOrPublicID(ctx context.Context, lockStrength LockingStrength, accountID string, routeID string) (*route.Route, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var route *route.Route + result := tx.Take(&route, accountAndAnyIDQueryCondition, accountID, routeID, routeID) + if err := result.Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, status.NewRouteNotFoundError(routeID) + } + log.WithContext(ctx).Errorf("failed to get route from the store: %s", err) + return nil, status.Errorf(status.Internal, "failed to get route from store") + } + + return route, nil +} + +// SaveRoute saves a route to the database. +func (s *SqlStore) SaveRoute(ctx context.Context, route *route.Route) error { + result := s.db.Save(route) + if err := result.Error; err != nil { + log.WithContext(ctx).Errorf("failed to save route to the store: %s", err) + return status.Errorf(status.Internal, "failed to save route to store") + } + + return nil +} + +// DeleteRoute deletes a route from the database. +func (s *SqlStore) DeleteRoute(ctx context.Context, accountID, routeID string) error { + result := s.db.Delete(&route.Route{}, accountAndIDQueryCondition, accountID, routeID) + if err := result.Error; err != nil { + log.WithContext(ctx).Errorf("failed to delete route from the store: %s", err) + return status.Errorf(status.Internal, "failed to delete route from store") + } + + if result.RowsAffected == 0 { + return status.NewRouteNotFoundError(routeID) + } + + return nil +} diff --git a/management/server/store/sql_store_route_test.go b/management/server/store/sql_store_route_test.go new file mode 100644 index 000000000..53e4f130e --- /dev/null +++ b/management/server/store/sql_store_route_test.go @@ -0,0 +1,165 @@ +package store + +import ( + "context" + "net/netip" + "testing" + + "github.com/stretchr/testify/require" + + nbroute "github.com/netbirdio/netbird/route" + "github.com/netbirdio/netbird/shared/management/status" +) + +func TestSqlStore_GetAccountRoutes(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + tests := []struct { + name string + accountID string + expectedCount int + }{ + { + name: "retrieve routes by existing account ID", + accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", + expectedCount: 1, + }, + { + name: "non-existing account ID", + accountID: "nonexistent", + expectedCount: 0, + }, + { + name: "empty account ID", + accountID: "", + expectedCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + routes, err := store.GetAccountRoutes(context.Background(), LockingStrengthNone, tt.accountID) + require.NoError(t, err) + require.Len(t, routes, tt.expectedCount) + }) + } +} + +func TestSqlStore_GetRouteByID(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + tests := []struct { + name string + routeID string + expectError bool + }{ + { + name: "retrieve existing route", + routeID: "ct03t427qv97vmtmglog", + expectError: false, + }, + { + name: "retrieve non-existing route", + routeID: "non-existing", + expectError: true, + }, + { + name: "retrieve with empty route ID", + routeID: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + route, err := store.GetRouteByID(context.Background(), LockingStrengthNone, accountID, tt.routeID) + if tt.expectError { + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) + require.Nil(t, route) + } else { + require.NoError(t, err) + require.NotNil(t, route) + require.Equal(t, tt.routeID, string(route.ID)) + } + }) + } +} + +func TestSqlStore_GetRouteByIDOrPublicID(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + routeID := "ct03t427qv97vmtmglog" + + route, err := store.GetRouteByID(context.Background(), LockingStrengthNone, accountID, routeID) + require.NoError(t, err) + require.NotEmpty(t, route.PublicID) + + for _, id := range []string{routeID, route.PublicID} { + route, err := store.GetRouteByIDOrPublicID(context.Background(), LockingStrengthNone, accountID, id) + require.NoError(t, err) + require.Equal(t, routeID, string(route.ID)) + } + + route, err = store.GetRouteByIDOrPublicID(context.Background(), LockingStrengthNone, accountID, "non-existing") + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) + require.Nil(t, route) +} + +func TestSqlStore_SaveRoute(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + route := &nbroute.Route{ + ID: "route-id", + AccountID: accountID, + Network: netip.MustParsePrefix("10.10.0.0/16"), + NetID: "netID", + PeerGroups: []string{"routeA"}, + NetworkType: nbroute.IPv4Network, + Masquerade: true, + Metric: 9999, + Enabled: true, + Groups: []string{"groupA"}, + AccessControlGroups: []string{}, + } + err = store.SaveRoute(context.Background(), route) + require.NoError(t, err) + + saveRoute, err := store.GetRouteByID(context.Background(), LockingStrengthNone, accountID, string(route.ID)) + require.NoError(t, err) + require.Equal(t, route, saveRoute) + +} + +func TestSqlStore_DeleteRoute(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + routeID := "ct03t427qv97vmtmglog" + + err = store.DeleteRoute(context.Background(), accountID, routeID) + require.NoError(t, err) + + route, err := store.GetRouteByID(context.Background(), LockingStrengthNone, accountID, routeID) + require.Error(t, err) + require.Nil(t, route) +} diff --git a/management/server/store/sql_store_service.go b/management/server/store/sql_store_service.go new file mode 100644 index 000000000..5eedbcb37 --- /dev/null +++ b/management/server/store/sql_store_service.go @@ -0,0 +1,452 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "math" + "time" + + "github.com/jackc/pgx/v5" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/shared/management/status" +) + +// serviceSelectColumns and targetSelectColumns are the column lists the Postgres +// pgx read path scans. They must stay in sync with the rpservice.Service and +// rpservice.Target gorm models; TestPgxServiceColumnsMatchGorm enforces this. +const serviceSelectColumns = `id, account_id, name, domain, enabled, auth, restrictions, + meta_created_at, meta_certificate_issued_at, meta_last_renewed_at, meta_status, proxy_cluster, + pass_host_header, rewrite_redirects, session_private_key, session_public_key, + mode, listen_port, port_auto_assigned, source, source_peer, terminated, + private, access_groups` + +func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpservice.Service, error) { + const serviceQuery = `SELECT ` + serviceSelectColumns + ` FROM services WHERE account_id = $1` + + serviceRows, err := s.pool.Query(ctx, serviceQuery, accountID) + if err != nil { + return nil, err + } + + services, err := pgx.CollectRows(serviceRows, scanService) + if err != nil { + return nil, err + } + + if len(services) == 0 { + return services, nil + } + + serviceIDs := make([]string, len(services)) + serviceMap := make(map[string]*rpservice.Service) + for i, svc := range services { + serviceIDs[i] = svc.ID + serviceMap[svc.ID] = svc + } + + targets, err := s.getServiceTargets(ctx, serviceIDs) + if err != nil { + return nil, err + } + + for _, target := range targets { + if service, ok := serviceMap[target.ServiceID]; ok { + service.Targets = append(service.Targets, target) + } + } + + return services, nil +} + +func scanService(row pgx.CollectableRow) (*rpservice.Service, error) { + var s rpservice.Service + var auth []byte + var restrictions []byte + var accessGroups []byte + var createdAt, certIssuedAt, lastRenewedAt sql.NullTime + var status, proxyCluster, sessionPrivateKey, sessionPublicKey sql.NullString + var mode, source, sourcePeer sql.NullString + var terminated, portAutoAssigned, private sql.NullBool + var listenPort sql.NullInt64 + err := row.Scan( + &s.ID, + &s.AccountID, + &s.Name, + &s.Domain, + &s.Enabled, + &auth, + &restrictions, + &createdAt, + &certIssuedAt, + &lastRenewedAt, + &status, + &proxyCluster, + &s.PassHostHeader, + &s.RewriteRedirects, + &sessionPrivateKey, + &sessionPublicKey, + &mode, + &listenPort, + &portAutoAssigned, + &source, + &sourcePeer, + &terminated, + &private, + &accessGroups, + ) + if err != nil { + return nil, err + } + + if auth != nil { + if err := json.Unmarshal(auth, &s.Auth); err != nil { + return nil, err + } + } + + if len(restrictions) > 0 { + if err := json.Unmarshal(restrictions, &s.Restrictions); err != nil { + return nil, fmt.Errorf("unmarshal restrictions: %w", err) + } + } + + if len(accessGroups) > 0 { + if err := json.Unmarshal(accessGroups, &s.AccessGroups); err != nil { + return nil, fmt.Errorf("unmarshal access_groups: %w", err) + } + } + + if private.Valid { + s.Private = private.Bool + } + + s.Meta = serviceMetaFromRow(createdAt, certIssuedAt, lastRenewedAt, status) + if proxyCluster.Valid { + s.ProxyCluster = proxyCluster.String + } + if sessionPrivateKey.Valid { + s.SessionPrivateKey = sessionPrivateKey.String + } + if sessionPublicKey.Valid { + s.SessionPublicKey = sessionPublicKey.String + } + if mode.Valid { + s.Mode = mode.String + } + if source.Valid { + s.Source = source.String + } + if sourcePeer.Valid { + s.SourcePeer = sourcePeer.String + } + if terminated.Valid { + s.Terminated = terminated.Bool + } + if portAutoAssigned.Valid { + s.PortAutoAssigned = portAutoAssigned.Bool + } + if listenPort.Valid { + if listenPort.Int64 < 0 || listenPort.Int64 > math.MaxUint16 { + return nil, fmt.Errorf("listen_port %d out of range", listenPort.Int64) + } + s.ListenPort = uint16(listenPort.Int64) + } + s.Targets = []*rpservice.Target{} + return &s, nil +} + +func serviceMetaFromRow(createdAt, certIssuedAt, lastRenewedAt sql.NullTime, status sql.NullString) rpservice.Meta { + meta := rpservice.Meta{} + if createdAt.Valid { + meta.CreatedAt = createdAt.Time + } + if certIssuedAt.Valid { + t := certIssuedAt.Time + meta.CertificateIssuedAt = &t + } + if lastRenewedAt.Valid { + t := lastRenewedAt.Time + meta.LastRenewedAt = &t + } + if status.Valid { + meta.Status = status.String + } + return meta +} + +func (s *SqlStore) CreateService(ctx context.Context, service *rpservice.Service) error { + serviceCopy := service.Copy() + if err := serviceCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil { + return fmt.Errorf("encrypt service data: %w", err) + } + result := s.db.Create(serviceCopy) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to create service to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to create service to store") + } + + return nil +} + +func (s *SqlStore) UpdateService(ctx context.Context, service *rpservice.Service) error { + serviceCopy := service.Copy() + if err := serviceCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil { + return fmt.Errorf("encrypt service data: %w", err) + } + + // Create target type instance outside transaction to avoid variable shadowing + targetType := &rpservice.Target{} + + // Use a transaction to ensure atomic updates of the service and its targets + err := s.db.Transaction(func(tx *gorm.DB) error { + // Delete existing targets + if err := tx.Where("service_id = ?", serviceCopy.ID).Delete(targetType).Error; err != nil { + return err + } + + // Update the service and create new targets + if err := tx.Session(&gorm.Session{FullSaveAssociations: true}).Save(serviceCopy).Error; err != nil { + return err + } + + return nil + }) + if err != nil { + log.WithContext(ctx).Errorf("failed to update service to store: %v", err) + return status.Errorf(status.Internal, "failed to update service to store") + } + + return nil +} + +func (s *SqlStore) DeleteService(ctx context.Context, accountID, serviceID string) error { + result := s.db.Delete(&rpservice.Service{}, accountAndIDQueryCondition, accountID, serviceID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete service from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete service from store") + } + + if result.RowsAffected == 0 { + return status.Errorf(status.NotFound, "service %s not found", serviceID) + } + + return nil +} + +func (s *SqlStore) GetServiceByID(ctx context.Context, lockStrength LockingStrength, accountID, serviceID string) (*rpservice.Service, error) { + tx := s.db.Preload("Targets") + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var service *rpservice.Service + result := tx.Take(&service, accountAndIDQueryCondition, accountID, serviceID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "service %s not found", serviceID) + } + + log.WithContext(ctx).Errorf("failed to get service from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get service from store") + } + + if err := service.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt service data: %w", err) + } + + return service, nil +} + +func (s *SqlStore) GetServiceByDomain(ctx context.Context, domain string) (*rpservice.Service, error) { + var service *rpservice.Service + result := s.db.Preload("Targets").Where("domain = ?", domain).First(&service) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "service with domain %s not found", domain) + } + + log.WithContext(ctx).Errorf("failed to get service by domain from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get service by domain from store") + } + + if err := service.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt service data: %w", err) + } + + return service, nil +} + +func (s *SqlStore) GetServices(ctx context.Context, lockStrength LockingStrength) ([]*rpservice.Service, error) { + tx := s.db.Preload("Targets") + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var serviceList []*rpservice.Service + result := tx.Find(&serviceList) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get services from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get services from store") + } + + for _, service := range serviceList { + if err := service.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt service data: %w", err) + } + } + + return serviceList, nil +} + +func (s *SqlStore) GetAccountServices(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*rpservice.Service, error) { + tx := s.db.Preload("Targets") + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var serviceList []*rpservice.Service + result := tx.Find(&serviceList, accountIDCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get services from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get services from store") + } + + for _, service := range serviceList { + if err := service.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt service data: %w", err) + } + } + + return serviceList, nil +} + +// RenewEphemeralService updates the last_renewed_at timestamp for an ephemeral service. +func (s *SqlStore) RenewEphemeralService(ctx context.Context, accountID, peerID, serviceID string) error { + result := s.db.Model(&rpservice.Service{}). + Where("id = ? AND account_id = ? AND source_peer = ? AND source = ?", serviceID, accountID, peerID, rpservice.SourceEphemeral). + Update("meta_last_renewed_at", time.Now()) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to renew ephemeral service: %v", result.Error) + return status.Errorf(status.Internal, "renew ephemeral service") + } + if result.RowsAffected == 0 { + return status.Errorf(status.NotFound, "no active expose session for service %s", serviceID) + } + return nil +} + +// GetExpiredEphemeralServices returns ephemeral services whose last renewal exceeds the given TTL. +// Only the fields needed for reaping are selected. The limit parameter caps the batch size to +// avoid loading too many rows in a single tick. Rows with empty source_peer are excluded to +// skip malformed legacy data. +func (s *SqlStore) GetExpiredEphemeralServices(ctx context.Context, ttl time.Duration, limit int) ([]*rpservice.Service, error) { + cutoff := time.Now().Add(-ttl) + var services []*rpservice.Service + result := s.db. + Select("id", "account_id", "source_peer", "domain"). + Where("source = ? AND source_peer <> '' AND meta_last_renewed_at < ?", rpservice.SourceEphemeral, cutoff). + Limit(limit). + Find(&services) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get expired ephemeral services: %v", result.Error) + return nil, status.Errorf(status.Internal, "get expired ephemeral services") + } + return services, nil +} + +// CountEphemeralServicesByPeer returns the count of ephemeral services for a specific peer. +// Use LockingStrengthUpdate inside a transaction to serialize concurrent create operations. +// The locking is applied via a row-level SELECT ... FOR UPDATE (not on the aggregate) to +// stay compatible with Postgres, which disallows FOR UPDATE on COUNT(*). +func (s *SqlStore) CountEphemeralServicesByPeer(ctx context.Context, lockStrength LockingStrength, accountID, peerID string) (int64, error) { + if lockStrength == LockingStrengthNone { + var count int64 + result := s.db.Model(&rpservice.Service{}). + Where("account_id = ? AND source_peer = ? AND source = ?", accountID, peerID, rpservice.SourceEphemeral). + Count(&count) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to count ephemeral services: %v", result.Error) + return 0, status.Errorf(status.Internal, "count ephemeral services") + } + return count, nil + } + + var ids []string + result := s.db.Model(&rpservice.Service{}). + Clauses(clause.Locking{Strength: string(lockStrength)}). + Select("id"). + Where("account_id = ? AND source_peer = ? AND source = ?", accountID, peerID, rpservice.SourceEphemeral). + Pluck("id", &ids) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to count ephemeral services: %v", result.Error) + return 0, status.Errorf(status.Internal, "count ephemeral services") + } + return int64(len(ids)), nil +} + +// EphemeralServiceExists checks if an ephemeral service exists for the given peer and domain. +// Use LockingStrengthUpdate inside a transaction to serialize concurrent create operations. +func (s *SqlStore) EphemeralServiceExists(ctx context.Context, lockStrength LockingStrength, accountID, peerID, domain string) (bool, error) { + if lockStrength == LockingStrengthNone { + var count int64 + result := s.db.Model(&rpservice.Service{}). + Where("account_id = ? AND source_peer = ? AND domain = ? AND source = ?", accountID, peerID, domain, rpservice.SourceEphemeral). + Count(&count) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to check ephemeral service existence: %v", result.Error) + return false, status.Errorf(status.Internal, "check ephemeral service existence") + } + return count > 0, nil + } + + var id string + result := s.db.Model(&rpservice.Service{}). + Clauses(clause.Locking{Strength: string(lockStrength)}). + Select("id"). + Where("account_id = ? AND source_peer = ? AND domain = ? AND source = ?", accountID, peerID, domain, rpservice.SourceEphemeral). + Limit(1). + Pluck("id", &id) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to check ephemeral service existence: %v", result.Error) + return false, status.Errorf(status.Internal, "check ephemeral service existence") + } + return id != "", nil +} + +// GetServicesByClusterAndPort returns services matching the given proxy cluster, mode, and listen port. +func (s *SqlStore) GetServicesByClusterAndPort(ctx context.Context, lockStrength LockingStrength, proxyCluster string, mode string, listenPort uint16) ([]*rpservice.Service, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var services []*rpservice.Service + result := tx.Where("proxy_cluster = ? AND mode = ? AND listen_port = ?", proxyCluster, mode, listenPort).Find(&services) + if result.Error != nil { + return nil, status.Errorf(status.Internal, "query services by cluster and port") + } + + return services, nil +} + +// GetServicesByCluster returns all services for the given proxy cluster. +func (s *SqlStore) GetServicesByCluster(ctx context.Context, lockStrength LockingStrength, proxyCluster string) ([]*rpservice.Service, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var services []*rpservice.Service + result := tx.Where("proxy_cluster = ?", proxyCluster).Find(&services) + if result.Error != nil { + return nil, status.Errorf(status.Internal, "query services by cluster") + } + return services, nil +} diff --git a/management/server/store/sql_store_service_target.go b/management/server/store/sql_store_service_target.go new file mode 100644 index 000000000..c4531a158 --- /dev/null +++ b/management/server/store/sql_store_service_target.go @@ -0,0 +1,163 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/shared/management/status" +) + +const targetSelectColumns = `id, account_id, service_id, path, host, port, protocol, + target_id, target_type, enabled, proxy_protocol, + skip_tls_verify, request_timeout, session_idle_timeout, path_rewrite, custom_headers, + direct_upstream, middlewares, capture_max_request_bytes, capture_max_response_bytes, + capture_content_types, agent_network, disable_access_log` + +func (s *SqlStore) getServiceTargets(ctx context.Context, serviceIDs []string) ([]*rpservice.Target, error) { + const targetsQuery = `SELECT ` + targetSelectColumns + ` FROM targets WHERE service_id = ANY($1)` + + rows, err := s.pool.Query(ctx, targetsQuery, serviceIDs) + if err != nil { + return nil, err + } + + return pgx.CollectRows(rows, scanTarget) +} + +func scanTarget(row pgx.CollectableRow) (*rpservice.Target, error) { + var t rpservice.Target + var path sql.NullString + var pathRewrite sql.NullString + var proxyProtocol, skipTLSVerify, directUpstream, agentNetwork, disableAccessLog sql.NullBool + var requestTimeout, sessionIdleTimeout, captureMaxRequestBytes, captureMaxResponseBytes sql.NullInt64 + var customHeaders, middlewares, captureContentTypes []byte + err := row.Scan( + &t.ID, + &t.AccountID, + &t.ServiceID, + &path, + &t.Host, + &t.Port, + &t.Protocol, + &t.TargetId, + &t.TargetType, + &t.Enabled, + &proxyProtocol, + &skipTLSVerify, + &requestTimeout, + &sessionIdleTimeout, + &pathRewrite, + &customHeaders, + &directUpstream, + &middlewares, + &captureMaxRequestBytes, + &captureMaxResponseBytes, + &captureContentTypes, + &agentNetwork, + &disableAccessLog, + ) + if err != nil { + return nil, err + } + if path.Valid { + t.Path = &path.String + } + + t.ProxyProtocol = proxyProtocol.Bool + t.Options.SkipTLSVerify = skipTLSVerify.Bool + t.Options.RequestTimeout = time.Duration(requestTimeout.Int64) + t.Options.SessionIdleTimeout = time.Duration(sessionIdleTimeout.Int64) + t.Options.PathRewrite = rpservice.PathRewriteMode(pathRewrite.String) + t.Options.DirectUpstream = directUpstream.Bool + t.Options.CaptureMaxRequestBytes = captureMaxRequestBytes.Int64 + t.Options.CaptureMaxResponseBytes = captureMaxResponseBytes.Int64 + t.Options.AgentNetwork = agentNetwork.Bool + t.Options.DisableAccessLog = disableAccessLog.Bool + + if len(customHeaders) > 0 { + if err := json.Unmarshal(customHeaders, &t.Options.CustomHeaders); err != nil { + return nil, fmt.Errorf("unmarshal custom_headers: %w", err) + } + } + if len(middlewares) > 0 { + if err := json.Unmarshal(middlewares, &t.Options.Middlewares); err != nil { + return nil, fmt.Errorf("unmarshal middlewares: %w", err) + } + } + if len(captureContentTypes) > 0 { + if err := json.Unmarshal(captureContentTypes, &t.Options.CaptureContentTypes); err != nil { + return nil, fmt.Errorf("unmarshal capture_content_types: %w", err) + } + } + return &t, nil +} + +func (s *SqlStore) DeleteTarget(ctx context.Context, accountID string, serviceID string, targetID uint) error { + result := s.db.Delete(&rpservice.Target{}, "account_id = ? AND service_id = ? AND id = ?", accountID, serviceID, targetID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete target from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete target from store") + } + + if result.RowsAffected == 0 { + return status.Errorf(status.NotFound, "target not found for service %s", serviceID) + } + + return nil +} + +func (s *SqlStore) DeleteServiceTargets(ctx context.Context, accountID string, serviceID string) error { + result := s.db.Delete(&rpservice.Target{}, "account_id = ? AND service_id = ?", accountID, serviceID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete targets from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete targets from store") + } + + return nil +} + +// GetTargetsByServiceID retrieves all targets for a given service +func (s *SqlStore) GetTargetsByServiceID(ctx context.Context, lockStrength LockingStrength, accountID string, serviceID string) ([]*rpservice.Target, error) { + var targets []*rpservice.Target + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + result := tx.Where("account_id = ? AND service_id = ?", accountID, serviceID).Find(&targets) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get targets from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get targets from store") + } + + return targets, nil +} + +func (s *SqlStore) GetServiceTargetByTargetID(ctx context.Context, lockStrength LockingStrength, accountID string, targetID string) (*rpservice.Target, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var target *rpservice.Target + result := tx.Take(&target, "account_id = ? AND target_id = ?", accountID, targetID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "service target with ID %s not found", targetID) + } + + log.WithContext(ctx).Errorf("failed to get service target from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get service target from store") + } + + return target, nil +} diff --git a/management/server/store/sql_store_setup_key.go b/management/server/store/sql_store_setup_key.go new file mode 100644 index 000000000..79fb406ad --- /dev/null +++ b/management/server/store/sql_store_setup_key.go @@ -0,0 +1,219 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "time" + + "github.com/jackc/pgx/v5" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +func (s *SqlStore) GetAccountBySetupKey(ctx context.Context, setupKey string) (*types.Account, error) { + var key types.SetupKey + result := s.db.Select("account_id").Take(&key, GetKeyQueryCondition(s), setupKey) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewSetupKeyNotFoundError(setupKey) + } + log.WithContext(ctx).Errorf("failed to get account by setup key from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get account by setup key from store") + } + + if key.AccountID == "" { + return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") + } + + return s.GetAccount(ctx, key.AccountID) +} + +func (s *SqlStore) getSetupKeys(ctx context.Context, accountID string) ([]types.SetupKey, error) { + const query = `SELECT id, account_id, key, key_secret, name, type, created_at, expires_at, updated_at, + revoked, used_times, last_used, auto_groups, usage_limit, ephemeral, allow_extra_dns_labels FROM setup_keys WHERE account_id = $1` + rows, err := s.pool.Query(ctx, query, accountID) + if err != nil { + return nil, err + } + + keys, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (types.SetupKey, error) { + var sk types.SetupKey + var autoGroups []byte + var skCreatedAt, expiresAt, updatedAt, lastUsed sql.NullTime + var revoked, ephemeral, allowExtraDNSLabels sql.NullBool + var usedTimes, usageLimit sql.NullInt64 + + err := row.Scan(&sk.Id, &sk.AccountID, &sk.Key, &sk.KeySecret, &sk.Name, &sk.Type, &skCreatedAt, + &expiresAt, &updatedAt, &revoked, &usedTimes, &lastUsed, &autoGroups, &usageLimit, &ephemeral, &allowExtraDNSLabels) + + if err == nil { + if expiresAt.Valid { + sk.ExpiresAt = &expiresAt.Time + } + if skCreatedAt.Valid { + sk.CreatedAt = skCreatedAt.Time + } + if updatedAt.Valid { + sk.UpdatedAt = updatedAt.Time + if sk.UpdatedAt.IsZero() { + sk.UpdatedAt = sk.CreatedAt + } + } + if lastUsed.Valid { + sk.LastUsed = &lastUsed.Time + } + if revoked.Valid { + sk.Revoked = revoked.Bool + } + if usedTimes.Valid { + sk.UsedTimes = int(usedTimes.Int64) + } + if usageLimit.Valid { + sk.UsageLimit = int(usageLimit.Int64) + } + if ephemeral.Valid { + sk.Ephemeral = ephemeral.Bool + } + if allowExtraDNSLabels.Valid { + sk.AllowExtraDNSLabels = allowExtraDNSLabels.Bool + } + if autoGroups != nil { + _ = json.Unmarshal(autoGroups, &sk.AutoGroups) + } else { + sk.AutoGroups = []string{} + } + } + return sk, err + }) + if err != nil { + return nil, err + } + return keys, nil +} + +func (s *SqlStore) GetAccountIDBySetupKey(ctx context.Context, setupKey string) (string, error) { + var accountID string + result := s.db.Model(&types.SetupKey{}).Select("account_id").Where(GetKeyQueryCondition(s), setupKey).Take(&accountID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return "", status.NewSetupKeyNotFoundError(setupKey) + } + log.WithContext(ctx).Errorf("failed to get account ID by setup key from store: %v", result.Error) + return "", status.Errorf(status.Internal, "failed to get account ID by setup key from store") + } + + if accountID == "" { + return "", status.Errorf(status.NotFound, "account not found: index lookup failed") + } + + return accountID, nil +} + +func (s *SqlStore) GetSetupKeyBySecret(ctx context.Context, lockStrength LockingStrength, key string) (*types.SetupKey, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var setupKey types.SetupKey + result := tx. + Take(&setupKey, GetKeyQueryCondition(s), key) + + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.PreconditionFailed, "setup key not found") + } + log.WithContext(ctx).Errorf("failed to get setup key by secret from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get setup key by secret from store") + } + return &setupKey, nil +} + +func (s *SqlStore) IncrementSetupKeyUsage(ctx context.Context, setupKeyID string) error { + result := s.db.Model(&types.SetupKey{}). + Where(idQueryCondition, setupKeyID). + Updates(map[string]interface{}{ + "used_times": gorm.Expr("used_times + 1"), + "last_used": time.Now(), + }) + + if result.Error != nil { + return status.Errorf(status.Internal, "issue incrementing setup key usage count: %s", result.Error) + } + + if result.RowsAffected == 0 { + return status.NewSetupKeyNotFoundError(setupKeyID) + } + + return nil +} + +// GetAccountSetupKeys retrieves setup keys for an account. +func (s *SqlStore) GetAccountSetupKeys(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.SetupKey, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var setupKeys []*types.SetupKey + result := tx. + Find(&setupKeys, accountIDCondition, accountID) + if err := result.Error; err != nil { + log.WithContext(ctx).Errorf("failed to get setup keys from the store: %s", err) + return nil, status.Errorf(status.Internal, "failed to get setup keys from store") + } + + return setupKeys, nil +} + +// GetSetupKeyByID retrieves a setup key by its ID and account ID. +func (s *SqlStore) GetSetupKeyByID(ctx context.Context, lockStrength LockingStrength, accountID, setupKeyID string) (*types.SetupKey, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var setupKey *types.SetupKey + result := tx.Take(&setupKey, accountAndIDQueryCondition, accountID, setupKeyID) + if err := result.Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, status.NewSetupKeyNotFoundError(setupKeyID) + } + log.WithContext(ctx).Errorf("failed to get setup key from the store: %s", err) + return nil, status.Errorf(status.Internal, "failed to get setup key from store") + } + + return setupKey, nil +} + +// SaveSetupKey saves a setup key to the database. +func (s *SqlStore) SaveSetupKey(ctx context.Context, setupKey *types.SetupKey) error { + result := s.db.Save(setupKey) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save setup key to store: %s", result.Error) + return status.Errorf(status.Internal, "failed to save setup key to store") + } + + return nil +} + +// DeleteSetupKey deletes a setup key from the database. +func (s *SqlStore) DeleteSetupKey(ctx context.Context, accountID, keyID string) error { + result := s.db.Delete(&types.SetupKey{}, accountAndIDQueryCondition, accountID, keyID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete setup key from store: %s", result.Error) + return status.Errorf(status.Internal, "failed to delete setup key from store") + } + + if result.RowsAffected == 0 { + return status.NewSetupKeyNotFoundError(keyID) + } + + return nil +} diff --git a/management/server/store/sql_store_setup_key_test.go b/management/server/store/sql_store_setup_key_test.go new file mode 100644 index 000000000..8835ea1f9 --- /dev/null +++ b/management/server/store/sql_store_setup_key_test.go @@ -0,0 +1,103 @@ +package store + +import ( + "context" + "crypto/sha256" + b64 "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/types" +) + +func TestSqlite_GetSetupKeyBySecret(t *testing.T) { + t.Setenv("NETBIRD_STORE_ENGINE", string(types.SqliteStoreEngine)) + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + if err != nil { + t.Fatal(err) + } + + existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + plainKey := "A2C8E62B-38F5-4553-B31E-DD66C696CEBB" + hashedKey := sha256.Sum256([]byte(plainKey)) + encodedHashedKey := b64.StdEncoding.EncodeToString(hashedKey[:]) + + _, err = store.GetAccount(context.Background(), existingAccountID) + require.NoError(t, err) + + setupKey, err := store.GetSetupKeyBySecret(context.Background(), LockingStrengthNone, encodedHashedKey) + require.NoError(t, err) + assert.Equal(t, encodedHashedKey, setupKey.Key) + assert.Equal(t, types.HiddenKey(plainKey, 4), setupKey.KeySecret) + assert.Equal(t, "bf1c8084-ba50-4ce7-9439-34653001fc3b", setupKey.AccountID) + assert.Equal(t, "Default key", setupKey.Name) +} + +func TestSqlite_incrementSetupKeyUsage(t *testing.T) { + t.Setenv("NETBIRD_STORE_ENGINE", string(types.SqliteStoreEngine)) + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + if err != nil { + t.Fatal(err) + } + + existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + plainKey := "A2C8E62B-38F5-4553-B31E-DD66C696CEBB" + hashedKey := sha256.Sum256([]byte(plainKey)) + encodedHashedKey := b64.StdEncoding.EncodeToString(hashedKey[:]) + + _, err = store.GetAccount(context.Background(), existingAccountID) + require.NoError(t, err) + + setupKey, err := store.GetSetupKeyBySecret(context.Background(), LockingStrengthNone, encodedHashedKey) + require.NoError(t, err) + assert.Equal(t, 0, setupKey.UsedTimes) + + err = store.IncrementSetupKeyUsage(context.Background(), setupKey.Id) + require.NoError(t, err) + + setupKey, err = store.GetSetupKeyBySecret(context.Background(), LockingStrengthNone, encodedHashedKey) + require.NoError(t, err) + assert.Equal(t, 1, setupKey.UsedTimes) + + err = store.IncrementSetupKeyUsage(context.Background(), setupKey.Id) + require.NoError(t, err) + + setupKey, err = store.GetSetupKeyBySecret(context.Background(), LockingStrengthNone, encodedHashedKey) + require.NoError(t, err) + assert.Equal(t, 2, setupKey.UsedTimes) +} + +func Test_DeleteSetupKeySuccessfully(t *testing.T) { + t.Setenv("NETBIRD_STORE_ENGINE", string(types.SqliteStoreEngine)) + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + setupKeyID := "A2C8E62B-38F5-4553-B31E-DD66C696CEBB" + + err = store.DeleteSetupKey(context.Background(), accountID, setupKeyID) + require.NoError(t, err) + + _, err = store.GetSetupKeyByID(context.Background(), LockingStrengthNone, setupKeyID, accountID) + require.Error(t, err) +} + +func Test_DeleteSetupKeyFailsForNonExistingKey(t *testing.T) { + t.Setenv("NETBIRD_STORE_ENGINE", string(types.SqliteStoreEngine)) + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + nonExistingKeyID := "non-existing-key-id" + + err = store.DeleteSetupKey(context.Background(), accountID, nonExistingKeyID) + require.Error(t, err) +} diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index 73132bf75..731b90ce9 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -2,16 +2,11 @@ package store import ( "context" - "crypto/sha256" - b64 "encoding/base64" - "encoding/binary" "fmt" "net" "net/netip" "os" - "reflect" "runtime" - "sort" "sync" "testing" "time" @@ -22,21 +17,9 @@ import ( "github.com/stretchr/testify/require" nbdns "github.com/netbirdio/netbird/dns" - proxydomain "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" - rpservice "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" nbpeer "github.com/netbirdio/netbird/management/server/peer" - "github.com/netbirdio/netbird/management/server/posture" "github.com/netbirdio/netbird/management/server/types" - "github.com/netbirdio/netbird/management/server/util" nbroute "github.com/netbirdio/netbird/route" - "github.com/netbirdio/netbird/shared/management/status" - "github.com/netbirdio/netbird/shared/testing_helpers" - "github.com/netbirdio/netbird/util/crypt" ) func runTestForAllEngines(t *testing.T, testDataFile string, f func(t *testing.T, store Store)) { @@ -72,650 +55,6 @@ func Test_NewStore(t *testing.T) { }) } -func Test_SaveAccount_Large(t *testing.T) { - if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" { - t.Skip("skip CI tests on darwin and windows") - } - - runTestForAllEngines(t, "", func(t *testing.T, store Store) { - runLargeTest(t, store) - }) -} - -func runLargeTest(t *testing.T, store Store) { - t.Helper() - - account := newAccountWithId(context.Background(), "account_id", "testuser", "") - groupALL, err := account.GetGroupAll() - if err != nil { - t.Fatal(err) - } - setupKey, _ := types.GenerateDefaultSetupKey() - account.SetupKeys[setupKey.Key] = setupKey - const numPerAccount = 6000 - for n := 0; n < numPerAccount; n++ { - netIP := sequentialIPv4(n) - peerID := fmt.Sprintf("%s-peer-%d", account.Id, n) - addr, _ := netip.AddrFromSlice(netIP) - - peer := &nbpeer.Peer{ - ID: peerID, - Key: peerID, - IP: addr.Unmap(), - Name: peerID, - DNSLabel: peerID, - UserID: "testuser", - Status: &nbpeer.PeerStatus{Connected: false, LastSeen: time.Now()}, - SSHEnabled: false, - } - account.Peers[peerID] = peer - group, _ := account.GetGroupAll() - group.Peers = append(group.Peers, peerID) - user := &types.User{ - Id: fmt.Sprintf("%s-user-%d", account.Id, n), - AccountID: account.Id, - } - account.Users[user.Id] = user - route := &nbroute.Route{ - ID: nbroute.ID(fmt.Sprintf("network-id-%d", n)), - Description: "base route", - NetID: nbroute.NetID(fmt.Sprintf("network-id-%d", n)), - Network: netip.MustParsePrefix(netIP.String() + "/24"), - NetworkType: nbroute.IPv4Network, - Metric: 9999, - Masquerade: false, - Enabled: true, - Groups: []string{groupALL.ID}, - } - account.Routes[route.ID] = route - - group = &types.Group{ - ID: fmt.Sprintf("group-id-%d", n), - AccountID: account.Id, - Name: fmt.Sprintf("group-id-%d", n), - Issued: "api", - Peers: nil, - } - account.Groups[group.ID] = group - - nameserver := &nbdns.NameServerGroup{ - ID: fmt.Sprintf("nameserver-id-%d", n), - AccountID: account.Id, - Name: fmt.Sprintf("nameserver-id-%d", n), - Description: "", - NameServers: []nbdns.NameServer{{IP: netip.MustParseAddr(netIP.String()), NSType: nbdns.UDPNameServerType}}, - Groups: []string{group.ID}, - Primary: false, - Domains: nil, - Enabled: false, - SearchDomainsEnabled: false, - } - account.NameServerGroups[nameserver.ID] = nameserver - - setupKey, _ := types.GenerateDefaultSetupKey() - _, exists := account.SetupKeys[setupKey.Key] - if exists { - t.Errorf("setup key already exists") - } - account.SetupKeys[setupKey.Key] = setupKey - } - - err = store.SaveAccount(context.Background(), account) - require.NoError(t, err) - - if len(store.GetAllAccounts(context.Background())) != 1 { - t.Errorf("expecting 1 Accounts to be stored after SaveAccount()") - } - - a, err := store.GetAccount(context.Background(), account.Id) - if a == nil { - t.Errorf("expecting Account to be stored after SaveAccount(): %v", err) - } - - if a != nil && len(a.Policies) != 1 { - t.Errorf("expecting Account to have one policy stored after SaveAccount(), got %d", len(a.Policies)) - } - - if a != nil && len(a.Policies[0].Rules) != 1 { - t.Errorf("expecting Account to have one policy rule stored after SaveAccount(), got %d", len(a.Policies[0].Rules)) - return - } - - if a != nil && len(a.Peers) != numPerAccount { - t.Errorf("expecting Account to have %d peers stored after SaveAccount(), got %d", - numPerAccount, len(a.Peers)) - return - } - - if a != nil && len(a.Users) != numPerAccount+1 { - t.Errorf("expecting Account to have %d users stored after SaveAccount(), got %d", - numPerAccount+1, len(a.Users)) - return - } - - if a != nil && len(a.Routes) != numPerAccount { - t.Errorf("expecting Account to have %d routes stored after SaveAccount(), got %d", - numPerAccount, len(a.Routes)) - return - } - - if a != nil && len(a.NameServerGroups) != numPerAccount { - t.Errorf("expecting Account to have %d NameServerGroups stored after SaveAccount(), got %d", - numPerAccount, len(a.NameServerGroups)) - return - } - - if a != nil && len(a.NameServerGroups) != numPerAccount { - t.Errorf("expecting Account to have %d NameServerGroups stored after SaveAccount(), got %d", - numPerAccount, len(a.NameServerGroups)) - return - } - - if a != nil && len(a.SetupKeys) != numPerAccount+1 { - t.Errorf("expecting Account to have %d SetupKeys stored after SaveAccount(), got %d", - numPerAccount+1, len(a.SetupKeys)) - return - } -} - -// sequentialIPv4 returns a unique IPv4 address for the given index, avoiding -// the random collisions that would otherwise violate the unique (account_id, ip) -// index when generating a large number of peers. -func sequentialIPv4(n int) net.IP { - b := make([]byte, 4) - binary.BigEndian.PutUint32(b, 0x0A000000+uint32(n)) - return net.IP(b) -} - -func Test_SaveAccount(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("The SQLite store is not properly supported by Windows yet") - } - - runTestForAllEngines(t, "", func(t *testing.T, store Store) { - account := newAccountWithId(context.Background(), "account_id", "testuser", "") - setupKey, _ := types.GenerateDefaultSetupKey() - account.SetupKeys[setupKey.Key] = setupKey - account.Peers["testpeer"] = &nbpeer.Peer{ - Key: "peerkey", - IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), - IPv6: netip.MustParseAddr("fd00::1"), - Meta: nbpeer.PeerSystemMeta{}, - Name: "peer name", - Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, - } - - err := store.SaveAccount(context.Background(), account) - require.NoError(t, err) - - account2 := newAccountWithId(context.Background(), "account_id2", "testuser2", "") - setupKey, _ = types.GenerateDefaultSetupKey() - account2.SetupKeys[setupKey.Key] = setupKey - account2.Peers["testpeer2"] = &nbpeer.Peer{ - Key: "peerkey2", - IP: netip.AddrFrom4([4]byte{127, 0, 0, 2}), - IPv6: netip.MustParseAddr("fd00::2"), - Meta: nbpeer.PeerSystemMeta{}, - Name: "peer name 2", - Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, - } - - err = store.SaveAccount(context.Background(), account2) - require.NoError(t, err) - - if len(store.GetAllAccounts(context.Background())) != 2 { - t.Errorf("expecting 2 Accounts to be stored after SaveAccount()") - } - - a, err := store.GetAccount(context.Background(), account.Id) - if a == nil { - t.Errorf("expecting Account to be stored after SaveAccount(): %v", err) - } - - if a != nil && len(a.Policies) != 1 { - t.Errorf("expecting Account to have one policy stored after SaveAccount(), got %d", len(a.Policies)) - } - - if a != nil && len(a.Policies[0].Rules) != 1 { - t.Errorf("expecting Account to have one policy rule stored after SaveAccount(), got %d", len(a.Policies[0].Rules)) - return - } - - if a, err := store.GetAccountByPeerPubKey(context.Background(), "peerkey"); a == nil { - t.Errorf("expecting PeerKeyID2AccountID index updated after SaveAccount(): %v", err) - } - - if a, err := store.GetAccountByUser(context.Background(), "testuser"); a == nil { - t.Errorf("expecting UserID2AccountID index updated after SaveAccount(): %v", err) - } - - if a, err := store.GetAccountByPeerID(context.Background(), "testpeer"); a == nil { - t.Errorf("expecting PeerID2AccountID index updated after SaveAccount(): %v", err) - } - - if a, err := store.GetAccountBySetupKey(context.Background(), setupKey.Key); a == nil { - t.Errorf("expecting SetupKeyID2AccountID index updated after SaveAccount(): %v", err) - } - }) -} - -func Test_AccountSettings_SaveAndRetrieve(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("The SQLite store is not properly supported by Windows yet") - } - - populateFields := testing_helpers.NewPopulateFields().WithCustomFieldSetter( - reflect.PointerTo(reflect.TypeOf(types.ExtraSettings{})), func(this *testing_helpers.PopulateFields, field reflect.Value) (int, error) { - es := types.ExtraSettings{} - reflectedEs := reflect.ValueOf(&es).Elem() - n, err := this.PopulateAll(reflectedEs) - if err != nil { - return n, err - } - field.Set(reflectedEs.Addr()) - return n, nil - }).WithCustomFieldSetter( - reflect.PointerTo(reflect.TypeOf(types.DashboardFeatures{})), func(this *testing_helpers.PopulateFields, field reflect.Value) (int, error) { - t := true - df := types.DashboardFeatures{AgentNetwork: &t} - reflectedDf := reflect.ValueOf(&df).Elem() - field.Set(reflectedDf.Addr()) - return 1, nil - }).WithSkippedTag("gorm", "-") - - runTestForAllEngines(t, "", func(t *testing.T, store Store) { - account := newAccountWithId(context.Background(), "account_id", "testuser", "") - setupKey, _ := types.GenerateDefaultSetupKey() - account.SetupKeys[setupKey.Key] = setupKey - - settings := types.Settings{} - numOfExportedFields, err := populateFields.PopulateAll(reflect.ValueOf(&settings).Elem()) - assert.NoError(t, err) - assert.Equal(t, 27, numOfExportedFields) - account.Settings = &settings - - err = store.SaveAccount(context.Background(), account) - assert.NoError(t, err) - - accountFromDb, err := store.GetAccount(context.Background(), account.Id) - assert.NoError(t, err) - assert.NotNil(t, accountFromDb) - assert.NotNil(t, accountFromDb.Settings) - - assert.True(t, reflect.DeepEqual(&settings, accountFromDb.Settings), "created settings and settings retrieved from the db should match") - }) -} - -func TestSqlite_DeleteAccount(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("The SQLite store is not properly supported by Windows yet") - } - - t.Setenv("NETBIRD_STORE_ENGINE", string(types.SqliteStoreEngine)) - store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir()) - t.Cleanup(cleanUp) - assert.NoError(t, err) - - testUserID := "testuser" - user := types.NewAdminUser(testUserID) - user.PATs = map[string]*types.PersonalAccessToken{"testtoken": { - ID: "testtoken", - Name: "test token", - }} - - account := newAccountWithId(context.Background(), "account_id", testUserID, "") - setupKey, _ := types.GenerateDefaultSetupKey() - account.SetupKeys[setupKey.Key] = setupKey - account.Peers["testpeer"] = &nbpeer.Peer{ - Key: "peerkey", - IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), - IPv6: netip.MustParseAddr("fd00::1"), - Meta: nbpeer.PeerSystemMeta{}, - Name: "peer name", - Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, - } - account.Users[testUserID] = user - account.Networks = []*networkTypes.Network{ - { - ID: "network_id", - AccountID: account.Id, - Name: "network name", - Description: "network description", - }, - } - account.NetworkRouters = []*routerTypes.NetworkRouter{ - { - ID: "router_id", - NetworkID: account.Networks[0].ID, - AccountID: account.Id, - PeerGroups: []string{"group_id"}, - Masquerade: true, - Metric: 1, - }, - } - account.NetworkResources = []*resourceTypes.NetworkResource{ - { - ID: "resource_id", - NetworkID: account.Networks[0].ID, - AccountID: account.Id, - Name: "Name", - Description: "Description", - Type: "Domain", - Address: "example.com", - }, - } - - account.Services = []*rpservice.Service{ - { - ID: "service_id", - AccountID: account.Id, - Name: "test service", - Domain: "svc.example.com", - Enabled: true, - Targets: []*rpservice.Target{ - { - AccountID: account.Id, - ServiceID: "service_id", - Host: "localhost", - Port: 8080, - Protocol: "http", - Enabled: true, - }, - }, - }, - } - - account.Domains = []*proxydomain.Domain{ - { - ID: "domain_id", - Domain: "custom.example.com", - AccountID: account.Id, - Validated: true, - }, - } - - err = store.SaveAccount(context.Background(), account) - require.NoError(t, err) - - if len(store.GetAllAccounts(context.Background())) != 1 { - t.Errorf("expecting 1 Accounts to be stored after SaveAccount()") - } - - o, err := store.GetAccountOnboarding(context.Background(), account.Id) - require.NoError(t, err) - require.Equal(t, o.AccountID, account.Id) - - err = store.DeleteAccount(context.Background(), account) - require.NoError(t, err) - - _, err = store.GetAccountOnboarding(context.Background(), account.Id) - require.Error(t, err, "expecting error after removing DeleteAccount when getting onboarding") - - if len(store.GetAllAccounts(context.Background())) != 0 { - t.Errorf("expecting 0 Accounts to be stored after DeleteAccount()") - } - - _, err = store.GetAccountByPeerPubKey(context.Background(), "peerkey") - require.Error(t, err, "expecting error after removing DeleteAccount when getting account by peer public key") - - _, err = store.GetAccountByUser(context.Background(), "testuser") - require.Error(t, err, "expecting error after removing DeleteAccount when getting account by user") - - _, err = store.GetAccountByPeerID(context.Background(), "testpeer") - require.Error(t, err, "expecting error after removing DeleteAccount when getting account by peer id") - - _, err = store.GetAccountBySetupKey(context.Background(), setupKey.Key) - require.Error(t, err, "expecting error after removing DeleteAccount when getting account by setup key") - - _, err = store.GetAccount(context.Background(), account.Id) - require.Error(t, err, "expecting error after removing DeleteAccount when getting account by id") - - for _, policy := range account.Policies { - var rules []*types.PolicyRule - err = store.(*SqlStore).db.Model(&types.PolicyRule{}).Find(&rules, "policy_id = ?", policy.ID).Error - require.NoError(t, err, "expecting no error after removing DeleteAccount when searching for policy rules") - require.Len(t, rules, 0, "expecting no policy rules to be found after removing DeleteAccount") - - } - - for _, accountUser := range account.Users { - var pats []*types.PersonalAccessToken - err = store.(*SqlStore).db.Model(&types.PersonalAccessToken{}).Find(&pats, "user_id = ?", accountUser.Id).Error - require.NoError(t, err, "expecting no error after removing DeleteAccount when searching for personal access token") - require.Len(t, pats, 0, "expecting no personal access token to be found after removing DeleteAccount") - - } - - for _, network := range account.Networks { - routers, err := store.GetNetworkRoutersByNetID(context.Background(), LockingStrengthNone, account.Id, network.ID) - require.NoError(t, err, "expecting no error after removing DeleteAccount when searching for network routers") - require.Len(t, routers, 0, "expecting no network routers to be found after DeleteAccount") - - resources, err := store.GetNetworkResourcesByNetID(context.Background(), LockingStrengthNone, account.Id, network.ID) - require.NoError(t, err, "expecting no error after removing DeleteAccount when searching for network resources") - require.Len(t, resources, 0, "expecting no network resources to be found after DeleteAccount") - } - - domains, err := store.ListCustomDomains(context.Background(), account.Id) - require.NoError(t, err, "expecting no error after DeleteAccount when searching for custom domains") - require.Len(t, domains, 0, "expecting no custom domains to be found after DeleteAccount") - - var services []*rpservice.Service - err = store.(*SqlStore).db.Model(&rpservice.Service{}).Find(&services, "account_id = ?", account.Id).Error - require.NoError(t, err, "expecting no error after DeleteAccount when searching for services") - require.Len(t, services, 0, "expecting no services to be found after DeleteAccount") - - var targets []*rpservice.Target - err = store.(*SqlStore).db.Model(&rpservice.Target{}).Find(&targets, "account_id = ?", account.Id).Error - require.NoError(t, err, "expecting no error after DeleteAccount when searching for service targets") - require.Len(t, targets, 0, "expecting no service targets to be found after DeleteAccount") -} - -func Test_GetAccount(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("The SQLite store is not properly supported by Windows yet") - } - - runTestForAllEngines(t, "../testdata/store.sql", func(t *testing.T, store Store) { - id := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - account, err := store.GetAccount(context.Background(), id) - require.NoError(t, err) - require.Equal(t, id, account.Id, "account id should match") - require.Equal(t, false, account.Onboarding.OnboardingFlowPending) - - id = "9439-34653001fc3b-bf1c8084-ba50-4ce7" - - account, err = store.GetAccount(context.Background(), id) - require.NoError(t, err) - require.Equal(t, id, account.Id, "account id should match") - require.Equal(t, true, account.Onboarding.OnboardingFlowPending) - - _, err = store.GetAccount(context.Background(), "non-existing-account") - assert.Error(t, err) - parsedErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") - - }) -} - -// TestSqlStore_GetPeerByIP_NotFound pins the not-found semantics the -// proxy's ValidateTunnelPeer relies on: a tunnel-IP that isn't in the -// account roster must surface as a NotFound error (not a generic -// Internal) so callers can distinguish an expected miss from a real -// store failure. A known IP still resolves. -func TestSqlStore_GetPeerByIP_NotFound(t *testing.T) { - runTestForAllEngines(t, "../testdata/store.sql", func(t *testing.T, store Store) { - const accountID = "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - peer, err := store.GetPeerByIP(context.Background(), LockingStrengthNone, accountID, net.ParseIP("192.168.0.0")) - require.NoError(t, err, "known tunnel IP must resolve") - require.NotNil(t, peer) - - _, err = store.GetPeerByIP(context.Background(), LockingStrengthNone, accountID, net.ParseIP("100.65.0.99")) - require.Error(t, err, "unknown tunnel IP must error") - parsedErr, ok := status.FromError(err) - require.True(t, ok, "error must be a status error") - require.Equal(t, status.NotFound, parsedErr.Type(), "tunnel-IP miss must be NotFound, not Internal") - }) -} - -func TestSqlStore_SavePeer(t *testing.T) { - populateFields := testing_helpers.NewPopulateFields() - - runTestForAllEngines(t, "../testdata/store.sql", func(t *testing.T, store Store) { - account, err := store.GetAccount(context.Background(), "bf1c8084-ba50-4ce7-9439-34653001fc3b") - require.NoError(t, err) - - metadata := nbpeer.PeerSystemMeta{} - reflectedMetadata := reflect.ValueOf(&metadata).Elem() - - numOfFields, err := populateFields.PopulateAll(reflectedMetadata) - assert.NoError(t, err) - assert.Equal(t, 33, numOfFields) - - // save status of non-existing peer - peer := &nbpeer.Peer{ - Key: "peerkey", - ID: "testpeer", - IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), - IPv6: netip.MustParseAddr("fd00::1"), - Meta: metadata, //nbpeer.PeerSystemMeta{Hostname: "testingpeer"}, - Name: "peer name", - Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, - CreatedAt: time.Now().UTC(), - } - ctx := context.Background() - err = store.SavePeer(ctx, account.Id, peer) - assert.Error(t, err) - parsedErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") - - // save new status of existing peer - account.Peers[peer.ID] = peer - - err = store.SaveAccount(context.Background(), account) - require.NoError(t, err) - - updatedPeer := peer.Copy() - updatedPeer.Status.Connected = false - updatedPeer.Meta.Hostname = "updatedpeer" - - err = store.SavePeer(ctx, account.Id, updatedPeer) - require.NoError(t, err) - - account, err = store.GetAccount(context.Background(), account.Id) - require.NoError(t, err) - - actual := account.Peers[peer.ID] - assert.Equal(t, updatedPeer.Meta, actual.Meta) - assert.Equal(t, updatedPeer.Status.Connected, actual.Status.Connected) - assert.Equal(t, updatedPeer.Status.LoginExpired, actual.Status.LoginExpired) - assert.Equal(t, updatedPeer.Status.RequiresApproval, actual.Status.RequiresApproval) - assert.WithinDurationf(t, updatedPeer.Status.LastSeen, actual.Status.LastSeen.UTC(), time.Millisecond, "LastSeen should be equal") - }) -} - -func TestSqlStore_SavePeerStatus(t *testing.T) { - store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanUp) - assert.NoError(t, err) - - account, err := store.GetAccount(context.Background(), "bf1c8084-ba50-4ce7-9439-34653001fc3b") - require.NoError(t, err) - - // save status of non-existing peer - newStatus := nbpeer.PeerStatus{Connected: false, LastSeen: time.Now().UTC()} - err = store.SavePeerStatus(context.Background(), account.Id, "non-existing-peer", newStatus) - assert.Error(t, err) - parsedErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") - - // save new status of existing peer - account.Peers["testpeer"] = &nbpeer.Peer{ - Key: "peerkey", - ID: "testpeer", - IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), - IPv6: netip.MustParseAddr("fd00::1"), - Meta: nbpeer.PeerSystemMeta{}, - Name: "peer name", - Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, - } - - err = store.SaveAccount(context.Background(), account) - require.NoError(t, err) - - err = store.SavePeerStatus(context.Background(), account.Id, "testpeer", newStatus) - require.NoError(t, err) - - account, err = store.GetAccount(context.Background(), account.Id) - require.NoError(t, err) - - actual := account.Peers["testpeer"].Status - assert.Equal(t, newStatus.Connected, actual.Connected) - assert.Equal(t, newStatus.LoginExpired, actual.LoginExpired) - assert.Equal(t, newStatus.RequiresApproval, actual.RequiresApproval) - assert.WithinDurationf(t, newStatus.LastSeen, actual.LastSeen.UTC(), time.Millisecond, "LastSeen should be equal") - - newStatus.Connected = true - - err = store.SavePeerStatus(context.Background(), account.Id, "testpeer", newStatus) - require.NoError(t, err) - - account, err = store.GetAccount(context.Background(), account.Id) - require.NoError(t, err) - - actual = account.Peers["testpeer"].Status - assert.Equal(t, newStatus.Connected, actual.Connected) - assert.Equal(t, newStatus.LoginExpired, actual.LoginExpired) - assert.Equal(t, newStatus.RequiresApproval, actual.RequiresApproval) - assert.WithinDurationf(t, newStatus.LastSeen, actual.LastSeen.UTC(), time.Millisecond, "LastSeen should be equal") -} - -func Test_TestGetAccountByPrivateDomain(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("The SQLite store is not properly supported by Windows yet") - } - - runTestForAllEngines(t, "../testdata/store.sql", func(t *testing.T, store Store) { - existingDomain := "test.com" - - account, err := store.GetAccountByPrivateDomain(context.Background(), existingDomain) - require.NoError(t, err, "should found account") - require.Equal(t, existingDomain, account.Domain, "domains should match") - - _, err = store.GetAccountByPrivateDomain(context.Background(), "missing-domain.com") - require.Error(t, err, "should return error on domain lookup") - parsedErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") - }) -} - -func Test_GetTokenIDByHashedToken(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("The SQLite store is not properly supported by Windows yet") - } - - runTestForAllEngines(t, "../testdata/store.sql", func(t *testing.T, store Store) { - hashed := "SoMeHaShEdToKeN" - id := "9dj38s35-63fb-11ec-90d6-0242ac120003" - - token, err := store.GetTokenIDByHashedToken(context.Background(), hashed) - require.NoError(t, err) - require.Equal(t, id, token) - - _, err = store.GetTokenIDByHashedToken(context.Background(), "non-existing-hash") - require.Error(t, err) - parsedErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, status.NotFound, parsedErr.Type(), "should return not found error") - }) -} - func TestMigrate(t *testing.T) { if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" { t.Skip("skip CI tests on darwin and windows") @@ -842,434 +181,6 @@ func TestPostgresql_NewStore(t *testing.T) { } } -func TestPostgresql_SaveAccount(t *testing.T) { - if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" { - t.Skip("skip CI tests on darwin and windows") - } - - t.Setenv("NETBIRD_STORE_ENGINE", string(types.PostgresStoreEngine)) - store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir()) - t.Cleanup(cleanUp) - assert.NoError(t, err) - - account := newAccountWithId(context.Background(), "account_id", "testuser", "") - setupKey, _ := types.GenerateDefaultSetupKey() - account.SetupKeys[setupKey.Key] = setupKey - account.Peers["testpeer"] = &nbpeer.Peer{ - Key: "peerkey", - IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), - IPv6: netip.MustParseAddr("fd00::1"), - Meta: nbpeer.PeerSystemMeta{}, - Name: "peer name", - Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, - } - - err = store.SaveAccount(context.Background(), account) - require.NoError(t, err) - - account2 := newAccountWithId(context.Background(), "account_id2", "testuser2", "") - setupKey, _ = types.GenerateDefaultSetupKey() - account2.SetupKeys[setupKey.Key] = setupKey - account2.Peers["testpeer2"] = &nbpeer.Peer{ - Key: "peerkey2", - IP: netip.AddrFrom4([4]byte{127, 0, 0, 2}), - IPv6: netip.MustParseAddr("fd00::2"), - Meta: nbpeer.PeerSystemMeta{}, - Name: "peer name 2", - Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, - } - - err = store.SaveAccount(context.Background(), account2) - require.NoError(t, err) - - if len(store.GetAllAccounts(context.Background())) != 2 { - t.Errorf("expecting 2 Accounts to be stored after SaveAccount()") - } - - a, err := store.GetAccount(context.Background(), account.Id) - if a == nil { - t.Errorf("expecting Account to be stored after SaveAccount(): %v", err) - } - - if a != nil && len(a.Policies) != 1 { - t.Errorf("expecting Account to have one policy stored after SaveAccount(), got %d", len(a.Policies)) - } - - if a != nil && len(a.Policies[0].Rules) != 1 { - t.Errorf("expecting Account to have one policy rule stored after SaveAccount(), got %d", len(a.Policies[0].Rules)) - return - } - - if a, err := store.GetAccountByPeerPubKey(context.Background(), "peerkey"); a == nil { - t.Errorf("expecting PeerKeyID2AccountID index updated after SaveAccount(): %v", err) - } - - if a, err := store.GetAccountByUser(context.Background(), "testuser"); a == nil { - t.Errorf("expecting UserID2AccountID index updated after SaveAccount(): %v", err) - } - - if a, err := store.GetAccountByPeerID(context.Background(), "testpeer"); a == nil { - t.Errorf("expecting PeerID2AccountID index updated after SaveAccount(): %v", err) - } - - if a, err := store.GetAccountBySetupKey(context.Background(), setupKey.Key); a == nil { - t.Errorf("expecting SetupKeyID2AccountID index updated after SaveAccount(): %v", err) - } -} - -func TestPostgresql_DeleteAccount(t *testing.T) { - if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" { - t.Skip("skip CI tests on darwin and windows") - } - - t.Setenv("NETBIRD_STORE_ENGINE", string(types.PostgresStoreEngine)) - store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir()) - t.Cleanup(cleanUp) - assert.NoError(t, err) - - testUserID := "testuser" - user := types.NewAdminUser(testUserID) - user.PATs = map[string]*types.PersonalAccessToken{"testtoken": { - ID: "testtoken", - Name: "test token", - }} - - account := newAccountWithId(context.Background(), "account_id", testUserID, "") - setupKey, _ := types.GenerateDefaultSetupKey() - account.SetupKeys[setupKey.Key] = setupKey - account.Peers["testpeer"] = &nbpeer.Peer{ - Key: "peerkey", - IP: netip.AddrFrom4([4]byte{127, 0, 0, 1}), - IPv6: netip.MustParseAddr("fd00::1"), - Meta: nbpeer.PeerSystemMeta{}, - Name: "peer name", - Status: &nbpeer.PeerStatus{Connected: true, LastSeen: time.Now().UTC()}, - } - account.Users[testUserID] = user - - err = store.SaveAccount(context.Background(), account) - require.NoError(t, err) - - if len(store.GetAllAccounts(context.Background())) != 1 { - t.Errorf("expecting 1 Accounts to be stored after SaveAccount()") - } - - err = store.DeleteAccount(context.Background(), account) - require.NoError(t, err) - - if len(store.GetAllAccounts(context.Background())) != 0 { - t.Errorf("expecting 0 Accounts to be stored after DeleteAccount()") - } - - _, err = store.GetAccountByPeerPubKey(context.Background(), "peerkey") - require.Error(t, err, "expecting error after removing DeleteAccount when getting account by peer public key") - - _, err = store.GetAccountByUser(context.Background(), "testuser") - require.Error(t, err, "expecting error after removing DeleteAccount when getting account by user") - - _, err = store.GetAccountByPeerID(context.Background(), "testpeer") - require.Error(t, err, "expecting error after removing DeleteAccount when getting account by peer id") - - _, err = store.GetAccountBySetupKey(context.Background(), setupKey.Key) - require.Error(t, err, "expecting error after removing DeleteAccount when getting account by setup key") - - _, err = store.GetAccount(context.Background(), account.Id) - require.Error(t, err, "expecting error after removing DeleteAccount when getting account by id") - - for _, policy := range account.Policies { - var rules []*types.PolicyRule - err = store.(*SqlStore).db.Model(&types.PolicyRule{}).Find(&rules, "policy_id = ?", policy.ID).Error - require.NoError(t, err, "expecting no error after removing DeleteAccount when searching for policy rules") - require.Len(t, rules, 0, "expecting no policy rules to be found after removing DeleteAccount") - - } - - for _, accountUser := range account.Users { - var pats []*types.PersonalAccessToken - err = store.(*SqlStore).db.Model(&types.PersonalAccessToken{}).Find(&pats, "user_id = ?", accountUser.Id).Error - require.NoError(t, err, "expecting no error after removing DeleteAccount when searching for personal access token") - require.Len(t, pats, 0, "expecting no personal access token to be found after removing DeleteAccount") - - } - -} - -func TestPostgresql_TestGetAccountByPrivateDomain(t *testing.T) { - if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" { - t.Skip("skip CI tests on darwin and windows") - } - - t.Setenv("NETBIRD_STORE_ENGINE", string(types.PostgresStoreEngine)) - store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanUp) - assert.NoError(t, err) - - existingDomain := "test.com" - - account, err := store.GetAccountByPrivateDomain(context.Background(), existingDomain) - require.NoError(t, err, "should found account") - require.Equal(t, existingDomain, account.Domain, "domains should match") - - _, err = store.GetAccountByPrivateDomain(context.Background(), "missing-domain.com") - require.Error(t, err, "should return error on domain lookup") -} - -func TestPostgresql_GetTokenIDByHashedToken(t *testing.T) { - if (os.Getenv("CI") == "true" && runtime.GOOS == "darwin") || runtime.GOOS == "windows" { - t.Skip("skip CI tests on darwin and windows") - } - - t.Setenv("NETBIRD_STORE_ENGINE", string(types.PostgresStoreEngine)) - store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanUp) - assert.NoError(t, err) - - hashed := "SoMeHaShEdToKeN" - id := "9dj38s35-63fb-11ec-90d6-0242ac120003" - - token, err := store.GetTokenIDByHashedToken(context.Background(), hashed) - require.NoError(t, err) - require.Equal(t, id, token) -} - -func TestSqlite_GetTakenIPs(t *testing.T) { - t.Setenv("NETBIRD_STORE_ENGINE", string(types.SqliteStoreEngine)) - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - defer cleanup() - if err != nil { - t.Fatal(err) - } - - existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - _, err = store.GetAccount(context.Background(), existingAccountID) - require.NoError(t, err) - - takenIPs, err := store.GetTakenIPs(context.Background(), LockingStrengthNone, existingAccountID) - require.NoError(t, err) - assert.Equal(t, []netip.Addr{}, takenIPs) - - peer1 := &nbpeer.Peer{ - ID: "peer1", - AccountID: existingAccountID, - Key: "key1", - DNSLabel: "peer1", - IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), - IPv6: netip.MustParseAddr("fd00::1:1:1:1"), - } - err = store.AddPeerToAccount(context.Background(), peer1) - require.NoError(t, err) - - takenIPs, err = store.GetTakenIPs(context.Background(), LockingStrengthNone, existingAccountID) - require.NoError(t, err) - ip1 := netip.AddrFrom4([4]byte{1, 1, 1, 1}) - assert.Equal(t, []netip.Addr{ip1}, takenIPs) - - peer2 := &nbpeer.Peer{ - ID: "peer1second", - AccountID: existingAccountID, - Key: "key2", - DNSLabel: "peer1-1", - IP: netip.AddrFrom4([4]byte{2, 2, 2, 2}), - IPv6: netip.MustParseAddr("fd00::2:2:2:2"), - } - err = store.AddPeerToAccount(context.Background(), peer2) - require.NoError(t, err) - - takenIPs, err = store.GetTakenIPs(context.Background(), LockingStrengthNone, existingAccountID) - require.NoError(t, err) - ip2 := netip.AddrFrom4([4]byte{2, 2, 2, 2}) - assert.Equal(t, []netip.Addr{ip1, ip2}, takenIPs) -} - -func TestSqlite_GetPeerLabelsInAccount(t *testing.T) { - runTestForAllEngines(t, "../testdata/extended-store.sql", func(t *testing.T, store Store) { - existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - peerHostname := "peer1" - - _, err := store.GetAccount(context.Background(), existingAccountID) - require.NoError(t, err) - - labels, err := store.GetPeerLabelsInAccount(context.Background(), LockingStrengthNone, existingAccountID, peerHostname) - require.NoError(t, err) - assert.Equal(t, []string{}, labels) - - peer1 := &nbpeer.Peer{ - ID: "peer1", - AccountID: existingAccountID, - Key: "key1", - DNSLabel: "peer1", - IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), - IPv6: netip.MustParseAddr("fd00::1:1:1:1"), - } - err = store.AddPeerToAccount(context.Background(), peer1) - require.NoError(t, err) - - labels, err = store.GetPeerLabelsInAccount(context.Background(), LockingStrengthNone, existingAccountID, peerHostname) - require.NoError(t, err) - assert.Equal(t, []string{"peer1"}, labels) - - peer2 := &nbpeer.Peer{ - ID: "peer1second", - AccountID: existingAccountID, - Key: "key2", - DNSLabel: "peer1-1", - IP: netip.AddrFrom4([4]byte{2, 2, 2, 2}), - IPv6: netip.MustParseAddr("fd00::2:2:2:2"), - } - err = store.AddPeerToAccount(context.Background(), peer2) - require.NoError(t, err) - - labels, err = store.GetPeerLabelsInAccount(context.Background(), LockingStrengthNone, existingAccountID, peerHostname) - require.NoError(t, err) - - expected := []string{"peer1", "peer1-1"} - sort.Strings(expected) - sort.Strings(labels) - assert.Equal(t, expected, labels) - }) -} - -func Test_AddPeerWithSameDnsLabel(t *testing.T) { - runTestForAllEngines(t, "../testdata/extended-store.sql", func(t *testing.T, store Store) { - existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - _, err := store.GetAccount(context.Background(), existingAccountID) - require.NoError(t, err) - - peer1 := &nbpeer.Peer{ - ID: "peer1", - AccountID: existingAccountID, - Key: "key1", - DNSLabel: "peer1.domain.test", - } - err = store.AddPeerToAccount(context.Background(), peer1) - require.NoError(t, err) - - peer2 := &nbpeer.Peer{ - ID: "peer1second", - AccountID: existingAccountID, - Key: "key2", - DNSLabel: "peer1.domain.test", - } - err = store.AddPeerToAccount(context.Background(), peer2) - require.Error(t, err) - }) -} - -func Test_AddPeerWithSameIP(t *testing.T) { - runTestForAllEngines(t, "../testdata/extended-store.sql", func(t *testing.T, store Store) { - existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - _, err := store.GetAccount(context.Background(), existingAccountID) - require.NoError(t, err) - - peer1 := &nbpeer.Peer{ - ID: "peer1", - AccountID: existingAccountID, - Key: "key1", - IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), - IPv6: netip.MustParseAddr("fd00::1:1:1:1"), - } - err = store.AddPeerToAccount(context.Background(), peer1) - require.NoError(t, err) - - peer2 := &nbpeer.Peer{ - ID: "peer1second", - AccountID: existingAccountID, - Key: "key2", - IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), - IPv6: netip.MustParseAddr("fd00::2:2:2:2"), - } - err = store.AddPeerToAccount(context.Background(), peer2) - require.Error(t, err) - }) -} - -func TestSqlite_GetAccountNetwork(t *testing.T) { - t.Setenv("NETBIRD_STORE_ENGINE", string(types.SqliteStoreEngine)) - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - if err != nil { - t.Fatal(err) - } - - existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - _, err = store.GetAccount(context.Background(), existingAccountID) - require.NoError(t, err) - - network, err := store.GetAccountNetwork(context.Background(), LockingStrengthNone, existingAccountID) - require.NoError(t, err) - ip := net.IP{100, 64, 0, 0}.To16() - assert.Equal(t, ip, network.Net.IP) - assert.Equal(t, net.IPMask{255, 255, 0, 0}, network.Net.Mask) - assert.Equal(t, "", network.Dns) - assert.Equal(t, "af1c8024-ha40-4ce2-9418-34653101fc3c", network.Identifier) - assert.Equal(t, uint64(0), network.Serial) -} - -func TestSqlite_GetSetupKeyBySecret(t *testing.T) { - t.Setenv("NETBIRD_STORE_ENGINE", string(types.SqliteStoreEngine)) - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - if err != nil { - t.Fatal(err) - } - - existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - plainKey := "A2C8E62B-38F5-4553-B31E-DD66C696CEBB" - hashedKey := sha256.Sum256([]byte(plainKey)) - encodedHashedKey := b64.StdEncoding.EncodeToString(hashedKey[:]) - - _, err = store.GetAccount(context.Background(), existingAccountID) - require.NoError(t, err) - - setupKey, err := store.GetSetupKeyBySecret(context.Background(), LockingStrengthNone, encodedHashedKey) - require.NoError(t, err) - assert.Equal(t, encodedHashedKey, setupKey.Key) - assert.Equal(t, types.HiddenKey(plainKey, 4), setupKey.KeySecret) - assert.Equal(t, "bf1c8084-ba50-4ce7-9439-34653001fc3b", setupKey.AccountID) - assert.Equal(t, "Default key", setupKey.Name) -} - -func TestSqlite_incrementSetupKeyUsage(t *testing.T) { - t.Setenv("NETBIRD_STORE_ENGINE", string(types.SqliteStoreEngine)) - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - if err != nil { - t.Fatal(err) - } - - existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - plainKey := "A2C8E62B-38F5-4553-B31E-DD66C696CEBB" - hashedKey := sha256.Sum256([]byte(plainKey)) - encodedHashedKey := b64.StdEncoding.EncodeToString(hashedKey[:]) - - _, err = store.GetAccount(context.Background(), existingAccountID) - require.NoError(t, err) - - setupKey, err := store.GetSetupKeyBySecret(context.Background(), LockingStrengthNone, encodedHashedKey) - require.NoError(t, err) - assert.Equal(t, 0, setupKey.UsedTimes) - - err = store.IncrementSetupKeyUsage(context.Background(), setupKey.Id) - require.NoError(t, err) - - setupKey, err = store.GetSetupKeyBySecret(context.Background(), LockingStrengthNone, encodedHashedKey) - require.NoError(t, err) - assert.Equal(t, 1, setupKey.UsedTimes) - - err = store.IncrementSetupKeyUsage(context.Background(), setupKey.Id) - require.NoError(t, err) - - setupKey, err = store.GetSetupKeyBySecret(context.Background(), LockingStrengthNone, encodedHashedKey) - require.NoError(t, err) - assert.Equal(t, 2, setupKey.UsedTimes) -} - func TestSqlite_CreateAndGetObjectInTransaction(t *testing.T) { t.Setenv("NETBIRD_STORE_ENGINE", string(types.SqliteStoreEngine)) store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) @@ -1302,965 +213,6 @@ func TestSqlite_CreateAndGetObjectInTransaction(t *testing.T) { assert.NoError(t, err) } -func TestSqlStore_SaveAccountPersistsAgentNetworkOnly(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - account, err := store.GetAccount(context.Background(), accountID) - require.NoError(t, err) - require.False(t, account.Settings.AgentNetworkOnly, "setting should default to false") - - account.Settings.AgentNetworkOnly = true - require.NoError(t, store.SaveAccount(context.Background(), account)) - - reloaded, err := store.GetAccount(context.Background(), accountID) - require.NoError(t, err) - require.True(t, reloaded.Settings.AgentNetworkOnly, "setting should survive a save/load round-trip") - - reloaded.Settings.AgentNetworkOnly = false - require.NoError(t, store.SaveAccount(context.Background(), reloaded)) - - disabled, err := store.GetAccount(context.Background(), accountID) - require.NoError(t, err) - require.False(t, disabled.Settings.AgentNetworkOnly, "disabling should persist") -} - -func TestSqlStore_SaveAccountPersistsDashboardFeatures(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - account, err := store.GetAccount(context.Background(), accountID) - require.NoError(t, err) - require.Nil(t, account.Settings.DashboardFeatures, "dashboard features should default to unset") - - agentNetwork := true - account.Settings.DashboardFeatures = &types.DashboardFeatures{AgentNetwork: &agentNetwork} - require.NoError(t, store.SaveAccount(context.Background(), account)) - - reloaded, err := store.GetAccount(context.Background(), accountID) - require.NoError(t, err) - require.NotNil(t, reloaded.Settings.DashboardFeatures, "dashboard features should survive a save/load round-trip") - require.NotNil(t, reloaded.Settings.DashboardFeatures.AgentNetwork, "agent network flag should be set") - require.True(t, *reloaded.Settings.DashboardFeatures.AgentNetwork, "agent network flag should persist as true") - - disabled := false - reloaded.Settings.DashboardFeatures = &types.DashboardFeatures{AgentNetwork: &disabled} - require.NoError(t, store.SaveAccount(context.Background(), reloaded)) - - reloadedDisabled, err := store.GetAccount(context.Background(), accountID) - require.NoError(t, err) - require.NotNil(t, reloadedDisabled.Settings.DashboardFeatures.AgentNetwork, "agent network flag should remain set") - require.False(t, *reloadedDisabled.Settings.DashboardFeatures.AgentNetwork, "explicit false should persist") -} - -func TestSqlStore_GetAccountUsers(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - if err != nil { - t.Fatal(err) - } - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - account, err := store.GetAccount(context.Background(), accountID) - require.NoError(t, err) - users, err := store.GetAccountUsers(context.Background(), LockingStrengthNone, accountID) - require.NoError(t, err) - require.Len(t, users, len(account.Users)) -} - -func TestSqlStore_UpdateAccountDomainAttributes(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - if err != nil { - t.Fatal(err) - } - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - t.Run("Should update attributes with public domain", func(t *testing.T) { - require.NoError(t, err) - domain := "example.com" - category := "public" - IsDomainPrimaryAccount := false - err = store.UpdateAccountDomainAttributes(context.Background(), accountID, domain, category, IsDomainPrimaryAccount) - require.NoError(t, err) - account, err := store.GetAccount(context.Background(), accountID) - require.NoError(t, err) - require.Equal(t, domain, account.Domain) - require.Equal(t, category, account.DomainCategory) - require.Equal(t, IsDomainPrimaryAccount, account.IsDomainPrimaryAccount) - }) - - t.Run("Should update attributes with private domain", func(t *testing.T) { - require.NoError(t, err) - domain := "test.com" - category := "private" - IsDomainPrimaryAccount := true - err = store.UpdateAccountDomainAttributes(context.Background(), accountID, domain, category, IsDomainPrimaryAccount) - require.NoError(t, err) - account, err := store.GetAccount(context.Background(), accountID) - require.NoError(t, err) - require.Equal(t, domain, account.Domain) - require.Equal(t, category, account.DomainCategory) - require.Equal(t, IsDomainPrimaryAccount, account.IsDomainPrimaryAccount) - }) - - t.Run("Should fail when account does not exist", func(t *testing.T) { - require.NoError(t, err) - domain := "test.com" - category := "private" - IsDomainPrimaryAccount := true - err = store.UpdateAccountDomainAttributes(context.Background(), "non-existing-account-id", domain, category, IsDomainPrimaryAccount) - require.Error(t, err) - }) - -} - -func TestSqlite_GetGroupByName(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - if err != nil { - t.Fatal(err) - } - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - group, err := store.GetGroupByName(context.Background(), LockingStrengthNone, accountID, "All") - require.NoError(t, err) - require.True(t, group.IsGroupAll()) -} - -func Test_DeleteSetupKeySuccessfully(t *testing.T) { - t.Setenv("NETBIRD_STORE_ENGINE", string(types.SqliteStoreEngine)) - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - setupKeyID := "A2C8E62B-38F5-4553-B31E-DD66C696CEBB" - - err = store.DeleteSetupKey(context.Background(), accountID, setupKeyID) - require.NoError(t, err) - - _, err = store.GetSetupKeyByID(context.Background(), LockingStrengthNone, setupKeyID, accountID) - require.Error(t, err) -} - -func Test_DeleteSetupKeyFailsForNonExistingKey(t *testing.T) { - t.Setenv("NETBIRD_STORE_ENGINE", string(types.SqliteStoreEngine)) - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - nonExistingKeyID := "non-existing-key-id" - - err = store.DeleteSetupKey(context.Background(), accountID, nonExistingKeyID) - require.Error(t, err) -} - -func TestSqlStore_GetGroupsByIDs(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - tests := []struct { - name string - groupIDs []string - expectedCount int - }{ - { - name: "retrieve existing groups by existing IDs", - groupIDs: []string{"cfefqs706sqkneg59g4g", "cfefqs706sqkneg59g3g"}, - expectedCount: 2, - }, - { - name: "empty group IDs list", - groupIDs: []string{}, - expectedCount: 0, - }, - { - name: "non-existing group IDs", - groupIDs: []string{"nonexistent1", "nonexistent2"}, - expectedCount: 0, - }, - { - name: "mixed existing and non-existing group IDs", - groupIDs: []string{"cfefqs706sqkneg59g4g", "nonexistent"}, - expectedCount: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - groups, err := store.GetGroupsByIDs(context.Background(), LockingStrengthNone, accountID, tt.groupIDs) - require.NoError(t, err) - require.Len(t, groups, tt.expectedCount) - }) - } -} - -func TestSqlStore_CreateGroup(t *testing.T) { - if os.Getenv("CI") == "true" { - t.Log("Skipping MySQL test on CI") - } - t.Setenv("NETBIRD_STORE_ENGINE", string(types.MysqlStoreEngine)) - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - group := &types.Group{ - ID: "group-id", - AccountID: accountID, - Issued: "api", - Peers: []string{}, - Resources: []types.Resource{}, - GroupPeers: []types.GroupPeer{}, - } - err = store.CreateGroup(context.Background(), group) - require.NoError(t, err) - - savedGroup, err := store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, "group-id") - require.NoError(t, err) - require.Equal(t, savedGroup, group) -} - -func TestSqlStore_CreateUpdateGroups(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - groups := []*types.Group{ - { - ID: "group-1", - AccountID: accountID, - Issued: "api", - Peers: []string{}, - Resources: []types.Resource{}, - GroupPeers: []types.GroupPeer{}, - }, - { - ID: "group-2", - AccountID: accountID, - Issued: "integration", - Peers: []string{}, - Resources: []types.Resource{}, - GroupPeers: []types.GroupPeer{}, - }, - } - err = store.CreateGroups(context.Background(), accountID, groups) - require.NoError(t, err) - - groups[1].Peers = []string{} - err = store.UpdateGroups(context.Background(), accountID, groups) - require.NoError(t, err) - - group, err := store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, groups[1].ID) - require.NoError(t, err) - require.Equal(t, groups[1], group) -} - -func TestSqlStore_DeleteGroup(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - tests := []struct { - name string - groupID string - expectError bool - }{ - { - name: "delete existing group", - groupID: "cfefqs706sqkneg59g4g", - expectError: false, - }, - { - name: "delete non-existing group", - groupID: "non-existing-group-id", - expectError: true, - }, - { - name: "delete with empty group ID", - groupID: "", - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := store.DeleteGroup(context.Background(), accountID, tt.groupID) - if tt.expectError { - require.Error(t, err) - sErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, sErr.Type(), status.NotFound) - } else { - require.NoError(t, err) - - group, err := store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, tt.groupID) - require.Error(t, err) - require.Nil(t, group) - } - }) - } -} - -func TestSqlStore_DeleteGroups(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - tests := []struct { - name string - groupIDs []string - expectError bool - }{ - { - name: "delete multiple existing groups", - groupIDs: []string{"cfefqs706sqkneg59g4g", "cfefqs706sqkneg59g3g"}, - expectError: false, - }, - { - name: "delete non-existing groups", - groupIDs: []string{"non-existing-id-1", "non-existing-id-2"}, - expectError: false, - }, - { - name: "delete with empty group IDs list", - groupIDs: []string{}, - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := store.DeleteGroups(context.Background(), accountID, tt.groupIDs) - if tt.expectError { - require.Error(t, err) - } else { - require.NoError(t, err) - - for _, groupID := range tt.groupIDs { - group, err := store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, groupID) - require.Error(t, err) - require.Nil(t, group) - } - } - }) - } -} - -func TestSqlStore_GetPeerByID(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_policy_migrate.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - tests := []struct { - name string - peerID string - expectError bool - }{ - { - name: "retrieve existing peer", - peerID: "cfefqs706sqkneg59g4g", - expectError: false, - }, - { - name: "retrieve non-existing peer", - peerID: "non-existing", - expectError: true, - }, - { - name: "retrieve with empty peer ID", - peerID: "", - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - peer, err := store.GetPeerByID(context.Background(), LockingStrengthNone, accountID, tt.peerID) - if tt.expectError { - require.Error(t, err) - sErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, sErr.Type(), status.NotFound) - require.Nil(t, peer) - } else { - require.NoError(t, err) - require.NotNil(t, peer) - require.Equal(t, tt.peerID, peer.ID) - } - }) - } -} - -func TestSqlStore_GetPeersByIDs(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_policy_migrate.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - tests := []struct { - name string - peerIDs []string - expectedCount int - }{ - { - name: "retrieve existing peers by existing IDs", - peerIDs: []string{"cfefqs706sqkneg59g4g", "cfeg6sf06sqkneg59g50"}, - expectedCount: 2, - }, - { - name: "empty peer IDs list", - peerIDs: []string{}, - expectedCount: 0, - }, - { - name: "non-existing peer IDs", - peerIDs: []string{"nonexistent1", "nonexistent2"}, - expectedCount: 0, - }, - { - name: "mixed existing and non-existing peer IDs", - peerIDs: []string{"cfeg6sf06sqkneg59g50", "nonexistent"}, - expectedCount: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - peers, err := store.GetPeersByIDs(context.Background(), LockingStrengthNone, accountID, tt.peerIDs) - require.NoError(t, err) - require.Len(t, peers, tt.expectedCount) - }) - } -} - -func TestSqlStore_GetPostureChecksByID(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - tests := []struct { - name string - postureChecksID string - expectError bool - }{ - { - name: "retrieve existing posture checks", - postureChecksID: "csplshq7qv948l48f7t0", - expectError: false, - }, - { - name: "retrieve non-existing posture checks", - postureChecksID: "non-existing", - expectError: true, - }, - { - name: "retrieve with empty posture checks ID", - postureChecksID: "", - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - postureChecks, err := store.GetPostureChecksByID(context.Background(), LockingStrengthNone, accountID, tt.postureChecksID) - if tt.expectError { - require.Error(t, err) - sErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, sErr.Type(), status.NotFound) - require.Nil(t, postureChecks) - } else { - require.NoError(t, err) - require.NotNil(t, postureChecks) - require.Equal(t, tt.postureChecksID, postureChecks.ID) - } - }) - } -} - -func TestSqlStore_GetPostureChecksByIDs(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - tests := []struct { - name string - postureCheckIDs []string - expectedCount int - }{ - { - name: "retrieve existing posture checks by existing IDs", - postureCheckIDs: []string{"csplshq7qv948l48f7t0", "cspnllq7qv95uq1r4k90"}, - expectedCount: 2, - }, - { - name: "empty posture check IDs list", - postureCheckIDs: []string{}, - expectedCount: 0, - }, - { - name: "non-existing posture check IDs", - postureCheckIDs: []string{"nonexistent1", "nonexistent2"}, - expectedCount: 0, - }, - { - name: "mixed existing and non-existing posture check IDs", - postureCheckIDs: []string{"cspnllq7qv95uq1r4k90", "nonexistent"}, - expectedCount: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - groups, err := store.GetPostureChecksByIDs(context.Background(), LockingStrengthNone, accountID, tt.postureCheckIDs) - require.NoError(t, err) - require.Len(t, groups, tt.expectedCount) - }) - } -} - -func TestSqlStore_SavePostureChecks(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - postureChecks := &posture.Checks{ - ID: "posture-checks-id", - AccountID: accountID, - Checks: posture.ChecksDefinition{ - NBVersionCheck: &posture.NBVersionCheck{ - MinVersion: "0.31.0", - }, - OSVersionCheck: &posture.OSVersionCheck{ - Ios: &posture.MinVersionCheck{ - MinVersion: "13.0.1", - }, - Linux: &posture.MinKernelVersionCheck{ - MinKernelVersion: "5.3.3-dev", - }, - }, - GeoLocationCheck: &posture.GeoLocationCheck{ - Locations: []posture.Location{ - { - CountryCode: "DE", - CityName: "Berlin", - }, - }, - Action: posture.CheckActionAllow, - }, - }, - } - err = store.SavePostureChecks(context.Background(), postureChecks) - require.NoError(t, err) - - savePostureChecks, err := store.GetPostureChecksByID(context.Background(), LockingStrengthNone, accountID, "posture-checks-id") - require.NoError(t, err) - require.Equal(t, savePostureChecks, postureChecks) -} - -func TestSqlStore_DeletePostureChecks(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - tests := []struct { - name string - postureChecksID string - expectError bool - }{ - { - name: "delete existing posture checks", - postureChecksID: "csplshq7qv948l48f7t0", - expectError: false, - }, - { - name: "delete non-existing posture checks", - postureChecksID: "non-existing-posture-checks-id", - expectError: true, - }, - { - name: "delete with empty posture checks ID", - postureChecksID: "", - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err = store.DeletePostureChecks(context.Background(), accountID, tt.postureChecksID) - if tt.expectError { - require.Error(t, err) - sErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, sErr.Type(), status.NotFound) - } else { - require.NoError(t, err) - group, err := store.GetPostureChecksByID(context.Background(), LockingStrengthNone, accountID, tt.postureChecksID) - require.Error(t, err) - require.Nil(t, group) - } - }) - } -} - -func TestSqlStore_GetPolicyByID(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - tests := []struct { - name string - policyID string - expectError bool - }{ - { - name: "retrieve existing policy", - policyID: "cs1tnh0hhcjnqoiuebf0", - expectError: false, - }, - { - name: "retrieve non-existing policy checks", - policyID: "non-existing", - expectError: true, - }, - { - name: "retrieve with empty policy ID", - policyID: "", - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - policy, err := store.GetPolicyByID(context.Background(), LockingStrengthNone, accountID, tt.policyID) - if tt.expectError { - require.Error(t, err) - sErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, sErr.Type(), status.NotFound) - require.Nil(t, policy) - } else { - require.NoError(t, err) - require.NotNil(t, policy) - require.Equal(t, tt.policyID, policy.ID) - } - }) - } -} - -func TestSqlStore_GetPolicyByIDOrPublicID(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - policyID := "cs1tnh0hhcjnqoiuebf0" - - policy, err := store.GetPolicyByID(context.Background(), LockingStrengthNone, accountID, policyID) - require.NoError(t, err) - require.NotEmpty(t, policy.PublicID) - - for _, id := range []string{policyID, policy.PublicID} { - policy, err := store.GetPolicyByIDOrPublicID(context.Background(), LockingStrengthNone, accountID, id) - require.NoError(t, err) - require.Equal(t, policyID, policy.ID) - } - - policy, err = store.GetPolicyByIDOrPublicID(context.Background(), LockingStrengthNone, accountID, "non-existing") - require.Error(t, err) - sErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, sErr.Type(), status.NotFound) - require.Nil(t, policy) -} - -func TestSqlStore_CreatePolicy(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - policy := &types.Policy{ - ID: "policy-id", - AccountID: accountID, - Enabled: true, - Rules: []*types.PolicyRule{ - { - Enabled: true, - Sources: []string{"groupA"}, - Destinations: []string{"groupC"}, - Bidirectional: true, - Action: types.PolicyTrafficActionAccept, - }, - }, - } - err = store.CreatePolicy(context.Background(), policy) - require.NoError(t, err) - - savePolicy, err := store.GetPolicyByID(context.Background(), LockingStrengthNone, accountID, policy.ID) - require.NoError(t, err) - require.Equal(t, savePolicy, policy) - -} - -func TestSqlStore_SavePolicy(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - policyID := "cs1tnh0hhcjnqoiuebf0" - - policy, err := store.GetPolicyByID(context.Background(), LockingStrengthNone, accountID, policyID) - require.NoError(t, err) - - policy.Enabled = false - policy.Description = "policy" - policy.Rules[0].Sources = []string{"group"} - policy.Rules[0].Ports = []string{"80", "443"} - err = store.SavePolicy(context.Background(), policy) - require.NoError(t, err) - - savePolicy, err := store.GetPolicyByID(context.Background(), LockingStrengthNone, accountID, policy.ID) - require.NoError(t, err) - require.Equal(t, savePolicy, policy) -} - -func TestSqlStore_DeletePolicy(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - policyID := "cs1tnh0hhcjnqoiuebf0" - - err = store.DeletePolicy(context.Background(), accountID, policyID) - require.NoError(t, err) - - policy, err := store.GetPolicyByID(context.Background(), LockingStrengthNone, accountID, policyID) - require.Error(t, err) - require.Nil(t, policy) -} - -func TestSqlStore_GetDNSSettings(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - tests := []struct { - name string - accountID string - expectError bool - }{ - { - name: "retrieve existing account dns settings", - accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", - expectError: false, - }, - { - name: "retrieve non-existing account dns settings", - accountID: "non-existing", - expectError: true, - }, - { - name: "retrieve dns settings with empty account ID", - accountID: "", - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - dnsSettings, err := store.GetAccountDNSSettings(context.Background(), LockingStrengthNone, tt.accountID) - if tt.expectError { - require.Error(t, err) - sErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, sErr.Type(), status.NotFound) - require.Nil(t, dnsSettings) - } else { - require.NoError(t, err) - require.NotNil(t, dnsSettings) - } - }) - } -} - -func TestSqlStore_SaveDNSSettings(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - dnsSettings, err := store.GetAccountDNSSettings(context.Background(), LockingStrengthNone, accountID) - require.NoError(t, err) - - dnsSettings.DisabledManagementGroups = []string{"groupA", "groupB"} - err = store.SaveDNSSettings(context.Background(), accountID, dnsSettings) - require.NoError(t, err) - - saveDNSSettings, err := store.GetAccountDNSSettings(context.Background(), LockingStrengthNone, accountID) - require.NoError(t, err) - require.Equal(t, saveDNSSettings, dnsSettings) -} - -func TestSqlStore_GetAccountNameServerGroups(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - tests := []struct { - name string - accountID string - expectedCount int - }{ - { - name: "retrieve name server groups by existing account ID", - accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", - expectedCount: 1, - }, - { - name: "non-existing account ID", - accountID: "nonexistent", - expectedCount: 0, - }, - { - name: "empty account ID", - accountID: "", - expectedCount: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - peers, err := store.GetAccountNameServerGroups(context.Background(), LockingStrengthNone, tt.accountID) - require.NoError(t, err) - require.Len(t, peers, tt.expectedCount) - }) - } - -} - -func TestSqlStore_GetNameServerByID(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - tests := []struct { - name string - nsGroupID string - expectError bool - }{ - { - name: "retrieve existing nameserver group", - nsGroupID: "csqdelq7qv97ncu7d9t0", - expectError: false, - }, - { - name: "retrieve non-existing nameserver group", - nsGroupID: "non-existing", - expectError: true, - }, - { - name: "retrieve with empty nameserver group ID", - nsGroupID: "", - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - nsGroup, err := store.GetNameServerGroupByID(context.Background(), LockingStrengthNone, accountID, tt.nsGroupID) - if tt.expectError { - require.Error(t, err) - sErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, sErr.Type(), status.NotFound) - require.Nil(t, nsGroup) - } else { - require.NoError(t, err) - require.NotNil(t, nsGroup) - require.Equal(t, tt.nsGroupID, nsGroup.ID) - } - }) - } -} - -func TestSqlStore_SaveNameServerGroup(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - nsGroup := &nbdns.NameServerGroup{ - ID: "ns-group-id", - AccountID: accountID, - Name: "NS Group", - NameServers: []nbdns.NameServer{ - { - IP: netip.MustParseAddr("8.8.8.8"), - NSType: 1, - Port: 53, - }, - }, - Groups: []string{"groupA"}, - Primary: true, - Enabled: true, - SearchDomainsEnabled: false, - } - - err = store.SaveNameServerGroup(context.Background(), nsGroup) - require.NoError(t, err) - - saveNSGroup, err := store.GetNameServerGroupByID(context.Background(), LockingStrengthNone, accountID, nsGroup.ID) - require.NoError(t, err) - require.Equal(t, saveNSGroup, nsGroup) -} - -func TestSqlStore_DeleteNameServerGroup(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - nsGroupID := "csqdelq7qv97ncu7d9t0" - - err = store.DeleteNameServerGroup(context.Background(), accountID, nsGroupID) - require.NoError(t, err) - - nsGroup, err := store.GetNameServerGroupByID(context.Background(), LockingStrengthNone, accountID, nsGroupID) - require.Error(t, err) - require.Nil(t, nsGroup) -} - // newAccountWithId creates a new Account with a default SetupKey (doesn't store in a Store) and provided id func newAccountWithId(ctx context.Context, accountID, userID, domain string) *types.Account { log.WithContext(ctx).Debugf("creating new account") @@ -2311,831 +263,6 @@ func newAccountWithId(ctx context.Context, accountID, userID, domain string) *ty return acc } -func TestSqlStore_GetAccountNetworks(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - tests := []struct { - name string - accountID string - expectedCount int - }{ - { - name: "retrieve networks by existing account ID", - accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", - expectedCount: 1, - }, - - { - name: "retrieve networks by non-existing account ID", - accountID: "non-existent", - expectedCount: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - networks, err := store.GetAccountNetworks(context.Background(), LockingStrengthNone, tt.accountID) - require.NoError(t, err) - require.Len(t, networks, tt.expectedCount) - }) - } -} - -func TestSqlStore_GetNetworkByID(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - tests := []struct { - name string - networkID string - expectError bool - }{ - { - name: "retrieve existing network ID", - networkID: "ct286bi7qv930dsrrug0", - expectError: false, - }, - { - name: "retrieve non-existing network ID", - networkID: "non-existing", - expectError: true, - }, - { - name: "retrieve network with empty ID", - networkID: "", - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - network, err := store.GetNetworkByID(context.Background(), LockingStrengthNone, accountID, tt.networkID) - if tt.expectError { - require.Error(t, err) - sErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, sErr.Type(), status.NotFound) - require.Nil(t, network) - } else { - require.NoError(t, err) - require.NotNil(t, network) - require.Equal(t, tt.networkID, network.ID) - } - }) - } -} - -func TestSqlStore_SaveNetwork(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - network := &networkTypes.Network{ - ID: "net-id", - AccountID: accountID, - Name: "net", - } - - err = store.SaveNetwork(context.Background(), network) - require.NoError(t, err) - - savedNet, err := store.GetNetworkByID(context.Background(), LockingStrengthNone, accountID, network.ID) - require.NoError(t, err) - require.Equal(t, network, savedNet) -} - -func TestSqlStore_DeleteNetwork(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - networkID := "ct286bi7qv930dsrrug0" - - err = store.DeleteNetwork(context.Background(), accountID, networkID) - require.NoError(t, err) - - network, err := store.GetNetworkByID(context.Background(), LockingStrengthNone, accountID, networkID) - require.Error(t, err) - sErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, status.NotFound, sErr.Type()) - require.Nil(t, network) -} - -func TestSqlStore_GetNetworkRoutersByNetID(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - tests := []struct { - name string - networkID string - expectedCount int - }{ - { - name: "retrieve routers by existing network ID", - networkID: "ct286bi7qv930dsrrug0", - expectedCount: 1, - }, - { - name: "retrieve routers by non-existing network ID", - networkID: "non-existent", - expectedCount: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - routers, err := store.GetNetworkRoutersByNetID(context.Background(), LockingStrengthNone, accountID, tt.networkID) - require.NoError(t, err) - require.Len(t, routers, tt.expectedCount) - }) - } -} - -func TestSqlStore_GetNetworkRouterByID(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - tests := []struct { - name string - networkRouterID string - expectError bool - }{ - { - name: "retrieve existing network router ID", - networkRouterID: "ctc20ji7qv9ck2sebc80", - expectError: false, - }, - { - name: "retrieve non-existing network router ID", - networkRouterID: "non-existing", - expectError: true, - }, - { - name: "retrieve network with empty router ID", - networkRouterID: "", - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - networkRouter, err := store.GetNetworkRouterByID(context.Background(), LockingStrengthNone, accountID, tt.networkRouterID) - if tt.expectError { - require.Error(t, err) - sErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, sErr.Type(), status.NotFound) - require.Nil(t, networkRouter) - } else { - require.NoError(t, err) - require.NotNil(t, networkRouter) - require.Equal(t, tt.networkRouterID, networkRouter.ID) - } - }) - } -} - -func TestSqlStore_CreateNetworkRouter(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - networkID := "ct286bi7qv930dsrrug0" - - netRouter, err := routerTypes.NewNetworkRouter(accountID, networkID, "", []string{"net-router-grp"}, true, 0, true) - require.NoError(t, err) - - err = store.CreateNetworkRouter(context.Background(), netRouter) - require.NoError(t, err) - - savedNetRouter, err := store.GetNetworkRouterByID(context.Background(), LockingStrengthNone, accountID, netRouter.ID) - require.NoError(t, err) - require.Equal(t, netRouter, savedNetRouter) -} - -func TestSqlStore_UpdateNetworkRouter(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - networkID := "ct286bi7qv930dsrrug0" - routerID := "ctc20ji7qv9ck2sebc80" - - netRouter := &routerTypes.NetworkRouter{ - ID: routerID, - AccountID: accountID, - NetworkID: networkID, - Peer: "", - PeerGroups: []string{"net-router-grp"}, - Masquerade: true, - Metric: 42, - Enabled: true, - } - - err = store.UpdateNetworkRouter(context.Background(), netRouter) - require.NoError(t, err) - - savedNetRouter, err := store.GetNetworkRouterByID(context.Background(), LockingStrengthNone, accountID, routerID) - require.NoError(t, err) - require.Equal(t, netRouter, savedNetRouter) - - // Updating a router under a different account must not match any row. - netRouter.AccountID = "non-existent-account" - err = store.UpdateNetworkRouter(context.Background(), netRouter) - require.Error(t, err) -} - -func TestSqlStore_DeleteNetworkRouter(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - netRouterID := "ctc20ji7qv9ck2sebc80" - - err = store.DeleteNetworkRouter(context.Background(), accountID, netRouterID) - require.NoError(t, err) - - netRouter, err := store.GetNetworkByID(context.Background(), LockingStrengthNone, accountID, netRouterID) - require.Error(t, err) - sErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, status.NotFound, sErr.Type()) - require.Nil(t, netRouter) -} - -func TestSqlStore_GetNetworkResourcesByNetID(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - tests := []struct { - name string - networkID string - expectedCount int - }{ - { - name: "retrieve resources by existing network ID", - networkID: "ct286bi7qv930dsrrug0", - expectedCount: 1, - }, - { - name: "retrieve resources by non-existing network ID", - networkID: "non-existent", - expectedCount: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - netResources, err := store.GetNetworkResourcesByNetID(context.Background(), LockingStrengthNone, accountID, tt.networkID) - require.NoError(t, err) - require.Len(t, netResources, tt.expectedCount) - }) - } -} - -func TestSqlStore_GetNetworkResourceByID(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - tests := []struct { - name string - netResourceID string - expectError bool - }{ - { - name: "retrieve existing network resource ID", - netResourceID: "ctc4nci7qv9061u6ilfg", - expectError: false, - }, - { - name: "retrieve non-existing network resource ID", - netResourceID: "non-existing", - expectError: true, - }, - { - name: "retrieve network with empty resource ID", - netResourceID: "", - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - netResource, err := store.GetNetworkResourceByID(context.Background(), LockingStrengthNone, accountID, tt.netResourceID) - if tt.expectError { - require.Error(t, err) - sErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, sErr.Type(), status.NotFound) - require.Nil(t, netResource) - } else { - require.NoError(t, err) - require.NotNil(t, netResource) - require.Equal(t, tt.netResourceID, netResource.ID) - } - }) - } -} - -func TestSqlStore_GetNetworkResourceByIDOrPublicID(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - netResourceID := "ctc4nci7qv9061u6ilfg" - - netResource, err := store.GetNetworkResourceByID(context.Background(), LockingStrengthNone, accountID, netResourceID) - require.NoError(t, err) - require.NotEmpty(t, netResource.PublicID) - - for _, id := range []string{netResourceID, netResource.PublicID} { - netResource, err := store.GetNetworkResourceByIDOrPublicID(context.Background(), LockingStrengthNone, accountID, id) - require.NoError(t, err) - require.Equal(t, netResourceID, netResource.ID) - } - - netResource, err = store.GetNetworkResourceByIDOrPublicID(context.Background(), LockingStrengthNone, accountID, "non-existing") - require.Error(t, err) - sErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, sErr.Type(), status.NotFound) - require.Nil(t, netResource) -} - -func TestSqlStore_SaveNetworkResource(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - networkID := "ct286bi7qv930dsrrug0" - - netResource, err := resourceTypes.NewNetworkResource(accountID, networkID, "resource-name", "", "example.com", []string{}, true) - require.NoError(t, err) - - err = store.SaveNetworkResource(context.Background(), netResource) - require.NoError(t, err) - - savedNetResource, err := store.GetNetworkResourceByID(context.Background(), LockingStrengthNone, accountID, netResource.ID) - require.NoError(t, err) - require.Equal(t, netResource.ID, savedNetResource.ID) - require.Equal(t, netResource.Name, savedNetResource.Name) - require.Equal(t, netResource.NetworkID, savedNetResource.NetworkID) - require.Equal(t, netResource.Type, resourceTypes.NetworkResourceType("domain")) - require.Equal(t, netResource.Domain, "example.com") - require.Equal(t, netResource.AccountID, savedNetResource.AccountID) - require.Equal(t, netResource.Prefix, netip.Prefix{}) -} - -func TestSqlStore_DeleteNetworkResource(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - netResourceID := "ctc4nci7qv9061u6ilfg" - - err = store.DeleteNetworkResource(context.Background(), accountID, netResourceID) - require.NoError(t, err) - - netResource, err := store.GetNetworkByID(context.Background(), LockingStrengthNone, accountID, netResourceID) - require.Error(t, err) - sErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, status.NotFound, sErr.Type()) - require.Nil(t, netResource) -} - -func TestSqlStore_AddAndRemoveResourceFromGroup(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - require.NoError(t, err) - t.Cleanup(cleanup) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - resourceId := "ctc4nci7qv9061u6ilfg" - groupID := "cs1tnh0hhcjnqoiuebeg" - - res := &types.Resource{ - ID: resourceId, - Type: "host", - } - err = store.AddResourceToGroup(context.Background(), accountID, groupID, res) - require.NoError(t, err) - - group, err := store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, groupID) - require.NoError(t, err) - require.Contains(t, group.Resources, *res) - - groups, err := store.GetResourceGroups(context.Background(), LockingStrengthNone, accountID, resourceId) - require.NoError(t, err) - require.Len(t, groups, 1) - - err = store.RemoveResourceFromGroup(context.Background(), accountID, groupID, res.ID) - require.NoError(t, err) - - group, err = store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, groupID) - require.NoError(t, err) - require.NotContains(t, group.Resources, *res) -} - -func TestSqlStore_AddPeerToGroup(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_policy_migrate.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - peerID := "cfefqs706sqkneg59g4g" - groupID := "cfefqs706sqkneg59g4h" - - group, err := store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, groupID) - require.NoError(t, err, "failed to get group") - require.Len(t, group.Peers, 0, "group should have 0 peers") - - err = store.AddPeerToGroup(context.Background(), accountID, peerID, groupID) - require.NoError(t, err, "failed to add peer to group") - - group, err = store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, groupID) - require.NoError(t, err, "failed to get group") - require.Len(t, group.Peers, 1, "group should have 1 peers") - require.Contains(t, group.Peers, peerID) -} - -func TestSqlStore_AddPeerToAllGroup(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_policy_migrate.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - groupID := "cfefqs706sqkneg59g3g" - - peer := &nbpeer.Peer{ - ID: "peer1", - AccountID: accountID, - DNSLabel: "peer1.domain.test", - } - - group, err := store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, groupID) - require.NoError(t, err, "failed to get group") - require.Len(t, group.Peers, 2, "group should have 2 peers") - require.NotContains(t, group.Peers, peer.ID) - - err = store.AddPeerToAccount(context.Background(), peer) - require.NoError(t, err, "failed to add peer to account") - - err = store.AddPeerToAllGroup(context.Background(), accountID, peer.ID) - require.NoError(t, err, "failed to add peer to all group") - - group, err = store.GetGroupByID(context.Background(), LockingStrengthNone, accountID, groupID) - require.NoError(t, err, "failed to get group") - require.Len(t, group.Peers, 3, "group should have peers") - require.Contains(t, group.Peers, peer.ID) -} - -func TestSqlStore_AddPeerToAccount(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_policy_migrate.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - peer := &nbpeer.Peer{ - ID: "peer1", - AccountID: accountID, - Key: "key", - IP: netip.AddrFrom4([4]byte{1, 1, 1, 1}), - IPv6: netip.MustParseAddr("fd00::1:1:1:1"), - Meta: nbpeer.PeerSystemMeta{ - Hostname: "hostname", - GoOS: "linux", - Kernel: "Linux", - Core: "21.04", - Platform: "x86_64", - OS: "Ubuntu", - WtVersion: "development", - UIVersion: "development", - }, - Name: "peer.test", - DNSLabel: "peer", - Status: &nbpeer.PeerStatus{ - LastSeen: time.Now().UTC(), - Connected: true, - LoginExpired: false, - RequiresApproval: false, - }, - SSHKey: "ssh-key", - SSHEnabled: false, - LoginExpirationEnabled: true, - InactivityExpirationEnabled: false, - LastLogin: util.ToPtr(time.Now().UTC()), - CreatedAt: time.Now().UTC(), - Ephemeral: true, - } - err = store.AddPeerToAccount(context.Background(), peer) - require.NoError(t, err, "failed to add peer to account") - - storedPeer, err := store.GetPeerByID(context.Background(), LockingStrengthNone, accountID, peer.ID) - require.NoError(t, err, "failed to get peer") - - assert.Equal(t, peer.ID, storedPeer.ID) - assert.Equal(t, peer.AccountID, storedPeer.AccountID) - assert.Equal(t, peer.Key, storedPeer.Key) - assert.Equal(t, peer.IP.String(), storedPeer.IP.String()) - assert.Equal(t, peer.Meta, storedPeer.Meta) - assert.Equal(t, peer.Name, storedPeer.Name) - assert.Equal(t, peer.DNSLabel, storedPeer.DNSLabel) - assert.Equal(t, peer.SSHKey, storedPeer.SSHKey) - assert.Equal(t, peer.SSHEnabled, storedPeer.SSHEnabled) - assert.Equal(t, peer.LoginExpirationEnabled, storedPeer.LoginExpirationEnabled) - assert.Equal(t, peer.InactivityExpirationEnabled, storedPeer.InactivityExpirationEnabled) - assert.WithinDurationf(t, peer.GetLastLogin(), storedPeer.GetLastLogin().UTC(), time.Millisecond, "LastLogin should be equal") - assert.WithinDurationf(t, peer.CreatedAt, storedPeer.CreatedAt.UTC(), time.Millisecond, "CreatedAt should be equal") - assert.Equal(t, peer.Ephemeral, storedPeer.Ephemeral) - assert.Equal(t, peer.Status.Connected, storedPeer.Status.Connected) - assert.Equal(t, peer.Status.LoginExpired, storedPeer.Status.LoginExpired) - assert.Equal(t, peer.Status.RequiresApproval, storedPeer.Status.RequiresApproval) - assert.WithinDurationf(t, peer.Status.LastSeen, storedPeer.Status.LastSeen.UTC(), time.Millisecond, "LastSeen should be equal") -} - -func TestSqlStore_GetPeerGroups(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_policy_migrate.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - peerID := "cfefqs706sqkneg59g4g" - - groups, err := store.GetPeerGroups(context.Background(), LockingStrengthNone, accountID, peerID) - require.NoError(t, err) - assert.Len(t, groups, 1) - assert.Equal(t, groups[0].Name, "All") - - err = store.AddPeerToGroup(context.Background(), accountID, peerID, "cfefqs706sqkneg59g4h") - require.NoError(t, err) - - groups, err = store.GetPeerGroups(context.Background(), LockingStrengthNone, accountID, peerID) - require.NoError(t, err) - assert.Len(t, groups, 2) - - foreignPeerID := "foreign-peer" - err = store.AddPeerToGroup(context.Background(), accountID, foreignPeerID, "cfefqs706sqkneg59g4h") - require.NoError(t, err) - - groups, err = store.GetPeerGroups(context.Background(), LockingStrengthNone, "other-account", foreignPeerID) - require.NoError(t, err) - assert.Empty(t, groups, "groups of another account must not be returned") -} - -func TestSqlStore_GetAccountPeers(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_with_expired_peers.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - tests := []struct { - name string - accountID string - nameFilter string - ipFilter string - expectedCount int - }{ - { - name: "should retrieve peers for an existing account ID", - accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", - expectedCount: 5, - }, - { - name: "should return no peers for a non-existing account ID", - accountID: "nonexistent", - expectedCount: 0, - }, - { - name: "should return no peers for an empty account ID", - accountID: "", - expectedCount: 0, - }, - { - name: "should filter peers by name", - accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", - nameFilter: "expiredhost", - expectedCount: 1, - }, - { - name: "should filter peers by partial name", - accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", - nameFilter: "host", - expectedCount: 4, - }, - { - name: "should filter peers by ip", - accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", - ipFilter: "100.64.39.54", - expectedCount: 1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - peers, err := store.GetAccountPeers(context.Background(), LockingStrengthNone, tt.accountID, tt.nameFilter, tt.ipFilter) - require.NoError(t, err) - require.Len(t, peers, tt.expectedCount) - }) - } - -} - -func TestSqlStore_GetAccountPeersWithExpiration(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_with_expired_peers.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - tests := []struct { - name string - accountID string - expectedCount int - expectedPeerIDs []string - }{ - { - name: "should retrieve only non-expired peers with expiration enabled", - accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", - expectedCount: 1, - expectedPeerIDs: []string{"notexpired01"}, - }, - { - name: "should return no peers with expiration for a non-existing account ID", - accountID: "nonexistent", - expectedCount: 0, - }, - { - name: "should return no peers with expiration for a empty account ID", - accountID: "", - expectedCount: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - peers, err := store.GetAccountPeersWithExpiration(context.Background(), LockingStrengthNone, tt.accountID) - require.NoError(t, err) - require.Len(t, peers, tt.expectedCount) - for i, peer := range peers { - assert.Equal(t, tt.expectedPeerIDs[i], peer.ID) - } - }) - } -} - -func TestSqlStore_GetAccountPeersWithExpiration_ExcludesAlreadyExpired(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_with_expired_peers.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - peers, err := store.GetAccountPeersWithExpiration(context.Background(), LockingStrengthNone, accountID) - require.NoError(t, err) - - // Verify the already-expired peer (cg05lnblo1hkg2j514p0) is not returned - for _, peer := range peers { - assert.NotEqual(t, "cg05lnblo1hkg2j514p0", peer.ID, "already expired peer should not be returned") - assert.False(t, peer.Status.LoginExpired, "returned peers should not have LoginExpired set") - } -} - -func TestSqlStore_GetAccountPeersWithInactivity(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_with_expired_peers.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - tests := []struct { - name string - accountID string - expectedCount int - }{ - { - name: "should retrieve peers with inactivity for an existing account ID", - accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", - expectedCount: 1, - }, - { - name: "should return no peers with inactivity for a non-existing account ID", - accountID: "nonexistent", - expectedCount: 0, - }, - { - name: "should return no peers with inactivity for an empty account ID", - accountID: "", - expectedCount: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - peers, err := store.GetAccountPeersWithInactivity(context.Background(), LockingStrengthNone, tt.accountID) - require.NoError(t, err) - require.Len(t, peers, tt.expectedCount) - }) - } -} - -func TestSqlStore_GetAllEphemeralPeers(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/storev1.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - peers, err := store.GetAllEphemeralPeers(context.Background(), LockingStrengthNone) - require.NoError(t, err) - require.Len(t, peers, 1) - require.True(t, peers[0].Ephemeral) -} - -func TestSqlStore_GetUserPeers(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_with_expired_peers.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - tests := []struct { - name string - accountID string - userID string - expectedCount int - }{ - { - name: "should retrieve peers for existing account ID and user ID", - accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", - userID: "f4f6d672-63fb-11ec-90d6-0242ac120003", - expectedCount: 1, - }, - { - name: "should return no peers for non-existing account ID with existing user ID", - accountID: "nonexistent", - userID: "f4f6d672-63fb-11ec-90d6-0242ac120003", - expectedCount: 0, - }, - { - name: "should return no peers for non-existing user ID with existing account ID", - accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", - userID: "nonexistent_user", - expectedCount: 0, - }, - { - name: "should retrieve peers for another valid account ID and user ID", - accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", - userID: "edafee4e-63fb-11ec-90d6-0242ac120003", - expectedCount: 3, - }, - { - name: "should return no peers for existing account ID with empty user ID", - accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", - userID: "", - expectedCount: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - peers, err := store.GetUserPeers(context.Background(), LockingStrengthNone, tt.accountID, tt.userID) - require.NoError(t, err) - require.Len(t, peers, tt.expectedCount) - }) - } -} - -func TestSqlStore_DeletePeer(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_with_expired_peers.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - peerID := "csrnkiq7qv9d8aitqd50" - - err = store.DeletePeer(context.Background(), accountID, peerID) - require.NoError(t, err) - - peer, err := store.GetPeerByID(context.Background(), LockingStrengthNone, accountID, peerID) - require.Error(t, err) - require.Nil(t, peer) -} - func TestSqlStore_DatabaseBlocking(t *testing.T) { store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_with_expired_peers.sql", t.TempDir()) t.Cleanup(cleanup) @@ -3206,1100 +333,6 @@ func TestSqlStore_DatabaseBlocking(t *testing.T) { t.Logf("Test completed") } -func TestSqlStore_GetAccountCreatedBy(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - tests := []struct { - name string - accountID string - expectError bool - createdBy string - }{ - { - name: "existing account ID", - accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", - expectError: false, - createdBy: "edafee4e-63fb-11ec-90d6-0242ac120003", - }, - { - name: "non-existing account ID", - accountID: "nonexistent", - expectError: true, - }, - { - name: "empty account ID", - accountID: "", - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - createdBy, err := store.GetAccountCreatedBy(context.Background(), LockingStrengthNone, tt.accountID) - if tt.expectError { - require.Error(t, err) - sErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, sErr.Type(), status.NotFound) - require.Empty(t, createdBy) - } else { - require.NoError(t, err) - require.NotNil(t, createdBy) - require.Equal(t, tt.createdBy, createdBy) - } - }) - } - -} - -func TestSqlStore_GetUserByUserID(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - tests := []struct { - name string - userID string - expectError bool - }{ - { - name: "retrieve existing user", - userID: "edafee4e-63fb-11ec-90d6-0242ac120003", - expectError: false, - }, - { - name: "retrieve non-existing user", - userID: "non-existing", - expectError: true, - }, - { - name: "retrieve with empty user ID", - userID: "", - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - user, err := store.GetUserByUserID(context.Background(), LockingStrengthNone, tt.userID) - if tt.expectError { - require.Error(t, err) - sErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, sErr.Type(), status.NotFound) - require.Nil(t, user) - } else { - require.NoError(t, err) - require.NotNil(t, user) - require.Equal(t, tt.userID, user.Id) - } - }) - } -} - -func TestSqlStore_GetUserByPATID(t *testing.T) { - store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanUp) - assert.NoError(t, err) - - id := "9dj38s35-63fb-11ec-90d6-0242ac120003" - - user, err := store.GetUserByPATID(context.Background(), LockingStrengthNone, id) - require.NoError(t, err) - require.Equal(t, "f4f6d672-63fb-11ec-90d6-0242ac120003", user.Id) -} - -func TestSqlStore_SaveUser(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - user := &types.User{ - Id: "user-id", - AccountID: accountID, - Role: types.UserRoleAdmin, - IsServiceUser: false, - AutoGroups: []string{"groupA", "groupB"}, - Blocked: false, - LastLogin: util.ToPtr(time.Now().UTC()), - CreatedAt: time.Now().UTC().Add(-time.Hour), - Issued: types.UserIssuedIntegration, - } - err = store.SaveUser(context.Background(), user) - require.NoError(t, err) - - saveUser, err := store.GetUserByUserID(context.Background(), LockingStrengthNone, user.Id) - require.NoError(t, err) - require.Equal(t, user.Id, saveUser.Id) - require.Equal(t, user.AccountID, saveUser.AccountID) - require.Equal(t, user.Role, saveUser.Role) - require.Equal(t, user.AutoGroups, saveUser.AutoGroups) - require.WithinDurationf(t, user.GetLastLogin(), saveUser.LastLogin.UTC(), time.Millisecond, "LastLogin should be equal") - require.WithinDurationf(t, user.CreatedAt, saveUser.CreatedAt.UTC(), time.Millisecond, "CreatedAt should be equal") - require.Equal(t, user.Issued, saveUser.Issued) - require.Equal(t, user.Blocked, saveUser.Blocked) - require.Equal(t, user.IsServiceUser, saveUser.IsServiceUser) -} - -func TestSqlStore_SaveUsers(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - accountUsers, err := store.GetAccountUsers(context.Background(), LockingStrengthNone, accountID) - require.NoError(t, err) - require.Len(t, accountUsers, 2) - - users := []*types.User{ - { - Id: "user-1", - AccountID: accountID, - Issued: "api", - AutoGroups: []string{"groupA", "groupB"}, - }, - { - Id: "user-2", - AccountID: accountID, - Issued: "integration", - AutoGroups: []string{"groupA"}, - }, - } - err = store.SaveUsers(context.Background(), users) - require.NoError(t, err) - - accountUsers, err = store.GetAccountUsers(context.Background(), LockingStrengthNone, accountID) - require.NoError(t, err) - require.Len(t, accountUsers, 4) - - users[1].AutoGroups = []string{"groupA", "groupC"} - err = store.SaveUsers(context.Background(), users) - require.NoError(t, err) - - user, err := store.GetUserByUserID(context.Background(), LockingStrengthNone, users[1].Id) - require.NoError(t, err) - require.Equal(t, users[1].AutoGroups, user.AutoGroups) -} - -func TestSqlStore_SaveUserWithEncryption(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - // Enable encryption - key, err := crypt.GenerateKey() - require.NoError(t, err) - fieldEncrypt, err := crypt.NewFieldEncrypt(key) - require.NoError(t, err) - store.SetFieldEncrypt(fieldEncrypt) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - // rawUser is used to read raw (potentially encrypted) data from the database - // without any gorm hooks or automatic decryption - type rawUser struct { - Id string - Email string - Name string - } - - t.Run("save user with empty email and name", func(t *testing.T) { - user := &types.User{ - Id: "user-empty-fields", - AccountID: accountID, - Role: types.UserRoleUser, - Email: "", - Name: "", - AutoGroups: []string{"groupA"}, - } - err = store.SaveUser(context.Background(), user) - require.NoError(t, err) - - // Verify using direct database query that empty strings remain empty (not encrypted) - var raw rawUser - err = store.(*SqlStore).db.Table("users").Select("id, email, name").Where("id = ?", user.Id).First(&raw).Error - require.NoError(t, err) - require.Equal(t, "", raw.Email, "empty email should remain empty in database") - require.Equal(t, "", raw.Name, "empty name should remain empty in database") - - // Verify manual decryption returns empty strings - decryptedEmail, err := fieldEncrypt.Decrypt(raw.Email) - require.NoError(t, err) - require.Equal(t, "", decryptedEmail) - - decryptedName, err := fieldEncrypt.Decrypt(raw.Name) - require.NoError(t, err) - require.Equal(t, "", decryptedName) - }) - - t.Run("save user with email and name", func(t *testing.T) { - user := &types.User{ - Id: "user-with-fields", - AccountID: accountID, - Role: types.UserRoleAdmin, - Email: "test@example.com", - Name: "Test User", - AutoGroups: []string{"groupB"}, - } - err = store.SaveUser(context.Background(), user) - require.NoError(t, err) - - // Verify using direct database query that the data is encrypted (not plaintext) - var raw rawUser - err = store.(*SqlStore).db.Table("users").Select("id, email, name").Where("id = ?", user.Id).First(&raw).Error - require.NoError(t, err) - require.NotEqual(t, "test@example.com", raw.Email, "email should be encrypted in database") - require.NotEqual(t, "Test User", raw.Name, "name should be encrypted in database") - - // Verify manual decryption returns correct values - decryptedEmail, err := fieldEncrypt.Decrypt(raw.Email) - require.NoError(t, err) - require.Equal(t, "test@example.com", decryptedEmail) - - decryptedName, err := fieldEncrypt.Decrypt(raw.Name) - require.NoError(t, err) - require.Equal(t, "Test User", decryptedName) - }) - - t.Run("save multiple users with mixed fields", func(t *testing.T) { - users := []*types.User{ - { - Id: "batch-user-1", - AccountID: accountID, - Email: "", - Name: "", - }, - { - Id: "batch-user-2", - AccountID: accountID, - Email: "batch@example.com", - Name: "Batch User", - }, - } - err = store.SaveUsers(context.Background(), users) - require.NoError(t, err) - - // Verify first user (empty fields) using direct database query - var raw1 rawUser - err = store.(*SqlStore).db.Table("users").Select("id, email, name").Where("id = ?", "batch-user-1").First(&raw1).Error - require.NoError(t, err) - require.Equal(t, "", raw1.Email, "empty email should remain empty in database") - require.Equal(t, "", raw1.Name, "empty name should remain empty in database") - - // Verify second user (with fields) using direct database query - var raw2 rawUser - err = store.(*SqlStore).db.Table("users").Select("id, email, name").Where("id = ?", "batch-user-2").First(&raw2).Error - require.NoError(t, err) - require.NotEqual(t, "batch@example.com", raw2.Email, "email should be encrypted in database") - require.NotEqual(t, "Batch User", raw2.Name, "name should be encrypted in database") - - // Verify manual decryption returns empty strings for first user - decryptedEmail1, err := fieldEncrypt.Decrypt(raw1.Email) - require.NoError(t, err) - require.Equal(t, "", decryptedEmail1) - - decryptedName1, err := fieldEncrypt.Decrypt(raw1.Name) - require.NoError(t, err) - require.Equal(t, "", decryptedName1) - - // Verify manual decryption returns correct values for second user - decryptedEmail2, err := fieldEncrypt.Decrypt(raw2.Email) - require.NoError(t, err) - require.Equal(t, "batch@example.com", decryptedEmail2) - - decryptedName2, err := fieldEncrypt.Decrypt(raw2.Name) - require.NoError(t, err) - require.Equal(t, "Batch User", decryptedName2) - }) -} - -func TestSqlStore_DeleteUser(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - userID := "f4f6d672-63fb-11ec-90d6-0242ac120003" - - err = store.DeleteUser(context.Background(), accountID, userID) - require.NoError(t, err) - - user, err := store.GetUserByUserID(context.Background(), LockingStrengthNone, userID) - require.Error(t, err) - require.Nil(t, user) - - userPATs, err := store.GetUserPATs(context.Background(), LockingStrengthNone, userID) - require.NoError(t, err) - require.Len(t, userPATs, 0) -} - -func TestSqlStore_GetPATByID(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - userID := "f4f6d672-63fb-11ec-90d6-0242ac120003" - - tests := []struct { - name string - patID string - expectError bool - }{ - { - name: "retrieve existing PAT", - patID: "9dj38s35-63fb-11ec-90d6-0242ac120003", - expectError: false, - }, - { - name: "retrieve non-existing PAT", - patID: "non-existing", - expectError: true, - }, - { - name: "retrieve with empty PAT ID", - patID: "", - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - pat, err := store.GetPATByID(context.Background(), LockingStrengthNone, userID, tt.patID) - if tt.expectError { - require.Error(t, err) - sErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, sErr.Type(), status.NotFound) - require.Nil(t, pat) - } else { - require.NoError(t, err) - require.NotNil(t, pat) - require.Equal(t, tt.patID, pat.ID) - } - }) - } -} - -func TestSqlStore_GetUserPATs(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - userPATs, err := store.GetUserPATs(context.Background(), LockingStrengthNone, "f4f6d672-63fb-11ec-90d6-0242ac120003") - require.NoError(t, err) - require.Len(t, userPATs, 1) -} - -func TestSqlStore_GetPATByHashedToken(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - pat, err := store.GetPATByHashedToken(context.Background(), LockingStrengthNone, "SoMeHaShEdToKeN") - require.NoError(t, err) - require.Equal(t, "9dj38s35-63fb-11ec-90d6-0242ac120003", pat.ID) -} - -func TestSqlStore_MarkPATUsed(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - userID := "f4f6d672-63fb-11ec-90d6-0242ac120003" - patID := "9dj38s35-63fb-11ec-90d6-0242ac120003" - - err = store.MarkPATUsed(context.Background(), patID) - require.NoError(t, err) - - pat, err := store.GetPATByID(context.Background(), LockingStrengthNone, userID, patID) - require.NoError(t, err) - now := time.Now().UTC() - require.WithinRange(t, pat.LastUsed.UTC(), now.Add(-15*time.Second), now, "LastUsed should be within 1 second of now") -} - -func TestSqlStore_SavePAT(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - userID := "edafee4e-63fb-11ec-90d6-0242ac120003" - - pat := &types.PersonalAccessToken{ - ID: "pat-id", - UserID: userID, - Name: "token", - HashedToken: "SoMeHaShEdToKeN", - ExpirationDate: util.ToPtr(time.Now().UTC().Add(12 * time.Hour)), - CreatedBy: userID, - CreatedAt: time.Now().UTC().Add(time.Hour), - LastUsed: util.ToPtr(time.Now().UTC().Add(-15 * time.Minute)), - } - err = store.SavePAT(context.Background(), pat) - require.NoError(t, err) - - savePAT, err := store.GetPATByID(context.Background(), LockingStrengthNone, userID, pat.ID) - require.NoError(t, err) - require.Equal(t, pat.ID, savePAT.ID) - require.Equal(t, pat.UserID, savePAT.UserID) - require.Equal(t, pat.HashedToken, savePAT.HashedToken) - require.Equal(t, pat.CreatedBy, savePAT.CreatedBy) - require.WithinDurationf(t, pat.GetExpirationDate(), savePAT.ExpirationDate.UTC(), time.Millisecond, "ExpirationDate should be equal") - require.WithinDurationf(t, pat.CreatedAt, savePAT.CreatedAt.UTC(), time.Millisecond, "CreatedAt should be equal") - require.WithinDurationf(t, pat.GetLastUsed(), savePAT.LastUsed.UTC(), time.Millisecond, "LastUsed should be equal") -} - -func TestSqlStore_DeletePAT(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - userID := "f4f6d672-63fb-11ec-90d6-0242ac120003" - patID := "9dj38s35-63fb-11ec-90d6-0242ac120003" - - err = store.DeletePAT(context.Background(), userID, patID) - require.NoError(t, err) - - pat, err := store.GetPATByID(context.Background(), LockingStrengthNone, userID, patID) - require.Error(t, err) - require.Nil(t, pat) -} - -func TestSqlStore_SaveUsers_LargeBatch(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - accountUsers, err := store.GetAccountUsers(context.Background(), LockingStrengthNone, accountID) - require.NoError(t, err) - require.Len(t, accountUsers, 2) - - usersToSave := make([]*types.User, 0) - - for i := 1; i <= 8000; i++ { - usersToSave = append(usersToSave, &types.User{ - Id: fmt.Sprintf("user-%d", i), - AccountID: accountID, - Role: types.UserRoleUser, - }) - } - - err = store.SaveUsers(context.Background(), usersToSave) - require.NoError(t, err) - - accountUsers, err = store.GetAccountUsers(context.Background(), LockingStrengthNone, accountID) - require.NoError(t, err) - require.Equal(t, 8002, len(accountUsers)) -} - -func TestSqlStore_SaveGroups_LargeBatch(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - accountGroups, err := store.GetAccountGroups(context.Background(), LockingStrengthNone, accountID) - require.NoError(t, err) - require.Len(t, accountGroups, 3) - - groupsToSave := make([]*types.Group, 0) - - for i := 1; i <= 8000; i++ { - groupsToSave = append(groupsToSave, &types.Group{ - ID: fmt.Sprintf("%d", i), - AccountID: accountID, - Name: fmt.Sprintf("group-%d", i), - }) - } - - err = store.CreateGroups(context.Background(), accountID, groupsToSave) - require.NoError(t, err) - - accountGroups, err = store.GetAccountGroups(context.Background(), LockingStrengthNone, accountID) - require.NoError(t, err) - require.Equal(t, 8003, len(accountGroups)) -} -func TestSqlStore_GetAccountRoutes(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - tests := []struct { - name string - accountID string - expectedCount int - }{ - { - name: "retrieve routes by existing account ID", - accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b", - expectedCount: 1, - }, - { - name: "non-existing account ID", - accountID: "nonexistent", - expectedCount: 0, - }, - { - name: "empty account ID", - accountID: "", - expectedCount: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - routes, err := store.GetAccountRoutes(context.Background(), LockingStrengthNone, tt.accountID) - require.NoError(t, err) - require.Len(t, routes, tt.expectedCount) - }) - } -} - -func TestSqlStore_GetRouteByID(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - tests := []struct { - name string - routeID string - expectError bool - }{ - { - name: "retrieve existing route", - routeID: "ct03t427qv97vmtmglog", - expectError: false, - }, - { - name: "retrieve non-existing route", - routeID: "non-existing", - expectError: true, - }, - { - name: "retrieve with empty route ID", - routeID: "", - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - route, err := store.GetRouteByID(context.Background(), LockingStrengthNone, accountID, tt.routeID) - if tt.expectError { - require.Error(t, err) - sErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, sErr.Type(), status.NotFound) - require.Nil(t, route) - } else { - require.NoError(t, err) - require.NotNil(t, route) - require.Equal(t, tt.routeID, string(route.ID)) - } - }) - } -} - -func TestSqlStore_GetRouteByIDOrPublicID(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - routeID := "ct03t427qv97vmtmglog" - - route, err := store.GetRouteByID(context.Background(), LockingStrengthNone, accountID, routeID) - require.NoError(t, err) - require.NotEmpty(t, route.PublicID) - - for _, id := range []string{routeID, route.PublicID} { - route, err := store.GetRouteByIDOrPublicID(context.Background(), LockingStrengthNone, accountID, id) - require.NoError(t, err) - require.Equal(t, routeID, string(route.ID)) - } - - route, err = store.GetRouteByIDOrPublicID(context.Background(), LockingStrengthNone, accountID, "non-existing") - require.Error(t, err) - sErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, sErr.Type(), status.NotFound) - require.Nil(t, route) -} - -func TestSqlStore_SaveRoute(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - route := &nbroute.Route{ - ID: "route-id", - AccountID: accountID, - Network: netip.MustParsePrefix("10.10.0.0/16"), - NetID: "netID", - PeerGroups: []string{"routeA"}, - NetworkType: nbroute.IPv4Network, - Masquerade: true, - Metric: 9999, - Enabled: true, - Groups: []string{"groupA"}, - AccessControlGroups: []string{}, - } - err = store.SaveRoute(context.Background(), route) - require.NoError(t, err) - - saveRoute, err := store.GetRouteByID(context.Background(), LockingStrengthNone, accountID, string(route.ID)) - require.NoError(t, err) - require.Equal(t, route, saveRoute) - -} - -func TestSqlStore_DeleteRoute(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - routeID := "ct03t427qv97vmtmglog" - - err = store.DeleteRoute(context.Background(), accountID, routeID) - require.NoError(t, err) - - route, err := store.GetRouteByID(context.Background(), LockingStrengthNone, accountID, routeID) - require.Error(t, err) - require.Nil(t, route) -} - -func TestSqlStore_GetAccountMeta(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - accountMeta, err := store.GetAccountMeta(context.Background(), LockingStrengthNone, accountID) - require.NoError(t, err) - require.NotNil(t, accountMeta) - require.Equal(t, accountID, accountMeta.AccountID) - require.Equal(t, "edafee4e-63fb-11ec-90d6-0242ac120003", accountMeta.CreatedBy) - require.Equal(t, "test.com", accountMeta.Domain) - require.Equal(t, "private", accountMeta.DomainCategory) - require.Equal(t, time.Date(2024, time.October, 2, 14, 1, 38, 210000000, time.UTC), accountMeta.CreatedAt.UTC()) -} - -func TestSqlStore_GetAccountOnboarding(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "9439-34653001fc3b-bf1c8084-ba50-4ce7" - a, err := store.GetAccount(context.Background(), accountID) - require.NoError(t, err) - t.Logf("Onboarding: %+v", a.Onboarding) - err = store.SaveAccount(context.Background(), a) - require.NoError(t, err) - onboarding, err := store.GetAccountOnboarding(context.Background(), accountID) - require.NoError(t, err) - require.NotNil(t, onboarding) - require.Equal(t, accountID, onboarding.AccountID) - require.Equal(t, time.Date(2024, time.October, 2, 14, 1, 38, 210000000, time.UTC), onboarding.CreatedAt.UTC()) -} - -func TestSqlStore_SaveAccountOnboarding(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - t.Run("New onboarding should be saved correctly", func(t *testing.T) { - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - onboarding := &types.AccountOnboarding{ - AccountID: accountID, - SignupFormPending: true, - OnboardingFlowPending: true, - } - - err = store.SaveAccountOnboarding(context.Background(), onboarding) - require.NoError(t, err) - - savedOnboarding, err := store.GetAccountOnboarding(context.Background(), accountID) - require.NoError(t, err) - require.Equal(t, onboarding.SignupFormPending, savedOnboarding.SignupFormPending) - require.Equal(t, onboarding.OnboardingFlowPending, savedOnboarding.OnboardingFlowPending) - }) - - t.Run("Existing onboarding should be updated correctly", func(t *testing.T) { - accountID := "9439-34653001fc3b-bf1c8084-ba50-4ce7" - onboarding, err := store.GetAccountOnboarding(context.Background(), accountID) - require.NoError(t, err) - - onboarding.OnboardingFlowPending = !onboarding.OnboardingFlowPending - onboarding.SignupFormPending = !onboarding.SignupFormPending - - err = store.SaveAccountOnboarding(context.Background(), onboarding) - require.NoError(t, err) - - savedOnboarding, err := store.GetAccountOnboarding(context.Background(), accountID) - require.NoError(t, err) - require.Equal(t, onboarding.SignupFormPending, savedOnboarding.SignupFormPending) - require.Equal(t, onboarding.OnboardingFlowPending, savedOnboarding.OnboardingFlowPending) - }) -} - -func TestSqlStore_GetAnyAccountID(t *testing.T) { - t.Run("should return account ID when accounts exist", func(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID, err := store.GetAnyAccountID(context.Background()) - require.NoError(t, err) - assert.Equal(t, "bf1c8084-ba50-4ce7-9439-34653001fc3b", accountID) - }) - - t.Run("should return error when no accounts exist", func(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID, err := store.GetAnyAccountID(context.Background()) - require.Error(t, err) - sErr, ok := status.FromError(err) - assert.True(t, ok) - assert.Equal(t, sErr.Type(), status.NotFound) - assert.Empty(t, accountID) - }) -} - -func BenchmarkGetAccountPeers(b *testing.B) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_with_expired_peers.sql", b.TempDir()) - if err != nil { - b.Fatal(err) - } - b.Cleanup(cleanup) - - numberOfPeers := 1000 - numberOfGroups := 200 - numberOfPeersPerGroup := 500 - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - peers := make([]*nbpeer.Peer, 0, numberOfPeers) - for i := 0; i < numberOfPeers; i++ { - peer := &nbpeer.Peer{ - ID: fmt.Sprintf("peer-%d", i), - AccountID: accountID, - Key: fmt.Sprintf("key-%d", i), - DNSLabel: fmt.Sprintf("peer%d.example.com", i), - IP: intToIPv4(uint32(i)), - } - err = store.AddPeerToAccount(context.Background(), peer) - if err != nil { - b.Fatalf("Failed to add peer: %v", err) - } - peers = append(peers, peer) - } - - for i := 0; i < numberOfGroups; i++ { - groupID := fmt.Sprintf("group-%d", i) - group := &types.Group{ - ID: groupID, - AccountID: accountID, - } - err = store.CreateGroup(context.Background(), group) - if err != nil { - b.Fatalf("Failed to create group: %v", err) - } - for j := 0; j < numberOfPeersPerGroup; j++ { - peerIndex := (i*numberOfPeersPerGroup + j) % numberOfPeers - err = store.AddPeerToGroup(context.Background(), accountID, peers[peerIndex].ID, groupID) - if err != nil { - b.Fatalf("Failed to add peer to group: %v", err) - } - } - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, err := store.GetPeerGroups(context.Background(), LockingStrengthNone, accountID, peers[i%numberOfPeers].ID) - if err != nil { - b.Fatal(err) - } - } -} - -func intToIPv4(n uint32) netip.Addr { - var b [4]byte - binary.BigEndian.PutUint32(b[:], n) - return netip.AddrFrom4(b) -} - -func TestSqlStore_GetPeersByGroupIDs(t *testing.T) { - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - group1ID := "test-group-1" - group2ID := "test-group-2" - emptyGroupID := "empty-group" - - peer1 := "cfefqs706sqkneg59g4g" - peer2 := "cfeg6sf06sqkneg59g50" - - tests := []struct { - name string - groupIDs []string - expectedPeers []string - expectedCount int - }{ - { - name: "retrieve peers from single group with multiple peers", - groupIDs: []string{group1ID}, - expectedPeers: []string{peer1, peer2}, - expectedCount: 2, - }, - { - name: "retrieve peers from single group with one peer", - groupIDs: []string{group2ID}, - expectedPeers: []string{peer1}, - expectedCount: 1, - }, - { - name: "retrieve peers from multiple groups (with overlap)", - groupIDs: []string{group1ID, group2ID}, - expectedPeers: []string{peer1, peer2}, // should deduplicate - expectedCount: 2, - }, - { - name: "retrieve peers from existing 'All' group", - groupIDs: []string{"cfefqs706sqkneg59g3g"}, // All group from test data - expectedPeers: []string{peer1, peer2}, - expectedCount: 2, - }, - { - name: "retrieve peers from empty group", - groupIDs: []string{emptyGroupID}, - expectedPeers: []string{}, - expectedCount: 0, - }, - { - name: "retrieve peers from non-existing group", - groupIDs: []string{"non-existing-group"}, - expectedPeers: []string{}, - expectedCount: 0, - }, - { - name: "empty group IDs list", - groupIDs: []string{}, - expectedPeers: []string{}, - expectedCount: 0, - }, - { - name: "mix of existing and non-existing groups", - groupIDs: []string{group1ID, "non-existing-group"}, - expectedPeers: []string{peer1, peer2}, - expectedCount: 2, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_policy_migrate.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - ctx := context.Background() - - groups := []*types.Group{ - { - ID: group1ID, - AccountID: accountID, - }, - { - ID: group2ID, - AccountID: accountID, - }, - } - require.NoError(t, store.CreateGroups(ctx, accountID, groups)) - - otherAccount := newAccountWithId(ctx, "other-account", "other-user", "") - require.NoError(t, store.SaveAccount(ctx, otherAccount)) - foreignPeer := &nbpeer.Peer{ID: "foreign-peer", AccountID: otherAccount.Id} - require.NoError(t, store.AddPeerToAccount(ctx, foreignPeer)) - - require.NoError(t, store.AddPeerToGroup(ctx, accountID, peer1, group1ID)) - require.NoError(t, store.AddPeerToGroup(ctx, accountID, peer2, group1ID)) - require.NoError(t, store.AddPeerToGroup(ctx, accountID, peer1, group2ID)) - require.NoError(t, store.AddPeerToGroup(ctx, accountID, foreignPeer.ID, group1ID)) - - peers, err := store.GetPeersByGroupIDs(ctx, accountID, tt.groupIDs) - require.NoError(t, err) - require.Len(t, peers, tt.expectedCount) - - if tt.expectedCount > 0 { - actualPeerIDs := make([]string, len(peers)) - for i, peer := range peers { - actualPeerIDs[i] = peer.ID - } - assert.ElementsMatch(t, tt.expectedPeers, actualPeerIDs) - - // Verify all returned peers belong to the correct account - for _, peer := range peers { - assert.Equal(t, accountID, peer.AccountID) - } - } - }) - } -} - -func TestSqlStore_GetUserIDByPeerKey(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - userID := "test-user-123" - peerKey := "peer-key-abc" - - peer := &nbpeer.Peer{ - ID: "test-peer-1", - Key: peerKey, - AccountID: existingAccountID, - UserID: userID, - IP: netip.AddrFrom4([4]byte{10, 0, 0, 1}), - IPv6: netip.MustParseAddr("fd00::a00:1"), - DNSLabel: "test-peer-1", - } - - err = store.AddPeerToAccount(context.Background(), peer) - require.NoError(t, err) - - retrievedUserID, err := store.GetUserIDByPeerKey(context.Background(), LockingStrengthNone, peerKey) - require.NoError(t, err) - assert.Equal(t, userID, retrievedUserID) -} - -func TestSqlStore_GetUserIDByPeerKey_NotFound(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - nonExistentPeerKey := "non-existent-peer-key" - - userID, err := store.GetUserIDByPeerKey(context.Background(), LockingStrengthNone, nonExistentPeerKey) - require.Error(t, err) - assert.Equal(t, "", userID) -} - -func TestSqlStore_GetUserIDByPeerKey_NoUserID(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - existingAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - peerKey := "peer-key-abc" - - peer := &nbpeer.Peer{ - ID: "test-peer-1", - Key: peerKey, - AccountID: existingAccountID, - UserID: "", - IP: netip.AddrFrom4([4]byte{10, 0, 0, 1}), - IPv6: netip.MustParseAddr("fd00::a00:1"), - DNSLabel: "test-peer-1", - } - - err = store.AddPeerToAccount(context.Background(), peer) - require.NoError(t, err) - - retrievedUserID, err := store.GetUserIDByPeerKey(context.Background(), LockingStrengthNone, peerKey) - require.NoError(t, err) - assert.Equal(t, "", retrievedUserID) -} - -func TestSqlStore_ApproveAccountPeers(t *testing.T) { - runTestForAllEngines(t, "", func(t *testing.T, store Store) { - accountID := "test-account" - ctx := context.Background() - - account := newAccountWithId(ctx, accountID, "testuser", "example.com") - err := store.SaveAccount(ctx, account) - require.NoError(t, err) - - peers := []*nbpeer.Peer{ - { - ID: "peer1", - AccountID: accountID, - DNSLabel: "peer1.netbird.cloud", - Key: "peer1-key", - IP: netip.MustParseAddr("100.64.0.1"), - IPv6: netip.MustParseAddr("fd00::1"), - Status: &nbpeer.PeerStatus{ - RequiresApproval: true, - LastSeen: time.Now().UTC(), - }, - }, - { - ID: "peer2", - AccountID: accountID, - DNSLabel: "peer2.netbird.cloud", - Key: "peer2-key", - IP: netip.MustParseAddr("100.64.0.2"), - IPv6: netip.MustParseAddr("fd00::2"), - Status: &nbpeer.PeerStatus{ - RequiresApproval: true, - LastSeen: time.Now().UTC(), - }, - }, - { - ID: "peer3", - AccountID: accountID, - DNSLabel: "peer3.netbird.cloud", - Key: "peer3-key", - IP: netip.MustParseAddr("100.64.0.3"), - IPv6: netip.MustParseAddr("fd00::3"), - Status: &nbpeer.PeerStatus{ - RequiresApproval: false, - LastSeen: time.Now().UTC(), - }, - }, - } - - for _, peer := range peers { - err = store.AddPeerToAccount(ctx, peer) - require.NoError(t, err) - } - - t.Run("approve all pending peers", func(t *testing.T) { - count, err := store.ApproveAccountPeers(ctx, accountID) - require.NoError(t, err) - assert.Equal(t, 2, count) - - allPeers, err := store.GetAccountPeers(ctx, LockingStrengthNone, accountID, "", "") - require.NoError(t, err) - - for _, peer := range allPeers { - assert.False(t, peer.Status.RequiresApproval, "peer %s should not require approval", peer.ID) - } - }) - - t.Run("no peers to approve", func(t *testing.T) { - count, err := store.ApproveAccountPeers(ctx, accountID) - require.NoError(t, err) - assert.Equal(t, 0, count) - }) - - t.Run("non-existent account", func(t *testing.T) { - count, err := store.ApproveAccountPeers(ctx, "non-existent") - require.NoError(t, err) - assert.Equal(t, 0, count) - }) - }) -} - func TestSqlStore_ExecuteInTransaction_Timeout(t *testing.T) { if os.Getenv("NETBIRD_STORE_ENGINE") == "mysql" { t.Skip("Skipping timeout test for MySQL") @@ -4327,479 +360,6 @@ func TestSqlStore_ExecuteInTransaction_Timeout(t *testing.T) { assert.Contains(t, err.Error(), "transaction has already been committed or rolled back", "expected transaction rolled back error, got: %v", err) } -func TestSqlStore_CreateZone(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) - - err = store.CreateZone(context.Background(), zone) - require.NoError(t, err) - - savedZone, err := store.GetZoneByID(context.Background(), LockingStrengthNone, accountID, zone.ID) - require.NoError(t, err) - require.NotNil(t, savedZone) - assert.Equal(t, zone.ID, savedZone.ID) - assert.Equal(t, zone.Name, savedZone.Name) - assert.Equal(t, zone.Domain, savedZone.Domain) - assert.Equal(t, zone.Enabled, savedZone.Enabled) - assert.Equal(t, zone.EnableSearchDomain, savedZone.EnableSearchDomain) - assert.Equal(t, zone.DistributionGroups, savedZone.DistributionGroups) -} - -func TestSqlStore_GetZoneByID(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) - err = store.CreateZone(context.Background(), zone) - require.NoError(t, err) - - tests := []struct { - name string - accountID string - zoneID string - expectError bool - }{ - { - name: "retrieve existing zone", - accountID: accountID, - zoneID: zone.ID, - expectError: false, - }, - { - name: "retrieve non-existing zone", - accountID: accountID, - zoneID: "non-existing", - expectError: true, - }, - { - name: "retrieve with empty zone ID", - accountID: accountID, - zoneID: "", - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - savedZone, err := store.GetZoneByID(context.Background(), LockingStrengthNone, tt.accountID, tt.zoneID) - if tt.expectError { - require.Error(t, err) - sErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, sErr.Type(), status.NotFound) - require.Nil(t, savedZone) - } else { - require.NoError(t, err) - require.NotNil(t, savedZone) - assert.Equal(t, tt.zoneID, savedZone.ID) - } - }) - } -} - -func TestSqlStore_GetAccountZones(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - zone1 := zones.NewZone(accountID, "Zone 1", "example1.com", true, false, []string{"group1"}) - err = store.CreateZone(context.Background(), zone1) - require.NoError(t, err) - - zone2 := zones.NewZone(accountID, "Zone 2", "example2.com", true, true, []string{"group1", "group2"}) - err = store.CreateZone(context.Background(), zone2) - require.NoError(t, err) - - allZones, err := store.GetAccountZones(context.Background(), LockingStrengthNone, accountID) - require.NoError(t, err) - require.NotNil(t, allZones) - assert.GreaterOrEqual(t, len(allZones), 2) - - zoneIDs := make(map[string]bool) - for _, z := range allZones { - zoneIDs[z.ID] = true - } - assert.True(t, zoneIDs[zone1.ID]) - assert.True(t, zoneIDs[zone2.ID]) -} - -func TestSqlStore_GetZoneByDomain(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - otherAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3c" - - zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) - err = store.CreateZone(context.Background(), zone) - require.NoError(t, err) - - tests := []struct { - name string - accountID string - domain string - expectError bool - errorType status.Type - }{ - { - name: "retrieve existing zone by domain", - accountID: accountID, - domain: "example.com", - expectError: false, - }, - { - name: "retrieve non-existing zone domain", - accountID: accountID, - domain: "non-existing.com", - expectError: true, - errorType: status.NotFound, - }, - { - name: "retrieve with empty domain", - accountID: accountID, - domain: "", - expectError: true, - errorType: status.NotFound, - }, - { - name: "retrieve with different account ID", - accountID: otherAccountID, - domain: "example.com", - expectError: true, - errorType: status.NotFound, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - savedZone, err := store.GetZoneByDomain(context.Background(), tt.accountID, tt.domain) - if tt.expectError { - require.Error(t, err) - sErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, tt.errorType, sErr.Type()) - require.Nil(t, savedZone) - } else { - require.NoError(t, err) - require.NotNil(t, savedZone) - assert.Equal(t, tt.domain, savedZone.Domain) - assert.Equal(t, zone.ID, savedZone.ID) - assert.Equal(t, zone.Name, savedZone.Name) - } - }) - } -} - -func TestSqlStore_UpdateZone(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) - err = store.CreateZone(context.Background(), zone) - require.NoError(t, err) - - zone.Name = "Updated Zone" - zone.Domain = "updated.com" - zone.Enabled = false - zone.EnableSearchDomain = true - zone.DistributionGroups = []string{"group2", "group3"} - - err = store.UpdateZone(context.Background(), zone) - require.NoError(t, err) - - updatedZone, err := store.GetZoneByID(context.Background(), LockingStrengthNone, accountID, zone.ID) - require.NoError(t, err) - require.NotNil(t, updatedZone) - assert.Equal(t, "Updated Zone", updatedZone.Name) - assert.Equal(t, "updated.com", updatedZone.Domain) - assert.False(t, updatedZone.Enabled) - assert.True(t, updatedZone.EnableSearchDomain) - assert.Equal(t, []string{"group2", "group3"}, updatedZone.DistributionGroups) -} - -func TestSqlStore_DeleteZone(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) - err = store.CreateZone(context.Background(), zone) - require.NoError(t, err) - - err = store.DeleteZone(context.Background(), accountID, zone.ID) - require.NoError(t, err) - - deletedZone, err := store.GetZoneByID(context.Background(), LockingStrengthNone, accountID, zone.ID) - require.Error(t, err) - require.Nil(t, deletedZone) - sErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, sErr.Type(), status.NotFound) -} - -func TestSqlStore_CreateDNSRecord(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) - err = store.CreateZone(context.Background(), zone) - require.NoError(t, err) - - record := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300) - - err = store.CreateDNSRecord(context.Background(), record) - require.NoError(t, err) - - savedRecord, err := store.GetDNSRecordByID(context.Background(), LockingStrengthNone, accountID, zone.ID, record.ID) - require.NoError(t, err) - require.NotNil(t, savedRecord) - assert.Equal(t, record.ID, savedRecord.ID) - assert.Equal(t, record.Name, savedRecord.Name) - assert.Equal(t, record.Type, savedRecord.Type) - assert.Equal(t, record.Content, savedRecord.Content) - assert.Equal(t, record.TTL, savedRecord.TTL) - assert.Equal(t, zone.ID, savedRecord.ZoneID) -} - -func TestSqlStore_GetDNSRecordByID(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) - err = store.CreateZone(context.Background(), zone) - require.NoError(t, err) - - record := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300) - err = store.CreateDNSRecord(context.Background(), record) - require.NoError(t, err) - - tests := []struct { - name string - accountID string - zoneID string - recordID string - expectError bool - }{ - { - name: "retrieve existing record", - accountID: accountID, - zoneID: zone.ID, - recordID: record.ID, - expectError: false, - }, - { - name: "retrieve non-existing record", - accountID: accountID, - zoneID: zone.ID, - recordID: "non-existing", - expectError: true, - }, - { - name: "retrieve with empty record ID", - accountID: accountID, - zoneID: zone.ID, - recordID: "", - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - savedRecord, err := store.GetDNSRecordByID(context.Background(), LockingStrengthNone, tt.accountID, tt.zoneID, tt.recordID) - if tt.expectError { - require.Error(t, err) - sErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, sErr.Type(), status.NotFound) - require.Nil(t, savedRecord) - } else { - require.NoError(t, err) - require.NotNil(t, savedRecord) - assert.Equal(t, tt.recordID, savedRecord.ID) - } - }) - } -} - -func TestSqlStore_GetZoneDNSRecords(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) - err = store.CreateZone(context.Background(), zone) - require.NoError(t, err) - - recordA := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300) - err = store.CreateDNSRecord(context.Background(), recordA) - require.NoError(t, err) - - recordAAAA := records.NewRecord(accountID, zone.ID, "ipv6.example.com", records.RecordTypeAAAA, "2001:db8::1", 300) - err = store.CreateDNSRecord(context.Background(), recordAAAA) - require.NoError(t, err) - - recordCNAME := records.NewRecord(accountID, zone.ID, "alias.example.com", records.RecordTypeCNAME, "www.example.com", 300) - err = store.CreateDNSRecord(context.Background(), recordCNAME) - require.NoError(t, err) - - allRecords, err := store.GetZoneDNSRecords(context.Background(), LockingStrengthNone, accountID, zone.ID) - require.NoError(t, err) - require.NotNil(t, allRecords) - assert.Equal(t, 3, len(allRecords)) - - recordIDs := make(map[string]bool) - for _, r := range allRecords { - recordIDs[r.ID] = true - } - assert.True(t, recordIDs[recordA.ID]) - assert.True(t, recordIDs[recordAAAA.ID]) - assert.True(t, recordIDs[recordCNAME.ID]) -} - -func TestSqlStore_GetZoneDNSRecordsByName(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) - err = store.CreateZone(context.Background(), zone) - require.NoError(t, err) - - record1 := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300) - err = store.CreateDNSRecord(context.Background(), record1) - require.NoError(t, err) - - record2 := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeAAAA, "2001:db8::1", 300) - err = store.CreateDNSRecord(context.Background(), record2) - require.NoError(t, err) - - record3 := records.NewRecord(accountID, zone.ID, "mail.example.com", records.RecordTypeA, "192.168.1.2", 600) - err = store.CreateDNSRecord(context.Background(), record3) - require.NoError(t, err) - - recordsByName, err := store.GetZoneDNSRecordsByName(context.Background(), LockingStrengthNone, accountID, zone.ID, "www.example.com") - require.NoError(t, err) - require.NotNil(t, recordsByName) - assert.Equal(t, 2, len(recordsByName)) - - for _, r := range recordsByName { - assert.Equal(t, "www.example.com", r.Name) - } -} - -func TestSqlStore_UpdateDNSRecord(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) - err = store.CreateZone(context.Background(), zone) - require.NoError(t, err) - - record := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300) - err = store.CreateDNSRecord(context.Background(), record) - require.NoError(t, err) - - record.Name = "api.example.com" - record.Content = "192.168.1.100" - record.TTL = 600 - - err = store.UpdateDNSRecord(context.Background(), record) - require.NoError(t, err) - - updatedRecord, err := store.GetDNSRecordByID(context.Background(), LockingStrengthNone, accountID, zone.ID, record.ID) - require.NoError(t, err) - require.NotNil(t, updatedRecord) - assert.Equal(t, "api.example.com", updatedRecord.Name) - assert.Equal(t, "192.168.1.100", updatedRecord.Content) - assert.Equal(t, 600, updatedRecord.TTL) -} - -func TestSqlStore_DeleteDNSRecord(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) - err = store.CreateZone(context.Background(), zone) - require.NoError(t, err) - - record := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300) - err = store.CreateDNSRecord(context.Background(), record) - require.NoError(t, err) - - err = store.DeleteDNSRecord(context.Background(), accountID, zone.ID, record.ID) - require.NoError(t, err) - - deletedRecord, err := store.GetDNSRecordByID(context.Background(), LockingStrengthNone, accountID, zone.ID, record.ID) - require.Error(t, err) - require.Nil(t, deletedRecord) - sErr, ok := status.FromError(err) - require.True(t, ok) - require.Equal(t, sErr.Type(), status.NotFound) -} - -func TestSqlStore_DeleteZoneDNSRecords(t *testing.T) { - store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) - t.Cleanup(cleanup) - require.NoError(t, err) - - accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" - - zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) - err = store.CreateZone(context.Background(), zone) - require.NoError(t, err) - - record1 := records.NewRecord(accountID, zone.ID, "www.example.com", records.RecordTypeA, "192.168.1.1", 300) - err = store.CreateDNSRecord(context.Background(), record1) - require.NoError(t, err) - - record2 := records.NewRecord(accountID, zone.ID, "mail.example.com", records.RecordTypeA, "192.168.1.2", 600) - err = store.CreateDNSRecord(context.Background(), record2) - require.NoError(t, err) - - allRecords, err := store.GetZoneDNSRecords(context.Background(), LockingStrengthNone, accountID, zone.ID) - require.NoError(t, err) - assert.Equal(t, 2, len(allRecords)) - - err = store.DeleteZoneDNSRecords(context.Background(), accountID, zone.ID) - require.NoError(t, err) - - remainingRecords, err := store.GetZoneDNSRecords(context.Background(), LockingStrengthNone, accountID, zone.ID) - require.NoError(t, err) - assert.Equal(t, 0, len(remainingRecords)) -} - // TestNewSqliteStore_BusyTimeoutApplied opens a fresh SQLite store and verifies // that the _busy_timeout DSN parameter took effect at the driver level. Without // this, lock contention on the single SQLite connection waits indefinitely on diff --git a/management/server/store/sql_store_user.go b/management/server/store/sql_store_user.go new file mode 100644 index 000000000..2ead2156a --- /dev/null +++ b/management/server/store/sql_store_user.go @@ -0,0 +1,272 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +// SaveUsers saves the given list of users to the database. +func (s *SqlStore) SaveUsers(ctx context.Context, users []*types.User) error { + if len(users) == 0 { + return nil + } + + usersCopy := make([]*types.User, len(users)) + for i, user := range users { + userCopy := user.Copy() + userCopy.Email = user.Email + userCopy.Name = user.Name + if err := userCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil { + return fmt.Errorf("encrypt user: %w", err) + } + usersCopy[i] = userCopy + } + + result := s.db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&usersCopy) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save users to store: %s", result.Error) + return status.Errorf(status.Internal, "failed to save users to store") + } + return nil +} + +// SaveUser saves the given user to the database. +func (s *SqlStore) SaveUser(ctx context.Context, user *types.User) error { + userCopy := user.Copy() + userCopy.Email = user.Email + userCopy.Name = user.Name + + if err := userCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil { + return fmt.Errorf("encrypt user: %w", err) + } + + result := s.db.Save(userCopy) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save user to store: %s", result.Error) + return status.Errorf(status.Internal, "failed to save user to store") + } + return nil +} + +func (s *SqlStore) GetUserByPATID(ctx context.Context, lockStrength LockingStrength, patID string) (*types.User, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var user types.User + result := tx. + Joins("JOIN personal_access_tokens ON personal_access_tokens.user_id = users.id"). + Where("personal_access_tokens.id = ?", patID).Take(&user) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewPATNotFoundError(patID) + } + log.WithContext(ctx).Errorf("failed to get token user from the store: %s", result.Error) + return nil, status.NewGetUserFromStoreError() + } + + if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt user: %w", err) + } + + return &user, nil +} + +func (s *SqlStore) GetUserByUserID(ctx context.Context, lockStrength LockingStrength, userID string) (*types.User, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var user types.User + result := tx.Take(&user, idQueryCondition, userID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewUserNotFoundError(userID) + } + return nil, status.NewGetUserFromStoreError() + } + + if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt user: %w", err) + } + + return &user, nil +} + +func (s *SqlStore) DeleteUser(ctx context.Context, accountID, userID string) error { + err := s.transaction(func(tx *gorm.DB) error { + result := tx.Delete(&types.PersonalAccessToken{}, "user_id = ?", userID) + if result.Error != nil { + return result.Error + } + + return tx.Delete(&types.User{}, accountAndIDQueryCondition, accountID, userID).Error + }) + if err != nil { + log.WithContext(ctx).Errorf("failed to delete user from the store: %s", err) + return status.Errorf(status.Internal, "failed to delete user from store") + } + + return nil +} + +func (s *SqlStore) GetAccountUsers(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.User, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var users []*types.User + result := tx.Find(&users, accountIDCondition, accountID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "accountID not found: index lookup failed") + } + log.WithContext(ctx).Errorf("error when getting users from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "issue getting users from store") + } + + for _, user := range users { + if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt user: %w", err) + } + } + + return users, nil +} + +func (s *SqlStore) GetAccountOwner(ctx context.Context, lockStrength LockingStrength, accountID string) (*types.User, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var user types.User + result := tx.Take(&user, "account_id = ? AND role = ?", accountID, types.UserRoleOwner) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "account owner not found: index lookup failed") + } + return nil, status.Errorf(status.Internal, "failed to get account owner from the store") + } + + if err := user.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt user: %w", err) + } + + return &user, nil +} + +func (s *SqlStore) getUsers(ctx context.Context, accountID string) ([]types.User, error) { + const query = `SELECT id, account_id, role, is_service_user, non_deletable, service_user_name, auto_groups, blocked, pending_approval, last_login, created_at, issued, integration_ref_id, integration_ref_integration_type, email, name FROM users WHERE account_id = $1` + rows, err := s.pool.Query(ctx, query, accountID) + if err != nil { + return nil, err + } + users, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (types.User, error) { + var u types.User + var autoGroups []byte + var lastLogin, createdAt sql.NullTime + var isServiceUser, nonDeletable, blocked, pendingApproval sql.NullBool + err := row.Scan(&u.Id, &u.AccountID, &u.Role, &isServiceUser, &nonDeletable, &u.ServiceUserName, &autoGroups, &blocked, &pendingApproval, &lastLogin, &createdAt, &u.Issued, &u.IntegrationReference.ID, &u.IntegrationReference.IntegrationType, &u.Email, &u.Name) + if err == nil { + if lastLogin.Valid { + u.LastLogin = &lastLogin.Time + } + if createdAt.Valid { + u.CreatedAt = createdAt.Time + } + if isServiceUser.Valid { + u.IsServiceUser = isServiceUser.Bool + } + if nonDeletable.Valid { + u.NonDeletable = nonDeletable.Bool + } + if blocked.Valid { + u.Blocked = blocked.Bool + } + if pendingApproval.Valid { + u.PendingApproval = pendingApproval.Bool + } + if autoGroups != nil { + _ = json.Unmarshal(autoGroups, &u.AutoGroups) + } else { + u.AutoGroups = []string{} + } + } + return u, err + }) + if err != nil { + return nil, err + } + return users, nil +} + +func (s *SqlStore) GetAccountByUser(ctx context.Context, userID string) (*types.Account, error) { + var user types.User + result := s.db.Select("account_id").Take(&user, idQueryCondition, userID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") + } + return nil, status.NewGetAccountFromStoreError(result.Error) + } + + if user.AccountID == "" { + return nil, status.Errorf(status.NotFound, "account not found: index lookup failed") + } + + return s.GetAccount(ctx, user.AccountID) +} + +func (s *SqlStore) GetAccountIDByUserID(ctx context.Context, lockStrength LockingStrength, userID string) (string, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var accountID string + result := tx.Model(&types.User{}). + Select("account_id").Where(idQueryCondition, userID).Take(&accountID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return "", status.Errorf(status.NotFound, "account not found: index lookup failed") + } + return "", status.NewGetAccountFromStoreError(result.Error) + } + + return accountID, nil +} + +// SaveUserLastLogin stores the last login time for a user in DB. +func (s *SqlStore) SaveUserLastLogin(ctx context.Context, accountID, userID string, lastLogin time.Time) error { + var user types.User + result := s.db.Take(&user, accountAndIDQueryCondition, accountID, userID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return status.NewUserNotFoundError(userID) + } + return status.NewGetUserFromStoreError() + } + + if !lastLogin.IsZero() { + user.LastLogin = &lastLogin + return s.db.Save(&user).Error + } + + return nil +} diff --git a/management/server/store/sql_store_user_invite.go b/management/server/store/sql_store_user_invite.go new file mode 100644 index 000000000..3c93a0d19 --- /dev/null +++ b/management/server/store/sql_store_user_invite.go @@ -0,0 +1,139 @@ +package store + +import ( + "context" + "errors" + "fmt" + "strings" + + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/shared/management/status" +) + +// SaveUserInvite saves a user invite to the database +func (s *SqlStore) SaveUserInvite(ctx context.Context, invite *types.UserInviteRecord) error { + inviteCopy := invite.Copy() + if err := inviteCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil { + return fmt.Errorf("encrypt invite: %w", err) + } + + result := s.db.Save(inviteCopy) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to save user invite to store: %s", result.Error) + return status.Errorf(status.Internal, "failed to save user invite to store") + } + return nil +} + +// GetUserInviteByID retrieves a user invite by its ID and account ID +func (s *SqlStore) GetUserInviteByID(ctx context.Context, lockStrength LockingStrength, accountID, inviteID string) (*types.UserInviteRecord, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var invite types.UserInviteRecord + result := tx.Where("account_id = ?", accountID).Take(&invite, idQueryCondition, inviteID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "user invite not found") + } + log.WithContext(ctx).Errorf("failed to get user invite from store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get user invite from store") + } + + if err := invite.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt invite: %w", err) + } + + return &invite, nil +} + +// GetUserInviteByHashedToken retrieves a user invite by its hashed token +func (s *SqlStore) GetUserInviteByHashedToken(ctx context.Context, lockStrength LockingStrength, hashedToken string) (*types.UserInviteRecord, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var invite types.UserInviteRecord + result := tx.Take(&invite, "hashed_token = ?", hashedToken) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.Errorf(status.NotFound, "user invite not found") + } + log.WithContext(ctx).Errorf("failed to get user invite from store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get user invite from store") + } + + if err := invite.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt invite: %w", err) + } + + return &invite, nil +} + +// GetUserInviteByEmail retrieves a user invite by account ID and email. +// Since email is encrypted with random IVs, we fetch all invites for the account +// and compare emails in memory after decryption. +func (s *SqlStore) GetUserInviteByEmail(ctx context.Context, lockStrength LockingStrength, accountID, email string) (*types.UserInviteRecord, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var invites []*types.UserInviteRecord + result := tx.Find(&invites, "account_id = ?", accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get user invites from store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get user invites from store") + } + + for _, invite := range invites { + if err := invite.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt invite: %w", err) + } + if strings.EqualFold(invite.Email, email) { + return invite, nil + } + } + + return nil, status.Errorf(status.NotFound, "user invite not found for email") +} + +// GetAccountUserInvites retrieves all user invites for an account +func (s *SqlStore) GetAccountUserInvites(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.UserInviteRecord, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var invites []*types.UserInviteRecord + result := tx.Find(&invites, "account_id = ?", accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get user invites from store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get user invites from store") + } + + for _, invite := range invites { + if err := invite.DecryptSensitiveData(s.fieldEncrypt); err != nil { + return nil, fmt.Errorf("decrypt invite: %w", err) + } + } + + return invites, nil +} + +// DeleteUserInvite deletes a user invite by its ID +func (s *SqlStore) DeleteUserInvite(ctx context.Context, inviteID string) error { + result := s.db.Delete(&types.UserInviteRecord{}, idQueryCondition, inviteID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete user invite from store: %s", result.Error) + return status.Errorf(status.Internal, "failed to delete user invite from store") + } + return nil +} diff --git a/management/server/store/sql_store_user_test.go b/management/server/store/sql_store_user_test.go new file mode 100644 index 000000000..34bc458fe --- /dev/null +++ b/management/server/store/sql_store_user_test.go @@ -0,0 +1,343 @@ +package store + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/server/types" + "github.com/netbirdio/netbird/management/server/util" + "github.com/netbirdio/netbird/shared/management/status" + "github.com/netbirdio/netbird/util/crypt" +) + +func TestSqlStore_GetAccountUsers(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + if err != nil { + t.Fatal(err) + } + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + account, err := store.GetAccount(context.Background(), accountID) + require.NoError(t, err) + users, err := store.GetAccountUsers(context.Background(), LockingStrengthNone, accountID) + require.NoError(t, err) + require.Len(t, users, len(account.Users)) +} + +func TestSqlStore_GetUserByUserID(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + tests := []struct { + name string + userID string + expectError bool + }{ + { + name: "retrieve existing user", + userID: "edafee4e-63fb-11ec-90d6-0242ac120003", + expectError: false, + }, + { + name: "retrieve non-existing user", + userID: "non-existing", + expectError: true, + }, + { + name: "retrieve with empty user ID", + userID: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + user, err := store.GetUserByUserID(context.Background(), LockingStrengthNone, tt.userID) + if tt.expectError { + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) + require.Nil(t, user) + } else { + require.NoError(t, err) + require.NotNil(t, user) + require.Equal(t, tt.userID, user.Id) + } + }) + } +} + +func TestSqlStore_GetUserByPATID(t *testing.T) { + store, cleanUp, err := NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", t.TempDir()) + t.Cleanup(cleanUp) + assert.NoError(t, err) + + id := "9dj38s35-63fb-11ec-90d6-0242ac120003" + + user, err := store.GetUserByPATID(context.Background(), LockingStrengthNone, id) + require.NoError(t, err) + require.Equal(t, "f4f6d672-63fb-11ec-90d6-0242ac120003", user.Id) +} + +func TestSqlStore_SaveUser(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + user := &types.User{ + Id: "user-id", + AccountID: accountID, + Role: types.UserRoleAdmin, + IsServiceUser: false, + AutoGroups: []string{"groupA", "groupB"}, + Blocked: false, + LastLogin: util.ToPtr(time.Now().UTC()), + CreatedAt: time.Now().UTC().Add(-time.Hour), + Issued: types.UserIssuedIntegration, + } + err = store.SaveUser(context.Background(), user) + require.NoError(t, err) + + saveUser, err := store.GetUserByUserID(context.Background(), LockingStrengthNone, user.Id) + require.NoError(t, err) + require.Equal(t, user.Id, saveUser.Id) + require.Equal(t, user.AccountID, saveUser.AccountID) + require.Equal(t, user.Role, saveUser.Role) + require.Equal(t, user.AutoGroups, saveUser.AutoGroups) + require.WithinDurationf(t, user.GetLastLogin(), saveUser.LastLogin.UTC(), time.Millisecond, "LastLogin should be equal") + require.WithinDurationf(t, user.CreatedAt, saveUser.CreatedAt.UTC(), time.Millisecond, "CreatedAt should be equal") + require.Equal(t, user.Issued, saveUser.Issued) + require.Equal(t, user.Blocked, saveUser.Blocked) + require.Equal(t, user.IsServiceUser, saveUser.IsServiceUser) +} + +func TestSqlStore_SaveUsers(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + accountUsers, err := store.GetAccountUsers(context.Background(), LockingStrengthNone, accountID) + require.NoError(t, err) + require.Len(t, accountUsers, 2) + + users := []*types.User{ + { + Id: "user-1", + AccountID: accountID, + Issued: "api", + AutoGroups: []string{"groupA", "groupB"}, + }, + { + Id: "user-2", + AccountID: accountID, + Issued: "integration", + AutoGroups: []string{"groupA"}, + }, + } + err = store.SaveUsers(context.Background(), users) + require.NoError(t, err) + + accountUsers, err = store.GetAccountUsers(context.Background(), LockingStrengthNone, accountID) + require.NoError(t, err) + require.Len(t, accountUsers, 4) + + users[1].AutoGroups = []string{"groupA", "groupC"} + err = store.SaveUsers(context.Background(), users) + require.NoError(t, err) + + user, err := store.GetUserByUserID(context.Background(), LockingStrengthNone, users[1].Id) + require.NoError(t, err) + require.Equal(t, users[1].AutoGroups, user.AutoGroups) +} + +func TestSqlStore_SaveUserWithEncryption(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + // Enable encryption + key, err := crypt.GenerateKey() + require.NoError(t, err) + fieldEncrypt, err := crypt.NewFieldEncrypt(key) + require.NoError(t, err) + store.SetFieldEncrypt(fieldEncrypt) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + // rawUser is used to read raw (potentially encrypted) data from the database + // without any gorm hooks or automatic decryption + type rawUser struct { + Id string + Email string + Name string + } + + t.Run("save user with empty email and name", func(t *testing.T) { + user := &types.User{ + Id: "user-empty-fields", + AccountID: accountID, + Role: types.UserRoleUser, + Email: "", + Name: "", + AutoGroups: []string{"groupA"}, + } + err = store.SaveUser(context.Background(), user) + require.NoError(t, err) + + // Verify using direct database query that empty strings remain empty (not encrypted) + var raw rawUser + err = store.(*SqlStore).db.Table("users").Select("id, email, name").Where("id = ?", user.Id).First(&raw).Error + require.NoError(t, err) + require.Equal(t, "", raw.Email, "empty email should remain empty in database") + require.Equal(t, "", raw.Name, "empty name should remain empty in database") + + // Verify manual decryption returns empty strings + decryptedEmail, err := fieldEncrypt.Decrypt(raw.Email) + require.NoError(t, err) + require.Equal(t, "", decryptedEmail) + + decryptedName, err := fieldEncrypt.Decrypt(raw.Name) + require.NoError(t, err) + require.Equal(t, "", decryptedName) + }) + + t.Run("save user with email and name", func(t *testing.T) { + user := &types.User{ + Id: "user-with-fields", + AccountID: accountID, + Role: types.UserRoleAdmin, + Email: "test@example.com", + Name: "Test User", + AutoGroups: []string{"groupB"}, + } + err = store.SaveUser(context.Background(), user) + require.NoError(t, err) + + // Verify using direct database query that the data is encrypted (not plaintext) + var raw rawUser + err = store.(*SqlStore).db.Table("users").Select("id, email, name").Where("id = ?", user.Id).First(&raw).Error + require.NoError(t, err) + require.NotEqual(t, "test@example.com", raw.Email, "email should be encrypted in database") + require.NotEqual(t, "Test User", raw.Name, "name should be encrypted in database") + + // Verify manual decryption returns correct values + decryptedEmail, err := fieldEncrypt.Decrypt(raw.Email) + require.NoError(t, err) + require.Equal(t, "test@example.com", decryptedEmail) + + decryptedName, err := fieldEncrypt.Decrypt(raw.Name) + require.NoError(t, err) + require.Equal(t, "Test User", decryptedName) + }) + + t.Run("save multiple users with mixed fields", func(t *testing.T) { + users := []*types.User{ + { + Id: "batch-user-1", + AccountID: accountID, + Email: "", + Name: "", + }, + { + Id: "batch-user-2", + AccountID: accountID, + Email: "batch@example.com", + Name: "Batch User", + }, + } + err = store.SaveUsers(context.Background(), users) + require.NoError(t, err) + + // Verify first user (empty fields) using direct database query + var raw1 rawUser + err = store.(*SqlStore).db.Table("users").Select("id, email, name").Where("id = ?", "batch-user-1").First(&raw1).Error + require.NoError(t, err) + require.Equal(t, "", raw1.Email, "empty email should remain empty in database") + require.Equal(t, "", raw1.Name, "empty name should remain empty in database") + + // Verify second user (with fields) using direct database query + var raw2 rawUser + err = store.(*SqlStore).db.Table("users").Select("id, email, name").Where("id = ?", "batch-user-2").First(&raw2).Error + require.NoError(t, err) + require.NotEqual(t, "batch@example.com", raw2.Email, "email should be encrypted in database") + require.NotEqual(t, "Batch User", raw2.Name, "name should be encrypted in database") + + // Verify manual decryption returns empty strings for first user + decryptedEmail1, err := fieldEncrypt.Decrypt(raw1.Email) + require.NoError(t, err) + require.Equal(t, "", decryptedEmail1) + + decryptedName1, err := fieldEncrypt.Decrypt(raw1.Name) + require.NoError(t, err) + require.Equal(t, "", decryptedName1) + + // Verify manual decryption returns correct values for second user + decryptedEmail2, err := fieldEncrypt.Decrypt(raw2.Email) + require.NoError(t, err) + require.Equal(t, "batch@example.com", decryptedEmail2) + + decryptedName2, err := fieldEncrypt.Decrypt(raw2.Name) + require.NoError(t, err) + require.Equal(t, "Batch User", decryptedName2) + }) +} + +func TestSqlStore_DeleteUser(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + userID := "f4f6d672-63fb-11ec-90d6-0242ac120003" + + err = store.DeleteUser(context.Background(), accountID, userID) + require.NoError(t, err) + + user, err := store.GetUserByUserID(context.Background(), LockingStrengthNone, userID) + require.Error(t, err) + require.Nil(t, user) + + userPATs, err := store.GetUserPATs(context.Background(), LockingStrengthNone, userID) + require.NoError(t, err) + require.Len(t, userPATs, 0) +} + +func TestSqlStore_SaveUsers_LargeBatch(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + accountUsers, err := store.GetAccountUsers(context.Background(), LockingStrengthNone, accountID) + require.NoError(t, err) + require.Len(t, accountUsers, 2) + + usersToSave := make([]*types.User, 0) + + for i := 1; i <= 8000; i++ { + usersToSave = append(usersToSave, &types.User{ + Id: fmt.Sprintf("user-%d", i), + AccountID: accountID, + Role: types.UserRoleUser, + }) + } + + err = store.SaveUsers(context.Background(), usersToSave) + require.NoError(t, err) + + accountUsers, err = store.GetAccountUsers(context.Background(), LockingStrengthNone, accountID) + require.NoError(t, err) + require.Equal(t, 8002, len(accountUsers)) +} diff --git a/management/server/store/sql_store_zone.go b/management/server/store/sql_store_zone.go new file mode 100644 index 000000000..02b65546a --- /dev/null +++ b/management/server/store/sql_store_zone.go @@ -0,0 +1,98 @@ +package store + +import ( + "context" + "errors" + + log "github.com/sirupsen/logrus" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/netbirdio/netbird/management/internals/modules/zones" + "github.com/netbirdio/netbird/shared/management/status" +) + +func (s *SqlStore) CreateZone(ctx context.Context, zone *zones.Zone) error { + result := s.db.Create(zone) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to create zone to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to create zone to store") + } + + return nil +} + +func (s *SqlStore) UpdateZone(ctx context.Context, zone *zones.Zone) error { + result := s.db.Select("*").Save(zone) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to update zone to store: %v", result.Error) + return status.Errorf(status.Internal, "failed to update zone to store") + } + + return nil +} + +func (s *SqlStore) DeleteZone(ctx context.Context, accountID, zoneID string) error { + result := s.db.Delete(&zones.Zone{}, accountAndIDQueryCondition, accountID, zoneID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to delete zone from store: %v", result.Error) + return status.Errorf(status.Internal, "failed to delete zone from store") + } + + if result.RowsAffected == 0 { + return status.NewZoneNotFoundError(zoneID) + } + + return nil +} + +func (s *SqlStore) GetZoneByID(ctx context.Context, lockStrength LockingStrength, accountID, zoneID string) (*zones.Zone, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var zone *zones.Zone + result := tx.Preload("Records").Take(&zone, accountAndIDQueryCondition, accountID, zoneID) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewZoneNotFoundError(zoneID) + } + + log.WithContext(ctx).Errorf("failed to get zone from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get zone from store") + } + + return zone, nil +} + +func (s *SqlStore) GetZoneByDomain(ctx context.Context, accountID, domain string) (*zones.Zone, error) { + var zone *zones.Zone + result := s.db.Where("account_id = ? AND domain = ?", accountID, domain).First(&zone) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, status.NewZoneNotFoundError(domain) + } + + log.WithContext(ctx).Errorf("failed to get zone by domain from store: %v", result.Error) + return nil, status.Errorf(status.Internal, "failed to get zone by domain from store") + } + + return zone, nil +} + +func (s *SqlStore) GetAccountZones(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*zones.Zone, error) { + tx := s.db + if lockStrength != LockingStrengthNone { + tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + var zones []*zones.Zone + result := tx.Preload("Records").Find(&zones, accountIDCondition, accountID) + if result.Error != nil { + log.WithContext(ctx).Errorf("failed to get zones from the store: %s", result.Error) + return nil, status.Errorf(status.Internal, "failed to get zones from store") + } + + return zones, nil +} diff --git a/management/server/store/sql_store_zone_test.go b/management/server/store/sql_store_zone_test.go new file mode 100644 index 000000000..da2ef12e6 --- /dev/null +++ b/management/server/store/sql_store_zone_test.go @@ -0,0 +1,238 @@ +package store + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/zones" + "github.com/netbirdio/netbird/shared/management/status" +) + +func TestSqlStore_CreateZone(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) + + err = store.CreateZone(context.Background(), zone) + require.NoError(t, err) + + savedZone, err := store.GetZoneByID(context.Background(), LockingStrengthNone, accountID, zone.ID) + require.NoError(t, err) + require.NotNil(t, savedZone) + assert.Equal(t, zone.ID, savedZone.ID) + assert.Equal(t, zone.Name, savedZone.Name) + assert.Equal(t, zone.Domain, savedZone.Domain) + assert.Equal(t, zone.Enabled, savedZone.Enabled) + assert.Equal(t, zone.EnableSearchDomain, savedZone.EnableSearchDomain) + assert.Equal(t, zone.DistributionGroups, savedZone.DistributionGroups) +} + +func TestSqlStore_GetZoneByID(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) + err = store.CreateZone(context.Background(), zone) + require.NoError(t, err) + + tests := []struct { + name string + accountID string + zoneID string + expectError bool + }{ + { + name: "retrieve existing zone", + accountID: accountID, + zoneID: zone.ID, + expectError: false, + }, + { + name: "retrieve non-existing zone", + accountID: accountID, + zoneID: "non-existing", + expectError: true, + }, + { + name: "retrieve with empty zone ID", + accountID: accountID, + zoneID: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savedZone, err := store.GetZoneByID(context.Background(), LockingStrengthNone, tt.accountID, tt.zoneID) + if tt.expectError { + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) + require.Nil(t, savedZone) + } else { + require.NoError(t, err) + require.NotNil(t, savedZone) + assert.Equal(t, tt.zoneID, savedZone.ID) + } + }) + } +} + +func TestSqlStore_GetAccountZones(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + zone1 := zones.NewZone(accountID, "Zone 1", "example1.com", true, false, []string{"group1"}) + err = store.CreateZone(context.Background(), zone1) + require.NoError(t, err) + + zone2 := zones.NewZone(accountID, "Zone 2", "example2.com", true, true, []string{"group1", "group2"}) + err = store.CreateZone(context.Background(), zone2) + require.NoError(t, err) + + allZones, err := store.GetAccountZones(context.Background(), LockingStrengthNone, accountID) + require.NoError(t, err) + require.NotNil(t, allZones) + assert.GreaterOrEqual(t, len(allZones), 2) + + zoneIDs := make(map[string]bool) + for _, z := range allZones { + zoneIDs[z.ID] = true + } + assert.True(t, zoneIDs[zone1.ID]) + assert.True(t, zoneIDs[zone2.ID]) +} + +func TestSqlStore_GetZoneByDomain(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + otherAccountID := "bf1c8084-ba50-4ce7-9439-34653001fc3c" + + zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) + err = store.CreateZone(context.Background(), zone) + require.NoError(t, err) + + tests := []struct { + name string + accountID string + domain string + expectError bool + errorType status.Type + }{ + { + name: "retrieve existing zone by domain", + accountID: accountID, + domain: "example.com", + expectError: false, + }, + { + name: "retrieve non-existing zone domain", + accountID: accountID, + domain: "non-existing.com", + expectError: true, + errorType: status.NotFound, + }, + { + name: "retrieve with empty domain", + accountID: accountID, + domain: "", + expectError: true, + errorType: status.NotFound, + }, + { + name: "retrieve with different account ID", + accountID: otherAccountID, + domain: "example.com", + expectError: true, + errorType: status.NotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + savedZone, err := store.GetZoneByDomain(context.Background(), tt.accountID, tt.domain) + if tt.expectError { + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, tt.errorType, sErr.Type()) + require.Nil(t, savedZone) + } else { + require.NoError(t, err) + require.NotNil(t, savedZone) + assert.Equal(t, tt.domain, savedZone.Domain) + assert.Equal(t, zone.ID, savedZone.ID) + assert.Equal(t, zone.Name, savedZone.Name) + } + }) + } +} + +func TestSqlStore_UpdateZone(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) + err = store.CreateZone(context.Background(), zone) + require.NoError(t, err) + + zone.Name = "Updated Zone" + zone.Domain = "updated.com" + zone.Enabled = false + zone.EnableSearchDomain = true + zone.DistributionGroups = []string{"group2", "group3"} + + err = store.UpdateZone(context.Background(), zone) + require.NoError(t, err) + + updatedZone, err := store.GetZoneByID(context.Background(), LockingStrengthNone, accountID, zone.ID) + require.NoError(t, err) + require.NotNil(t, updatedZone) + assert.Equal(t, "Updated Zone", updatedZone.Name) + assert.Equal(t, "updated.com", updatedZone.Domain) + assert.False(t, updatedZone.Enabled) + assert.True(t, updatedZone.EnableSearchDomain) + assert.Equal(t, []string{"group2", "group3"}, updatedZone.DistributionGroups) +} + +func TestSqlStore_DeleteZone(t *testing.T) { + store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/extended-store.sql", t.TempDir()) + t.Cleanup(cleanup) + require.NoError(t, err) + + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + + zone := zones.NewZone(accountID, "Test Zone", "example.com", true, false, []string{"group1"}) + err = store.CreateZone(context.Background(), zone) + require.NoError(t, err) + + err = store.DeleteZone(context.Background(), accountID, zone.ID) + require.NoError(t, err) + + deletedZone, err := store.GetZoneByID(context.Background(), LockingStrengthNone, accountID, zone.ID) + require.Error(t, err) + require.Nil(t, deletedZone) + sErr, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, sErr.Type(), status.NotFound) +} From 222c6d53e24ddfff1a52ad59fdb853dfb67c460e Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Thu, 24 Sep 2026 20:48:40 +0300 Subject: [PATCH 11/33] [management] Refuse HTTP redirects on IdP clients (#7579) --- management/server/idp/auth0.go | 8 +------- management/server/idp/authentik.go | 8 +------- management/server/idp/azure.go | 8 +------- management/server/idp/dex.go | 10 +--------- management/server/idp/google_workspace.go | 9 +-------- management/server/idp/jumpcloud.go | 8 +------- management/server/idp/keycloak.go | 8 +------- management/server/idp/okta.go | 8 +------- management/server/idp/pocketid.go | 8 +------- management/server/idp/util.go | 19 +++++++++++++++++++ management/server/idp/zitadel.go | 8 +------- 11 files changed, 29 insertions(+), 73 deletions(-) diff --git a/management/server/idp/auth0.go b/management/server/idp/auth0.go index 7d3837190..4d6ef5859 100644 --- a/management/server/idp/auth0.go +++ b/management/server/idp/auth0.go @@ -132,13 +132,7 @@ type ConnectionOptions struct { // NewAuth0Manager creates a new instance of the Auth0Manager func NewAuth0Manager(config Auth0ClientConfig, appMetrics telemetry.AppMetrics) (*Auth0Manager, error) { - httpTransport := http.DefaultTransport.(*http.Transport).Clone() - httpTransport.MaxIdleConns = 5 - - httpClient := &http.Client{ - Timeout: idpTimeout(), - Transport: httpTransport, - } + httpClient := newHTTPClient() helper := JsonParser{} diff --git a/management/server/idp/authentik.go b/management/server/idp/authentik.go index ebd79b715..9ab884bc7 100644 --- a/management/server/idp/authentik.go +++ b/management/server/idp/authentik.go @@ -49,13 +49,7 @@ type AuthentikCredentials struct { // NewAuthentikManager creates a new instance of the AuthentikManager. func NewAuthentikManager(config AuthentikClientConfig, appMetrics telemetry.AppMetrics) (*AuthentikManager, error) { - httpTransport := http.DefaultTransport.(*http.Transport).Clone() - httpTransport.MaxIdleConns = 5 - - httpClient := &http.Client{ - Timeout: idpTimeout(), - Transport: httpTransport, - } + httpClient := newHTTPClient() helper := JsonParser{} diff --git a/management/server/idp/azure.go b/management/server/idp/azure.go index 320ca7a83..6640ef6d0 100644 --- a/management/server/idp/azure.go +++ b/management/server/idp/azure.go @@ -54,13 +54,7 @@ type azureProfile map[string]any // NewAzureManager creates a new instance of the AzureManager. func NewAzureManager(config AzureClientConfig, appMetrics telemetry.AppMetrics) (*AzureManager, error) { - httpTransport := http.DefaultTransport.(*http.Transport).Clone() - httpTransport.MaxIdleConns = 5 - - httpClient := &http.Client{ - Timeout: idpTimeout(), - Transport: httpTransport, - } + httpClient := newHTTPClient() helper := JsonParser{} diff --git a/management/server/idp/dex.go b/management/server/idp/dex.go index 0cac246e1..7d25c6ed0 100644 --- a/management/server/idp/dex.go +++ b/management/server/idp/dex.go @@ -4,10 +4,8 @@ import ( "context" "encoding/base64" "fmt" - "net/http" "strings" "sync" - "time" "github.com/dexidp/dex/api/v2" log "github.com/sirupsen/logrus" @@ -44,13 +42,7 @@ func NewDexManager(config DexClientConfig, appMetrics telemetry.AppMetrics) (*De return nil, fmt.Errorf("dex IdP configuration is incomplete, GRPCAddr is missing") } - httpTransport := http.DefaultTransport.(*http.Transport).Clone() - httpTransport.MaxIdleConns = 5 - - httpClient := &http.Client{ - Timeout: 10 * time.Second, - Transport: httpTransport, - } + httpClient := newHTTPClient() helper := JsonParser{} return &DexManager{ diff --git a/management/server/idp/google_workspace.go b/management/server/idp/google_workspace.go index dadbfd83e..ff58e7772 100644 --- a/management/server/idp/google_workspace.go +++ b/management/server/idp/google_workspace.go @@ -4,7 +4,6 @@ import ( "context" "encoding/base64" "fmt" - "net/http" log "github.com/sirupsen/logrus" "golang.org/x/oauth2/google" @@ -44,13 +43,7 @@ func (gc *GoogleWorkspaceCredentials) Authenticate(_ context.Context) (JWTToken, // NewGoogleWorkspaceManager creates a new instance of the GoogleWorkspaceManager. func NewGoogleWorkspaceManager(ctx context.Context, config GoogleWorkspaceClientConfig, appMetrics telemetry.AppMetrics) (*GoogleWorkspaceManager, error) { - httpTransport := http.DefaultTransport.(*http.Transport).Clone() - httpTransport.MaxIdleConns = 5 - - httpClient := &http.Client{ - Timeout: idpTimeout(), - Transport: httpTransport, - } + httpClient := newHTTPClient() helper := JsonParser{} diff --git a/management/server/idp/jumpcloud.go b/management/server/idp/jumpcloud.go index f0dec3a9b..ac547e4a1 100644 --- a/management/server/idp/jumpcloud.go +++ b/management/server/idp/jumpcloud.go @@ -58,13 +58,7 @@ type JumpCloudCredentials struct { // NewJumpCloudManager creates a new instance of the JumpCloudManager. func NewJumpCloudManager(config JumpCloudClientConfig, appMetrics telemetry.AppMetrics) (*JumpCloudManager, error) { - httpTransport := http.DefaultTransport.(*http.Transport).Clone() - httpTransport.MaxIdleConns = 5 - - httpClient := &http.Client{ - Timeout: idpTimeout(), - Transport: httpTransport, - } + httpClient := newHTTPClient() helper := JsonParser{} diff --git a/management/server/idp/keycloak.go b/management/server/idp/keycloak.go index 1cf26394f..9c01fcee2 100644 --- a/management/server/idp/keycloak.go +++ b/management/server/idp/keycloak.go @@ -59,13 +59,7 @@ type keycloakProfile struct { // NewKeycloakManager creates a new instance of the KeycloakManager. func NewKeycloakManager(config KeycloakClientConfig, appMetrics telemetry.AppMetrics) (*KeycloakManager, error) { - httpTransport := http.DefaultTransport.(*http.Transport).Clone() - httpTransport.MaxIdleConns = 5 - - httpClient := &http.Client{ - Timeout: idpTimeout(), - Transport: httpTransport, - } + httpClient := newHTTPClient() helper := JsonParser{} diff --git a/management/server/idp/okta.go b/management/server/idp/okta.go index 07f0d8008..90bcd05a9 100644 --- a/management/server/idp/okta.go +++ b/management/server/idp/okta.go @@ -40,13 +40,7 @@ type OktaCredentials struct { // NewOktaManager creates a new instance of the OktaManager. func NewOktaManager(config OktaClientConfig, appMetrics telemetry.AppMetrics) (*OktaManager, error) { - httpTransport := http.DefaultTransport.(*http.Transport).Clone() - httpTransport.MaxIdleConns = 5 - - httpClient := &http.Client{ - Timeout: idpTimeout(), - Transport: httpTransport, - } + httpClient := newHTTPClient() helper := JsonParser{} config.Issuer = baseURL(config.Issuer) diff --git a/management/server/idp/pocketid.go b/management/server/idp/pocketid.go index fc338b86b..b340bfe5f 100644 --- a/management/server/idp/pocketid.go +++ b/management/server/idp/pocketid.go @@ -83,13 +83,7 @@ type pocketIdUserGroupDto struct { } func NewPocketIdManager(config PocketIdClientConfig, appMetrics telemetry.AppMetrics) (*PocketIdManager, error) { - httpTransport := http.DefaultTransport.(*http.Transport).Clone() - httpTransport.MaxIdleConns = 5 - - httpClient := &http.Client{ - Timeout: idpTimeout(), - Transport: httpTransport, - } + httpClient := newHTTPClient() helper := JsonParser{} diff --git a/management/server/idp/util.go b/management/server/idp/util.go index 6545c2a69..be59edba7 100644 --- a/management/server/idp/util.go +++ b/management/server/idp/util.go @@ -2,6 +2,8 @@ package idp import ( "encoding/json" + "errors" + "net/http" "net/url" "os" "strings" @@ -81,6 +83,23 @@ const ( defaultTimeout = 10 * time.Second ) +// errRedirectRefused is returned instead of http.ErrUseLastResponse so the +// client closes the redirect response rather than handing it back unread. +var errRedirectRefused = errors.New("redirect refused") + +func newHTTPClient() *http.Client { + httpTransport := http.DefaultTransport.(*http.Transport).Clone() + httpTransport.MaxIdleConns = 5 + + return &http.Client{ + Timeout: idpTimeout(), + Transport: httpTransport, + CheckRedirect: func(*http.Request, []*http.Request) error { + return errRedirectRefused + }, + } +} + // idpTimeout returns a timeout value for the IDP func idpTimeout() time.Duration { timeoutStr, ok := os.LookupEnv(idpTimeoutEnv) diff --git a/management/server/idp/zitadel.go b/management/server/idp/zitadel.go index 320f0c131..fdc59915d 100644 --- a/management/server/idp/zitadel.go +++ b/management/server/idp/zitadel.go @@ -160,13 +160,7 @@ func verifyJWTConfig(config ZitadelClientConfig) error { // NewZitadelManager creates a new instance of the ZitadelManager. func NewZitadelManager(config ZitadelClientConfig, appMetrics telemetry.AppMetrics) (*ZitadelManager, error) { - httpTransport := http.DefaultTransport.(*http.Transport).Clone() - httpTransport.MaxIdleConns = 5 - - httpClient := &http.Client{ - Timeout: idpTimeout(), - Transport: httpTransport, - } + httpClient := newHTTPClient() helper := JsonParser{} From ad7598a7d7b82238dab6d8fb1b5ecf3665a2f53a Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Thu, 24 Sep 2026 21:21:55 +0300 Subject: [PATCH 12/33] [management] Revoke local Dex session on embedded IdP password change (#7556) --- idp/dex/provider.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/idp/dex/provider.go b/idp/dex/provider.go index f40b96a58..95c511c01 100644 --- a/idp/dex/provider.go +++ b/idp/dex/provider.go @@ -737,6 +737,10 @@ func (p *Provider) UpdateUserPassword(ctx context.Context, userID string, oldPas return fmt.Errorf("failed to update password: %w", err) } + if err := p.storage.DeleteAuthSession(ctx, user.UserID, server.LocalConnector); err != nil && !errors.Is(err, storage.ErrNotFound) { + p.logger.Error("failed to revoke local session after password change", "error", err) + } + return nil } From c7f610e6cd33809be041a0eb3d7ac220f71f5285 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:21:20 +0200 Subject: [PATCH 13/33] [misc, android] Build and lint the mobile Go code in CI (#7641) Nothing in CI compiles the files behind //go:build android or //go:build ios. The android bridge builds 7 of its 23 files on linux and skips client.go; the iOS SDK is not built at all. The linter matrix picks a GOOS by picking a runner OS, so it loads the same file set as the host build and never sees them either. A type error in client/android/client.go therefore passes every check on its PR, merges, and is discovered by netbirdio/android-client after sync-tag.yml fires trigger_android_bump on the release tag. The new Mobile workflow cross-compiles ./client/android/... for the GOARCH values gomobile ships and ./client/ios/..., and vets the android bridge. The new Android and iOS lint jobs run golangci-lint with GOOS/GOARCH in the job env. No NDK, Xcode or gomobile is needed: these are library packages, so the compiler type-checks them without a link step, and the dependency graph drags in the android/ios-tagged files across client/iface, client/internal/dns and client/internal/routemanager with them. Linting those files for the first time surfaces one gosec G101 on the SSH password-required marker. It is a sentinel string the Java side matches on, not a credential, so it is suppressed at the declaration. --- .github/workflows/golangci-lint.yml | 46 +++++++++++++ .github/workflows/mobile-build-validation.yml | 64 +++++++++++++++++++ client/android/ssh_client.go | 2 + 3 files changed, 112 insertions(+) create mode 100644 .github/workflows/mobile-build-validation.yml diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 586e1235b..ff36a0854 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -80,3 +80,49 @@ jobs: skip-save-cache: true cache-invalidation-interval: 0 args: --timeout=20m + + # Separate job rather than extra rows in the matrix above: those rows pick a + # GOOS by picking a runner OS, while android/ios are cross-compiled from + # ubuntu — an `include` entry with os: ubuntu-latest would merge into the + # Linux row instead of adding one. The package path is restricted because a + # whole-repo run under GOOS=android pulls *_linux.go files into packages that + # have no android counterpart. + golangci-mobile: + strategy: + fail-fast: false + matrix: + include: + - goos: android + goarch: arm64 + packages: ./client/android/... + display_name: Android + - goos: ios + goarch: arm64 + packages: ./client/ios/... + display_name: iOS + name: ${{ matrix.display_name }} + runs-on: ubuntu-latest + timeout-minutes: 25 + env: + CGO_ENABLED: 0 + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Install Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: "go.mod" + cache: false + - name: golangci-lint + uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1 + with: + version: latest + install-mode: binary + skip-cache: true + skip-save-cache: true + cache-invalidation-interval: 0 + args: --timeout=20m ${{ matrix.packages }} diff --git a/.github/workflows/mobile-build-validation.yml b/.github/workflows/mobile-build-validation.yml new file mode 100644 index 000000000..613a39b3d --- /dev/null +++ b/.github/workflows/mobile-build-validation.yml @@ -0,0 +1,64 @@ +name: Mobile + +on: + push: + branches: + - main + - "release-*" + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }} + cancel-in-progress: true + +jobs: + android_build: + name: "Android / Build" + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + goarch: [arm64, arm, amd64, "386"] + env: + CGO_ENABLED: 0 + GOOS: android + GOARCH: ${{ matrix.goarch }} + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Install Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: "go.mod" + - name: Build Android bridge + run: go build ./client/android/... + - name: Vet Android bridge + if: matrix.goarch == 'arm64' + run: go vet ./client/android/... + + ios_build: + name: "iOS / Build" + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + goarch: [arm64, amd64] + env: + CGO_ENABLED: 0 + GOOS: ios + GOARCH: ${{ matrix.goarch }} + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Install Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: "go.mod" + # No `go vet` counterpart: every ios target requires external (cgo) + # linking, which needs an Xcode toolchain the runner does not have. + - name: Build iOS SDK + run: go build ./client/ios/... diff --git a/client/android/ssh_client.go b/client/android/ssh_client.go index 2822b6539..9a11044ba 100644 --- a/client/android/ssh_client.go +++ b/client/android/ssh_client.go @@ -31,6 +31,8 @@ const ( // PasswordRequiredMarker tells Java to prompt for a password and retry. It is // a string because gomobile flattens errors to their message, so a sentinel // value would not survive the binding. +// +//nolint:gosec // G101 false positive: a sentinel marker, not a credential const PasswordRequiredMarker = "netbird-ssh-password-required" // HostKeyUnknownMarker tells Java to show the fingerprint and, on confirmation, From aa1e66cc8888ec719d45fd2c018eebc05412d2fe Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:17:34 +0200 Subject: [PATCH 14/33] [client] Raise the daemon IPC receive limit above gRPC's 4 MB default (#7676) Connections to the daemon were left on gRPC's own defaults, which cap a received message at 4 MB. A detailed status carries an entry per peer, so on a large deployment the response outgrows that cap and the command fails outright: netbird status -d Error: status failed: grpc: received message larger than max (4287609 vs. 4194304) The limit is raised where the daemon dial options are built, so every caller inherits it: the CLI, the desktop UI, the JSON gateway, and the SSH client and proxy. It is overridable through NB_DAEMON_GRPC_MAX_MSG_SIZE for a deployment that outgrows the new default too, mirroring what the management client already does with NB_MANAGEMENT_GRPC_MAX_MSG_SIZE, and reusing its 16 MB default. Only the receive direction needs raising. Requests to the daemon are small, and gRPC does not cap the send side by default, so the daemon could already send a response the caller then refused to read. --- client/internal/daemonaddr/grpc.go | 42 +++++++++ client/internal/daemonaddr/grpc_test.go | 112 ++++++++++++++++++++++++ client/internal/daemonaddr/pipe.go | 5 +- 3 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 client/internal/daemonaddr/grpc.go create mode 100644 client/internal/daemonaddr/grpc_test.go diff --git a/client/internal/daemonaddr/grpc.go b/client/internal/daemonaddr/grpc.go new file mode 100644 index 000000000..5f0cc10cc --- /dev/null +++ b/client/internal/daemonaddr/grpc.go @@ -0,0 +1,42 @@ +package daemonaddr + +import ( + "os" + "strconv" + + log "github.com/sirupsen/logrus" +) + +const ( + // EnvMaxRecvMsgSize overrides the default gRPC max receive message size for + // connections to the daemon. Value is in bytes. + EnvMaxRecvMsgSize = "NB_DAEMON_GRPC_MAX_MSG_SIZE" + + // defaultMaxRecvMsgSize is the max gRPC receive message size used for daemon + // connections when EnvMaxRecvMsgSize is unset or invalid. It overrides the + // gRPC library default of 4 MB, which a detailed status already exceeds on a + // network of a few thousand peers. + defaultMaxRecvMsgSize = 1024 * 1024 * 16 +) + +// MaxRecvMsgSize returns the max gRPC receive message size for daemon connections +// from the environment, or defaultMaxRecvMsgSize (16 MB) if unset or invalid. +func MaxRecvMsgSize() int { + val := os.Getenv(EnvMaxRecvMsgSize) + if val == "" { + return defaultMaxRecvMsgSize + } + + size, err := strconv.Atoi(val) + if err != nil { + log.Warnf("invalid %s value %q, using default: %v", EnvMaxRecvMsgSize, val, err) + return defaultMaxRecvMsgSize + } + + if size <= 0 { + log.Warnf("invalid %s value %d, must be positive, using default", EnvMaxRecvMsgSize, size) + return defaultMaxRecvMsgSize + } + + return size +} diff --git a/client/internal/daemonaddr/grpc_test.go b/client/internal/daemonaddr/grpc_test.go new file mode 100644 index 000000000..7c4909a42 --- /dev/null +++ b/client/internal/daemonaddr/grpc_test.go @@ -0,0 +1,112 @@ +package daemonaddr + +import ( + "context" + "net" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/proto" +) + +func TestMaxRecvMsgSize(t *testing.T) { + tests := []struct { + name string + envValue string + expected int + }{ + {name: "unset returns default", envValue: "", expected: defaultMaxRecvMsgSize}, + {name: "non-numeric returns default", envValue: "abc", expected: defaultMaxRecvMsgSize}, + {name: "negative returns default", envValue: "-1", expected: defaultMaxRecvMsgSize}, + {name: "zero returns default", envValue: "0", expected: defaultMaxRecvMsgSize}, + {name: "valid value is used", envValue: "33554432", expected: 33554432}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Set first so the previous value is restored on cleanup, then unset to + // exercise the absent case. + t.Setenv(EnvMaxRecvMsgSize, tc.envValue) + if tc.envValue == "" { + require.NoError(t, os.Unsetenv(EnvMaxRecvMsgSize), "unset the override") + } + + assert.Equal(t, tc.expected, MaxRecvMsgSize(), "max receive message size") + }) + } +} + +// bigStatusServer answers Status with a response larger than gRPC's 4 MB default +// receive limit, which is what a detailed status on a large network looks like. +type bigStatusServer struct { + proto.UnimplementedDaemonServiceServer + payload string +} + +func (s *bigStatusServer) Status(context.Context, *proto.StatusRequest) (*proto.StatusResponse, error) { + return &proto.StatusResponse{Status: s.payload}, nil +} + +func startBigStatusServer(t *testing.T, payload string) string { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err, "listen on loopback") + + srv := grpc.NewServer() + proto.RegisterDaemonServiceServer(srv, &bigStatusServer{payload: payload}) + go func() { + _ = srv.Serve(listener) + }() + t.Cleanup(srv.Stop) + + return "tcp://" + listener.Addr().String() +} + +func TestDialTargetAcceptsAStatusOverTheGrpcDefault(t *testing.T) { + payload := strings.Repeat("x", 5*1024*1024) + addr := startBigStatusServer(t, payload) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + target, opts := DialTarget(addr) + conn, err := grpc.NewClient(target, opts...) + require.NoError(t, err, "dial the daemon") + t.Cleanup(func() { _ = conn.Close() }) + + resp, err := proto.NewDaemonServiceClient(conn).Status(ctx, &proto.StatusRequest{}) + require.NoError(t, err, "a detailed status must not be rejected for its size") + assert.Len(t, resp.GetStatus(), len(payload), "the whole response must arrive") +} + +// TestDialTargetRaisesTheDefaultLimit is the negative control: the same response +// over a connection carrying gRPC's own defaults is refused, which is the failure +// reported by `netbird status -d` on a large deployment. +func TestDialTargetRaisesTheDefaultLimit(t *testing.T) { + payload := strings.Repeat("x", 5*1024*1024) + addr := startBigStatusServer(t, payload) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + conn, err := grpc.NewClient( + strings.TrimPrefix(addr, "tcp://"), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err, "dial with the library defaults") + t.Cleanup(func() { _ = conn.Close() }) + + _, err = proto.NewDaemonServiceClient(conn).Status(ctx, &proto.StatusRequest{}) + require.Error(t, err, "the library default must reject this response") + assert.Equal(t, codes.ResourceExhausted, status.Code(err), "gRPC rejects an oversized message") +} diff --git a/client/internal/daemonaddr/pipe.go b/client/internal/daemonaddr/pipe.go index 51815ef5e..bf1c8fdd0 100644 --- a/client/internal/daemonaddr/pipe.go +++ b/client/internal/daemonaddr/pipe.go @@ -36,7 +36,10 @@ const ( // address. The npipe scheme needs a context dialer because gRPC has no // named-pipe resolver; unix and tcp are handled by gRPC itself. func DialTarget(addr string) (string, []grpc.DialOption) { - opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())} + opts := []grpc.DialOption{ + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(MaxRecvMsgSize())), + } if name, ok := strings.CutPrefix(addr, pipeScheme); ok { paths := PipePaths(name) From 6cf07caaf600118d18e783826b628ffe248aaf5e Mon Sep 17 00:00:00 2001 From: Brad Ison Date: Fri, 25 Sep 2026 12:15:59 +0200 Subject: [PATCH 15/33] [misc] Build the upload server from source, nonroot on Chainguard (#7663) upload-server/Dockerfile only packaged the goreleaser-built binary, so the image could not be built from a checkout. It is now a multi-stage build on Chainguard static, running as the nonroot user (uid 65532), with a VARIANT=debug build arg that swaps in busybox for a shell. The goreleaser packaging file moves unchanged to Dockerfile.release and .goreleaser.yaml points at it, so the published netbirdio/upload image stays as it was: distroless and root. The bases are pinned by digest, since Chainguard publishes only :latest for free. A Dependabot docker entry for /upload-server moves them weekly, leaving the release base alone and holding golang to patch updates. --- .github/dependabot.yml | 22 +++++++++++++ .goreleaser.yaml | 2 +- upload-server/Dockerfile | 55 +++++++++++++++++++++++++++++--- upload-server/Dockerfile.release | 4 +++ 4 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 upload-server/Dockerfile.release diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 647e04936..ded77ec58 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -46,3 +46,25 @@ updates: wireguard: patterns: - "golang.zx2c4.com/wireguard*" + + # Base images of the source-build Dockerfiles, pinned by digest (Chainguard + # publishes only :latest for free). Dockerfile.release files feed goreleaser + # and keep the published images as they are, so their bases are left alone. + - package-ecosystem: "docker" + directories: + - "/upload-server" + schedule: + interval: "weekly" + open-pull-requests-limit: 3 + groups: + base-images: + patterns: + - "*" + ignore: + - dependency-name: "gcr.io/distroless/base" + # Go minor and major versions move with the rest of the repository; + # patch releases and new digests of the pinned tag still come through. + - dependency-name: "golang" + update-types: + - "version-update:semver-minor" + - "version-update:semver-major" diff --git a/.goreleaser.yaml b/.goreleaser.yaml index b6c563968..a08a7e92a 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -477,7 +477,7 @@ dockers_v2: tags: - "{{ .Version }}" - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}" - dockerfile: upload-server/Dockerfile + dockerfile: upload-server/Dockerfile.release platforms: - linux/amd64 - linux/arm64 diff --git a/upload-server/Dockerfile b/upload-server/Dockerfile index 3713d6f2a..8098a0186 100644 --- a/upload-server/Dockerfile +++ b/upload-server/Dockerfile @@ -1,4 +1,51 @@ -FROM gcr.io/distroless/base:debug -ENTRYPOINT [ "/go/bin/netbird-upload" ] -ARG TARGETPLATFORM -COPY ${TARGETPLATFORM}/netbird-upload /go/bin/netbird-upload +# syntax=docker/dockerfile:1 + +# Builds the upload server from source. Run it from the repository root: +# +# docker build -f upload-server/Dockerfile . +# +# Releases package the goreleaser-built binary with Dockerfile.release instead, +# which keeps the published image as it was (distroless base, running as root). +# +# The image runs as the base image's nonroot user (uid 65532), which owns the +# default STORE_DIR, /var/lib/netbird. A volume mounted there must be writable +# by that uid: a named Docker volume takes the directory's ownership on first +# use, a bind mount needs chown, and Kubernetes needs fsGroup: 65532. +# +# Build args: +# VARIANT=release|debug debug swaps the base for Chainguard busybox (a shell) +# VERSION stamped into the binary the same way goreleaser does +# +# Chainguard publishes only :latest for free, so the bases are pinned by digest +# and moved by Dependabot. + +ARG VARIANT=release + +# Pure Go: cross-compile from the build host instead of emulating the target. +FROM --platform=$BUILDPLATFORM golang:1.26.7-bookworm@sha256:e8c859f5632dcfde7b32d2012b4351728f6437930887c2f6a91ea242459e5514 AS builder +WORKDIR /app + +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod go mod download + +COPY . . +ARG TARGETOS +ARG TARGETARCH +ARG VERSION=development +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath \ + -ldflags "-s -w -X github.com/netbirdio/netbird/version.version=${VERSION}" \ + -o /out/netbird-upload ./upload-server \ + && mkdir -p /out/var/lib/netbird + +FROM cgr.dev/chainguard/static:latest@sha256:41e17ed83c594a64a9396b6ab96dd26d5ddc290dacf4c177464712ff21ad534f AS base-release +FROM cgr.dev/chainguard/busybox:latest@sha256:b2953ab1cae4a6265e18cf675851bd99975211b150d7a014774911d76eb309ba AS base-debug + +# hadolint ignore=DL3006 +FROM base-${VARIANT} +COPY --from=builder --chown=65532:65532 /out/var/lib/netbird /var/lib/netbird +COPY --from=builder /out/netbird-upload /go/bin/netbird-upload +WORKDIR /var/lib/netbird +USER 65532:65532 +ENTRYPOINT ["/go/bin/netbird-upload"] diff --git a/upload-server/Dockerfile.release b/upload-server/Dockerfile.release new file mode 100644 index 000000000..3713d6f2a --- /dev/null +++ b/upload-server/Dockerfile.release @@ -0,0 +1,4 @@ +FROM gcr.io/distroless/base:debug +ENTRYPOINT [ "/go/bin/netbird-upload" ] +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/netbird-upload /go/bin/netbird-upload From d5488db146bfb66aed5d64344007911b67cb05a1 Mon Sep 17 00:00:00 2001 From: Misha Bragin Date: Fri, 25 Sep 2026 19:15:35 +0200 Subject: [PATCH 16/33] [infrastructure] Certify the rootless UBI client image on release (#7525) Inline the job in release.yml, gated on a stable vX.Y.Z tag on the upstream repo so it stays out of main, release branches and pull requests. Its env and contents:read permission move to the job, keeping them off the rest of the workflow. --- .github/workflows/release.yml | 126 ++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e51890db..45405c7a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -370,6 +370,132 @@ jobs: path: dist/netbird_darwin** retention-days: 7 + # Certify and publish the rootless UBI client image in the Red Hat Ecosystem + # Catalog. Stable tags only: goreleaser pushes -rootless-ubi to + # ghcr.io in the release job above, and preflight submits every architecture + # of that manifest list to Pyxis. Auto-publish on the component makes the new + # version public once certification passes. + redhat_certification: + name: "Red Hat / Certify rootless UBI image" + needs: release + if: | + github.repository == 'netbirdio/netbird' && + startsWith(github.ref, 'refs/tags/v') && + !contains(github.ref_name, '-') + runs-on: ubuntu-24.04 + permissions: + contents: read + env: + PREFLIGHT_VERSION: "1.21.0" + # sha256 of preflight-linux-amd64 from the 1.21.0 GitHub release. + # Red Hat publishes no checksum file, so the value is pinned here. + PREFLIGHT_SHA256: "5e653135503c72f8702bbe31d7643197d12937c68086879133dd6b9650a9a449" + IMAGE_REPOSITORY: "ghcr.io/netbirdio/netbird" + # Component "NetBird Client Container Image (rootless)" in Partner Connect. + # Override with the REDHAT_CERT_COMPONENT_ID repository variable if it changes. + DEFAULT_COMPONENT_ID: "6aa3ca4b4676aefdf07aaa97" + steps: + - name: Resolve image reference + id: image + env: + INPUT_VERSION: ${{ github.ref_name }} + run: | + set -euo pipefail + version="${INPUT_VERSION#v}" + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Only stable x.y.z versions are certified, got '${INPUT_VERSION}'" + exit 1 + fi + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "ref=${IMAGE_REPOSITORY}:${version}-rootless-ubi" >> "$GITHUB_OUTPUT" + + - name: Verify the multi-arch image is on ghcr.io + env: + IMAGE_REF: ${{ steps.image.outputs.ref }} + run: | + set -euo pipefail + docker buildx imagetools inspect "$IMAGE_REF" --raw > manifest.json + for arch in amd64 arm64; do + if ! jq -e --arg a "$arch" '.manifests[] | select(.platform.architecture == $a)' manifest.json > /dev/null; then + echo "::error::${IMAGE_REF} has no ${arch} manifest" + exit 1 + fi + done + echo "Manifest list for ${IMAGE_REF}:" + jq -r '.manifests[] | "\(.platform.os)/\(.platform.architecture) \(.digest)"' manifest.json + + - name: Install preflight + run: | + set -euo pipefail + curl -fsSL --proto '=https' --proto-redir '=https' -o preflight \ + "https://github.com/redhat-openshift-ecosystem/openshift-preflight/releases/download/${PREFLIGHT_VERSION}/preflight-linux-amd64" + echo "${PREFLIGHT_SHA256} preflight" | sha256sum -c - + chmod +x preflight + ./preflight --version + + - name: Run preflight checks and submit to Red Hat + env: + IMAGE_REF: ${{ steps.image.outputs.ref }} + PFLT_PYXIS_API_TOKEN: ${{ secrets.PYXIS_API_TOKEN }} + PFLT_CERTIFICATION_COMPONENT_ID: ${{ vars.REDHAT_CERT_COMPONENT_ID || env.DEFAULT_COMPONENT_ID }} + PFLT_ARTIFACTS: artifacts + PFLT_LOGFILE: artifacts/preflight.log + PFLT_LOGLEVEL: info + PFLT_JUNIT: "true" + run: | + set -euo pipefail + # No --platform: preflight walks the manifest list and submits every + # architecture in one run, grouped under one manifest-list digest. + ./preflight check container "$IMAGE_REF" --submit + + - name: Fail if any check did not pass + run: | + set -euo pipefail + shopt -s nullglob + results=(artifacts/results.json artifacts/*/results.json) + if [[ ${#results[@]} -eq 0 ]]; then + echo "::error::preflight produced no results.json" + exit 1 + fi + status=0 + for f in "${results[@]}"; do + arch="$(basename "$(dirname "$f")")" + passed="$(jq -r '.passed' "$f")" + failed="$(jq -r '[.results.failed[]?.name] | join(", ")' "$f")" + echo "${arch}: passed=${passed} ${failed:+failed checks: ${failed}}" + [[ "$passed" == "true" ]] || status=1 + done + exit $status + + - name: Upload preflight artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: redhat-preflight-${{ steps.image.outputs.version }} + path: artifacts/ + retention-days: 30 + + - name: Wait for Pyxis to mark both architectures certified + env: + VERSION: ${{ steps.image.outputs.version }} + PFLT_PYXIS_API_TOKEN: ${{ secrets.PYXIS_API_TOKEN }} + COMPONENT_ID: ${{ vars.REDHAT_CERT_COMPONENT_ID || env.DEFAULT_COMPONENT_ID }} + run: | + set -euo pipefail + tag="${VERSION}-rootless-ubi" + url="https://catalog.redhat.com/api/containers/v1/projects/certification/id/${COMPONENT_ID}/images?page_size=100" + for attempt in $(seq 1 20); do + certified="$(curl -fsS --proto '=https' --proto-redir '=https' -H "X-API-KEY: ${PFLT_PYXIS_API_TOKEN}" "$url" \ + | jq -r --arg t "$tag" '[.data[] | select(.repositories[]?.tags[]?.name == $t) | select(.certified == true) | .architecture] | unique | join(",")')" + echo "attempt ${attempt}: certified architectures for ${tag}: ${certified:-none}" + if [[ "$certified" == "amd64,arm64" ]]; then + echo "Both architectures certified. Auto-publish is enabled on the component, so the catalog updates on its own." + exit 0 + fi + sleep 30 + done + echo "::warning::Pyxis has not marked both architectures certified after 10 minutes. Check https://connect.redhat.com/component/view/${COMPONENT_ID}/images" + release_ui: runs-on: ubuntu-latest outputs: From 4a8c158b500f9d5844f556e46d4ff234d30e48c9 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:36:08 +0900 Subject: [PATCH 17/33] [client] Resolve the shared socket source address through a connected UDP probe socket (#7633) --- sharedsock/sock_linux.go | 103 ++++++--- sharedsock/src_probe_linux.go | 214 +++++++++++++++++ sharedsock/src_probe_linux_test.go | 218 ++++++++++++++++++ sharedsock/src_probe_privileged_linux_test.go | 92 ++++++++ 4 files changed, 595 insertions(+), 32 deletions(-) create mode 100644 sharedsock/src_probe_linux.go create mode 100644 sharedsock/src_probe_linux_test.go create mode 100644 sharedsock/src_probe_privileged_linux_test.go diff --git a/sharedsock/sock_linux.go b/sharedsock/sock_linux.go index 150e8a722..640f813c9 100644 --- a/sharedsock/sock_linux.go +++ b/sharedsock/sock_linux.go @@ -10,13 +10,13 @@ import ( "context" "fmt" "net" + "net/netip" "time" "github.com/google/gopacket" "github.com/google/gopacket/layers" "github.com/mdlayher/socket" log "github.com/sirupsen/logrus" - "github.com/vishvananda/netlink" "golang.org/x/sync/errgroup" "golang.org/x/sys/unix" @@ -33,6 +33,8 @@ type SharedSocket struct { ctx context.Context conn4 *socket.Conn conn6 *socket.Conn + probe4 *srcProbe + probe6 *srcProbe port int mtu uint16 packetDemux chan rcvdPacket @@ -87,14 +89,26 @@ func Listen(port int, filter BPFFilter, mtu uint16) (_ net.PacketConn, err error return nil, fmt.Errorf("set SO_MARK on ipv4 socket: %w", err) } + if rawSock.probe4, err = newSrcProbe(unix.AF_INET); err != nil { + return nil, err + } + var sockErr error rawSock.conn6, sockErr = socket.Socket(unix.AF_INET6, unix.SOCK_RAW, unix.IPPROTO_UDP, "raw_udp6", nil) if sockErr != nil { - log.Errorf("Failed to create ipv6 raw socket: %v", err) + log.Errorf("Failed to create ipv6 raw socket: %v", sockErr) } else { if err = nbnet.SetSocketMark(rawSock.conn6); err != nil { return nil, fmt.Errorf("set SO_MARK on ipv6 socket: %w", err) } + rawSock.probe6, sockErr = newSrcProbe(unix.AF_INET6) + if sockErr != nil { + log.Errorf("Failed to create ipv6 source probe, continuing without ipv6: %v", sockErr) + if closeErr := rawSock.conn6.Close(); closeErr != nil { + log.Debugf("failed to close ipv6 raw socket: %v", closeErr) + } + rawSock.conn6 = nil + } } ipv4Instructions, ipv6Instructions, err := filter.GetInstructions(uint32(rawSock.port)) @@ -121,23 +135,36 @@ func Listen(port int, filter BPFFilter, mtu uint16) (_ net.PacketConn, err error return rawSock, nil } -// resolveSrc returns the source IP the kernel will pick for a packet sent to -// dst by these raw sockets, mirroring the fwmark the kernel will see on send. -func (s *SharedSocket) resolveSrc(dst net.IP) (net.IP, error) { - opts := &netlink.RouteGetOptions{} - if nbnet.AdvancedRouting() { - opts.Mark = nbnet.ControlPlaneMark +// sockaddr returns the raw send address for dst, carrying the scope of its zone. +func (s *SharedSocket) sockaddr(dst netip.Addr) (unix.Sockaddr, error) { + if dst.Zone() == "" { + return rawSockaddr(dst, 0), nil } - routes, err := netlink.RouteGetWithOptions(dst, opts) + if s.conn6 == nil { + return nil, fmt.Errorf("no raw socket for %s", dst) + } + rc, err := s.conn6.SyscallConn() if err != nil { - return nil, fmt.Errorf("route get %s: %w", dst, err) + return nil, fmt.Errorf("ipv6 raw socket: %w", err) } - for _, r := range routes { - if r.Src != nil { - return r.Src, nil - } + scope, err := zoneIndex(rc, dst.Zone()) + if err != nil { + return nil, err } - return nil, fmt.Errorf("no source IP for %s", dst) + return rawSockaddr(dst, scope), nil +} + +// resolveSrc returns the source IP the kernel will pick for a packet sent to sa +// by these raw sockets, mirroring the fwmark the kernel will see on send. +func (s *SharedSocket) resolveSrc(dst netip.Addr, sa unix.Sockaddr) (netip.Addr, error) { + probe := s.probe4 + if dst.Is6() { + probe = s.probe6 + } + if probe == nil { + return netip.Addr{}, fmt.Errorf("no raw socket for %s", dst) + } + return probe.resolve(sa) } // LocalAddr returns the local address, preferring IPv4 for backward compatibility. @@ -222,6 +249,13 @@ func (s *SharedSocket) Close() error { if s.conn6 != nil { errGrp.Go(s.conn6.Close) } + + if s.probe4 != nil { + errGrp.Go(s.probe4.close) + } + if s.probe6 != nil { + errGrp.Go(s.probe6.close) + } return errGrp.Wait() } @@ -296,14 +330,24 @@ func (s *SharedSocket) WriteTo(buf []byte, rAddr net.Addr) (n int, err error) { DstPort: layers.UDPPort(rUDPAddr.Port), } - src, err := s.resolveSrc(rUDPAddr.IP) - if err != nil { - return 0, fmt.Errorf("resolve source for %s: %w", rUDPAddr.IP, err) + dst := rUDPAddr.AddrPort().Addr().Unmap() + if !dst.IsValid() { + return 0, fmt.Errorf("invalid destination %s", rUDPAddr) } - rSockAddr, conn, nwLayer := s.getWriterObjects(src, rUDPAddr.IP) + rSockAddr, err := s.sockaddr(dst) + if err != nil { + return 0, err + } + + src, err := s.resolveSrc(dst, rSockAddr) + if err != nil { + return 0, fmt.Errorf("resolve source for %s: %w", dst, err) + } + + conn, nwLayer := s.getWriterObjects(src, dst) if conn == nil { - return 0, fmt.Errorf("no raw socket for %s", rUDPAddr.IP) + return 0, fmt.Errorf("no raw socket for %s", dst) } if err := udp.SetNetworkLayerForChecksum(nwLayer); err != nil { @@ -320,28 +364,23 @@ func (s *SharedSocket) WriteTo(buf []byte, rAddr net.Addr) (n int, err error) { } // getWriterObjects returns the specific IP version objects that are used to build a packet and send it using the raw socket -func (s *SharedSocket) getWriterObjects(src, dest net.IP) (sa unix.Sockaddr, conn *socket.Conn, layer gopacket.NetworkLayer) { - if dest.To4() == nil { - sa = &unix.SockaddrInet6{} - copy(sa.(*unix.SockaddrInet6).Addr[:], dest.To16()) +func (s *SharedSocket) getWriterObjects(src, dest netip.Addr) (conn *socket.Conn, layer gopacket.NetworkLayer) { + if dest.Is6() { conn = s.conn6 - layer = &layers.IPv6{ - SrcIP: src, - DstIP: dest, + SrcIP: src.AsSlice(), + DstIP: dest.AsSlice(), } } else { - sa = &unix.SockaddrInet4{} - copy(sa.(*unix.SockaddrInet4).Addr[:], dest.To4()) conn = s.conn4 layer = &layers.IPv4{ Version: 4, TTL: 64, Protocol: layers.IPProtocolUDP, - SrcIP: src, - DstIP: dest, + SrcIP: src.AsSlice(), + DstIP: dest.AsSlice(), } } - return sa, conn, layer + return conn, layer } diff --git a/sharedsock/src_probe_linux.go b/sharedsock/src_probe_linux.go new file mode 100644 index 000000000..5e463b598 --- /dev/null +++ b/sharedsock/src_probe_linux.go @@ -0,0 +1,214 @@ +//go:build linux && !android + +package sharedsock + +import ( + "errors" + "fmt" + "net/netip" + "strconv" + "sync" + "syscall" + "unsafe" + + "github.com/mdlayher/socket" + log "github.com/sirupsen/logrus" + "golang.org/x/sys/unix" + + nbnet "github.com/netbirdio/netbird/client/net" +) + +var errProbeClosed = errors.New("source probe closed") + +// srcProbe finds the source address the kernel picks for a destination by connecting +// a UDP socket that carries the raw sockets' fwmark and reading back its local address. +// Connecting a UDP socket runs the output route lookup without sending anything. +type srcProbe struct { + family int + + mu sync.Mutex + // conn is nil while no socket is open. A failed route lookup keeps the socket, + // any other failure closes it and the next lookup opens a fresh one, so a socket + // in an unknown state is never reused. + conn *socket.Conn + closed bool +} + +// newSrcProbe opens a probe socket for the given address family. +func newSrcProbe(family int) (*srcProbe, error) { + conn, err := openProbeSocket(family) + if err != nil { + return nil, err + } + return &srcProbe{family: family, conn: conn}, nil +} + +// resolve returns the source address the kernel would use for a packet to sa, a +// sockaddr of the probe's family. It is safe for concurrent use. +func (p *srcProbe) resolve(sa unix.Sockaddr) (netip.Addr, error) { + p.mu.Lock() + defer p.mu.Unlock() + + if p.closed { + return netip.Addr{}, errProbeClosed + } + + if p.conn == nil { + conn, err := openProbeSocket(p.family) + if err != nil { + return netip.Addr{}, err + } + p.conn = conn + } + + src, err := p.lookup(sa) + if err != nil { + var rErr *routeError + if !errors.As(err, &rErr) { + if closeErr := p.closeSocket(); closeErr != nil { + log.Debugf("failed to close source probe socket: %v", closeErr) + } + } + return netip.Addr{}, err + } + return src, nil +} + +// close releases the socket. Later lookups fail with errProbeClosed. +func (p *srcProbe) close() error { + p.mu.Lock() + defer p.mu.Unlock() + + p.closed = true + return p.closeSocket() +} + +// closeSocket closes the socket if one is open. Callers must hold p.mu. +func (p *srcProbe) closeSocket() error { + conn := p.conn + p.conn = nil + if conn == nil { + return nil + } + return conn.Close() +} + +// lookup runs one route lookup on the socket. Callers must hold p.mu. +func (p *srcProbe) lookup(sa unix.Sockaddr) (netip.Addr, error) { + rc, err := p.conn.SyscallConn() + if err != nil { + return netip.Addr{}, fmt.Errorf("probe socket: %w", err) + } + + var src netip.Addr + var lookupErr error + if err := rc.Control(func(fd uintptr) { + src, lookupErr = lookupFD(int(fd), sa) + }); err != nil { + return netip.Addr{}, fmt.Errorf("probe socket: %w", err) + } + return src, lookupErr +} + +// routeError is a route lookup the kernel refused. The socket is still usable after it. +type routeError struct { + err error +} + +func (e *routeError) Error() string { + return fmt.Sprintf("route lookup: %v", e.err) +} + +func (e *routeError) Unwrap() error { + return e.err +} + +func lookupFD(fd int, sa unix.Sockaddr) (netip.Addr, error) { + // A connected socket keeps the source address of its first connect and reuses + // it for later route lookups, so dissolve the association first. + if err := disconnect(fd); err != nil { + return netip.Addr{}, fmt.Errorf("disconnect probe socket: %w", err) + } + + if err := unix.Connect(fd, sa); err != nil { + return netip.Addr{}, &routeError{err: err} + } + + local, err := unix.Getsockname(fd) + if err != nil { + return netip.Addr{}, fmt.Errorf("read probe socket address: %w", err) + } + + var src netip.Addr + switch a := local.(type) { + case *unix.SockaddrInet4: + src = netip.AddrFrom4(a.Addr) + case *unix.SockaddrInet6: + src = netip.AddrFrom16(a.Addr) + } + if !src.IsValid() || src.IsUnspecified() { + return netip.Addr{}, &routeError{err: errors.New("no source address")} + } + return src, nil +} + +func openProbeSocket(family int) (*socket.Conn, error) { + conn, err := socket.Socket(family, unix.SOCK_DGRAM, unix.IPPROTO_UDP, "udp_src_probe", nil) + if err != nil { + return nil, fmt.Errorf("create source probe socket: %w", err) + } + + if err := nbnet.SetSocketMark(conn); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("set SO_MARK on source probe socket: %w", err) + } + return conn, nil +} + +// disconnect dissolves a UDP socket's association by connecting to AF_UNSPEC, which +// also clears the source address the kernel pinned on the previous connect. +func disconnect(fd int) error { + sa := unix.RawSockaddr{Family: unix.AF_UNSPEC} + _, _, errno := unix.Syscall(unix.SYS_CONNECT, uintptr(fd), uintptr(unsafe.Pointer(&sa)), unsafe.Sizeof(sa)) + if errno != 0 { + return errno + } + return nil +} + +// rawSockaddr returns the sockaddr for dst with port 0 and the given scope. Port 0 +// matches a raw send, whose route lookup carries no ports. A UDP probe connected +// to it still gets an ephemeral source port before its lookup. +func rawSockaddr(dst netip.Addr, scope uint32) unix.Sockaddr { + if dst.Is4() { + return &unix.SockaddrInet4{Addr: dst.As4()} + } + return &unix.SockaddrInet6{Addr: dst.As16(), ZoneId: scope} +} + +// zoneIndex returns the interface index for an IPv6 zone, which is either an +// interface name or a numeric index. An empty zone is index 0. A name costs one +// SIOCGIFINDEX ioctl on rc, which may be any socket. +func zoneIndex(rc syscall.RawConn, zone string) (uint32, error) { + if zone == "" { + return 0, nil + } + if idx, err := strconv.ParseUint(zone, 10, 32); err == nil { + return uint32(idx), nil + } + + ifr, err := unix.NewIfreq(zone) + if err != nil { + return 0, fmt.Errorf("zone %q: %w", zone, err) + } + var ioctlErr error + if err := rc.Control(func(fd uintptr) { + ioctlErr = unix.IoctlIfreq(int(fd), unix.SIOCGIFINDEX, ifr) + }); err != nil { + return 0, fmt.Errorf("zone %q: %w", zone, err) + } + if ioctlErr != nil { + return 0, fmt.Errorf("resolve zone %q: %w", zone, ioctlErr) + } + return ifr.Uint32(), nil +} diff --git a/sharedsock/src_probe_linux_test.go b/sharedsock/src_probe_linux_test.go new file mode 100644 index 000000000..bdf8e37d8 --- /dev/null +++ b/sharedsock/src_probe_linux_test.go @@ -0,0 +1,218 @@ +//go:build linux && !android + +package sharedsock + +import ( + "net" + "net/netip" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vishvananda/netlink" + "golang.org/x/sys/unix" +) + +// routeGetSrc is the kernel's answer through netlink, used as the reference. +func routeGetSrc(t *testing.T, dst netip.Addr) (netip.Addr, bool) { + t.Helper() + routes, err := netlink.RouteGet(net.IP(dst.AsSlice())) + if err != nil { + return netip.Addr{}, false + } + for _, r := range routes { + if src, ok := netip.AddrFromSlice(r.Src); ok { + return src.Unmap(), true + } + } + return netip.Addr{}, false +} + +func newTestProbe(t *testing.T, family int) *srcProbe { + t.Helper() + p, err := newSrcProbe(family) + require.NoError(t, err) + t.Cleanup(func() { _ = p.close() }) + return p +} + +// A reused UDP socket keeps the source address of its first connect. Alternating +// between a loopback and an off-host destination catches a probe that forgets to +// disconnect: the second lookup would report 127.0.0.1 for the off-host address. +func TestSrcProbe_AlternatingDestinationsMatchRouteGet(t *testing.T) { + loopback := netip.MustParseAddr("127.0.0.1") + remote := netip.MustParseAddr("192.0.2.1") + + remoteSrc, ok := routeGetSrc(t, remote) + if !ok { + t.Skip("no route to an off-host IPv4 destination") + } + require.NotEqual(t, loopback, remoteSrc, "off-host destination must not route via loopback") + + p := newTestProbe(t, unix.AF_INET) + for i := 0; i < 3; i++ { + src, err := p.resolve(rawSockaddr(loopback, 0)) + require.NoError(t, err) + assert.Equal(t, loopback, src, "source for loopback, round %d", i) + + src, err = p.resolve(rawSockaddr(remote, 0)) + require.NoError(t, err) + assert.Equal(t, remoteSrc, src, "source for %s, round %d", remote, i) + } +} + +func TestSrcProbe_IPv6(t *testing.T) { + loopback := netip.MustParseAddr("::1") + if _, ok := routeGetSrc(t, loopback); !ok { + t.Skip("no IPv6 loopback") + } + + p := newTestProbe(t, unix.AF_INET6) + src, err := p.resolve(rawSockaddr(loopback, 0)) + require.NoError(t, err) + assert.Equal(t, loopback, src, "source for ::1") + + remote := netip.MustParseAddr("2001:db8::1") + remoteSrc, ok := routeGetSrc(t, remote) + if !ok { + t.Skipf("no route to %s", remote) + } + src, err = p.resolve(rawSockaddr(remote, 0)) + require.NoError(t, err) + assert.Equal(t, remoteSrc, src, "source for %s", remote) +} + +// A route the kernel refuses is an answer, not a broken socket, so the probe keeps +// its socket and the next lookup reuses it. +func TestSrcProbe_RouteErrorKeepsSocket(t *testing.T) { + p := newTestProbe(t, unix.AF_INET) + conn := p.conn + + // Connecting to the limited broadcast address without SO_BROADCAST fails. + _, err := p.resolve(rawSockaddr(netip.MustParseAddr("255.255.255.255"), 0)) + var rErr *routeError + require.ErrorAs(t, err, &rErr, "lookup for the broadcast address should fail as a route error") + assert.Same(t, conn, p.conn, "route error should keep the probe socket") + + src, err := p.resolve(rawSockaddr(netip.MustParseAddr("127.0.0.1"), 0)) + require.NoError(t, err) + assert.Equal(t, netip.MustParseAddr("127.0.0.1"), src, "source after the route error") + assert.Same(t, conn, p.conn, "lookup after a route error should reuse the socket") +} + +// A socket that stops working is dropped, and the next lookup opens a fresh one. +func TestSrcProbe_ReopensAfterSocketError(t *testing.T) { + p := newTestProbe(t, unix.AF_INET) + require.NoError(t, p.conn.Close()) + + _, err := p.resolve(rawSockaddr(netip.MustParseAddr("127.0.0.1"), 0)) + require.Error(t, err, "lookup on a closed socket should fail") + assert.Nil(t, p.conn, "socket error should drop the probe socket") + + src, err := p.resolve(rawSockaddr(netip.MustParseAddr("127.0.0.1"), 0)) + require.NoError(t, err) + assert.Equal(t, netip.MustParseAddr("127.0.0.1"), src, "source after reopening") + assert.NotNil(t, p.conn, "probe socket should be open again") +} + +// A link-local destination is only routable with its scope. The probe must pass the +// scope to the kernel and get the interface's own link-local address back. +func TestSrcProbe_LinkLocalWithZone(t *testing.T) { + iface, want := linkLocalInterface(t) + dst := netip.MustParseAddr("fe80::1") + + p := newTestProbe(t, unix.AF_INET6) + rc, err := p.conn.SyscallConn() + require.NoError(t, err) + + for _, zone := range []string{iface.Name, strconv.Itoa(iface.Index)} { + scope, err := zoneIndex(rc, zone) + require.NoError(t, err, "zone %q", zone) + assert.Equal(t, uint32(iface.Index), scope, "index for zone %q", zone) + + src, err := p.resolve(rawSockaddr(dst.WithZone(zone), scope)) + require.NoError(t, err, "zone %q", zone) + assert.Equal(t, want, src, "source for %s%%%s", dst, zone) + } + + _, err = p.resolve(rawSockaddr(dst, 0)) + var rErr *routeError + assert.ErrorAs(t, err, &rErr, "link-local destination without a scope should fail as a route error") +} + +func TestZoneIndex(t *testing.T) { + p := newTestProbe(t, unix.AF_INET6) + rc, err := p.conn.SyscallConn() + require.NoError(t, err) + + scope, err := zoneIndex(rc, "") + require.NoError(t, err) + assert.Zero(t, scope, "empty zone should be index 0") + + _, err = zoneIndex(rc, "nb-no-such-if0") + assert.Error(t, err, "unknown interface name should fail") +} + +// linkLocalInterface returns an up interface and its IPv6 link-local address. +func linkLocalInterface(t *testing.T) (net.Interface, netip.Addr) { + t.Helper() + ifaces, err := net.Interfaces() + require.NoError(t, err) + for _, iface := range ifaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, a := range addrs { + prefix, err := netip.ParsePrefix(a.String()) + if err == nil && prefix.Addr().IsLinkLocalUnicast() { + return iface, prefix.Addr() + } + } + } + t.Skip("no interface with an IPv6 link-local address") + return net.Interface{}, netip.Addr{} +} + +func TestSrcProbe_ClosedRejectsLookups(t *testing.T) { + p, err := newSrcProbe(unix.AF_INET) + require.NoError(t, err) + require.NoError(t, p.close()) + require.NoError(t, p.close(), "close must be idempotent") + + _, err = p.resolve(rawSockaddr(netip.MustParseAddr("127.0.0.1"), 0)) + assert.ErrorIs(t, err, errProbeClosed) + assert.Nil(t, p.conn, "closed probe must not reopen") +} + +func BenchmarkSrcProbe(b *testing.B) { + p, err := newSrcProbe(unix.AF_INET) + require.NoError(b, err) + defer p.close() + + dst := netip.MustParseAddr("192.0.2.1") + if _, err := p.resolve(rawSockaddr(dst, 0)); err != nil { + b.Skipf("no route to %s: %v", dst, err) + } + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := p.resolve(rawSockaddr(dst, 0)); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkSrcRouteGet(b *testing.B) { + dst := net.ParseIP("192.0.2.1") + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := netlink.RouteGetWithOptions(dst, &netlink.RouteGetOptions{}); err != nil { + b.Skipf("no route to %s: %v", dst, err) + } + } +} diff --git a/sharedsock/src_probe_privileged_linux_test.go b/sharedsock/src_probe_privileged_linux_test.go new file mode 100644 index 000000000..fed1d3985 --- /dev/null +++ b/sharedsock/src_probe_privileged_linux_test.go @@ -0,0 +1,92 @@ +//go:build privileged + +package sharedsock + +import ( + "net" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vishvananda/netlink" + "golang.org/x/sys/unix" + + nbnet "github.com/netbirdio/netbird/client/net" +) + +// The probe must pick the source the raw sockets get on send, which carry the +// control-plane fwmark. The test installs the same shape of policy rule the client +// uses: unmarked traffic to 192.0.2.0/24 is diverted into a table that routes it +// via loopback, so an unmarked lookup reports 127.0.0.1 and a marked one does not. +func TestSrcProbe_HonoursControlPlaneMark(t *testing.T) { + nbnet.Init() + if !nbnet.AdvancedRouting() { + t.Skip("advanced routing not supported") + } + + const ( + table = 4242 + // Below the client's own rules, so a default route in the netbird table cannot win. + priority = 90 + ) + dst := netip.MustParseAddr("192.0.2.1") + + marked, err := netlink.RouteGetWithOptions(net.IP(dst.AsSlice()), &netlink.RouteGetOptions{Mark: nbnet.ControlPlaneMark}) + if err != nil { + t.Skipf("no route to %s: %v", dst, err) + } + if len(marked) == 0 || marked[0].Src == nil { + t.Skipf("marked route to %s has no source address", dst) + } + markedSrc, ok := netip.AddrFromSlice(marked[0].Src) + require.True(t, ok, "parse marked source") + markedSrc = markedSrc.Unmap() + if markedSrc == netip.MustParseAddr("127.0.0.1") { + t.Skipf("marked route to %s already uses loopback, no contrast to test", dst) + } + + rules, err := netlink.RuleList(unix.AF_INET) + require.NoError(t, err) + for _, r := range rules { + if r.Priority == priority || r.Table == table { + t.Skipf("rule priority %d or table %d already in use", priority, table) + } + } + + lo, err := netlink.LinkByName("lo") + require.NoError(t, err) + + route := &netlink.Route{ + Dst: &net.IPNet{IP: net.IPv4(192, 0, 2, 0), Mask: net.CIDRMask(24, 32)}, + LinkIndex: lo.Attrs().Index, + // 127.0.0.1 is host-scoped, so a link-scoped route only picks it when told to. + Src: net.IPv4(127, 0, 0, 1), + Table: table, + Scope: netlink.SCOPE_LINK, + } + require.NoError(t, netlink.RouteAdd(route)) + t.Cleanup(func() { _ = netlink.RouteDel(route) }) + + rule := netlink.NewRule() + rule.Family = unix.AF_INET + rule.Priority = priority + rule.Table = table + rule.Mark = nbnet.ControlPlaneMark + rule.Invert = true + require.NoError(t, netlink.RuleAdd(rule)) + t.Cleanup(func() { _ = netlink.RuleDel(rule) }) + + unmarked, err := netlink.RouteGet(net.IP(dst.AsSlice())) + require.NoError(t, err) + require.NotEmpty(t, unmarked) + require.True(t, unmarked[0].Src.Equal(net.IPv4(127, 0, 0, 1)), "unmarked lookup should be diverted to loopback, got %s", unmarked[0].Src) + + p, err := newSrcProbe(unix.AF_INET) + require.NoError(t, err) + t.Cleanup(func() { _ = p.close() }) + + src, err := p.resolve(rawSockaddr(dst, 0)) + require.NoError(t, err) + assert.Equal(t, markedSrc, src, "probe should resolve the source of the marked lookup") +} From 2f270514398b65199ae1f668f212a4bb158c7351 Mon Sep 17 00:00:00 2001 From: Nicolas Frati Date: Mon, 28 Sep 2026 09:46:42 +0200 Subject: [PATCH 18/33] [proxy] Add a release-wired UBI image variant (#7464) Add a UBI-based reverse-proxy image for the internal Red Hat certification requirement, without changing the existing image or deployment defaults. Includes non-root execution, licensing and image metadata, plus an AMD64 GoReleaser entry with separate UBI tags. Preflight and TLS/overlay checks passed. An intermittent shutdown exit error remains deferred; this stays draft pending maintainer testing. --- .github/workflows/release.yml | 12 ++- .goreleaser.yaml | 35 ++++++++ proxy/Dockerfile.ubi | 32 ++++++++ proxy/collect-licenses.sh | 82 +++++++++++++++++++ proxy/web/scripts/third-party-licenses.mjs | 68 ++++++++++++++++ proxy/web/src/assets/fonts/OFL.txt | 92 ++++++++++++++++++++++ 6 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 proxy/Dockerfile.ubi create mode 100644 proxy/collect-licenses.sh create mode 100644 proxy/web/scripts/third-party-licenses.mjs create mode 100644 proxy/web/src/assets/fonts/OFL.txt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 45405c7a8..673fcc281 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -194,6 +194,14 @@ jobs: - name: Fill the RPM ISA provide version # nfpm cannot emit rpmbuild's ISA provide and GoReleaser cannot template it. run: bash release_files/rpm-provides.sh + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + - name: Install proxy web dependencies for license collection + # proxy/collect-licenses.sh reads the UI's license terms from node_modules. + working-directory: proxy/web + run: npm ci --ignore-scripts - name: Set up QEMU uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 #v4.1.0 - name: Set up Docker Buildx @@ -301,10 +309,12 @@ jobs: tag_and_push() { local src="$1" img_name tag dst variant="" img_name="${src%%:*}" - # Client variants share a repository, so keep their tag suffixes. + # Variants share a repository with their default image, so keep + # their tag suffixes. Order matters: the first matching pattern wins. case "$src" in *-rootless-ubi-amd64) variant="-rootless-ubi" ;; *-rootless-amd64) variant="-rootless" ;; + *-ubi-amd64) variant="-ubi" ;; esac for tag in $(resolve_tags); do dst="${img_name}:${tag}${variant}" diff --git a/.goreleaser.yaml b/.goreleaser.yaml index a08a7e92a..275c1cd7b 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -533,6 +533,41 @@ dockers_v2: "org.opencontainers.image.revision": "{{.FullCommit}}" "org.opencontainers.image.source": "{{.GitURL}}" "maintainer": "dev@netbird.io" + - id: proxy-ubi + disable: "{{ .Env.SKIP_DOCKER_PUSH }}" + ids: + - netbird-proxy + images: + - netbirdio/reverse-proxy + - ghcr.io/netbirdio/reverse-proxy + tags: + - "{{ .Version }}-ubi" + - "{{ if eq .Env.SKIP_PUBLISH \"false\" }}ubi-latest{{ end }}" + dockerfile: proxy/Dockerfile.ubi + platforms: + - linux/amd64 + - linux/arm64 + build_args: + VERSION: "{{ .Version }}" + RELEASE: "{{ .Timestamp }}" + hooks: + pre: + - cmd: 'sh proxy/collect-licenses.sh "{{ .ContextDir }}/licenses" amd64 arm64' + env: + - GOOS=linux + - CGO_ENABLED=0 + labels: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + annotations: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.title": "{{.ProjectName}}" + "org.opencontainers.image.version": "{{.Version}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.source": "{{.GitURL}}" + "maintainer": "dev@netbird.io" brews: - ids: diff --git a/proxy/Dockerfile.ubi b/proxy/Dockerfile.ubi new file mode 100644 index 000000000..a74280a49 --- /dev/null +++ b/proxy/Dockerfile.ubi @@ -0,0 +1,32 @@ +FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93 + +ARG TARGETPLATFORM +ARG VERSION=dev +ARG RELEASE=1 + +LABEL name="netbird-reverse-proxy" \ + maintainer="NetBird " \ + vendor="NetBird GmbH" \ + version="${VERSION}" \ + release="${RELEASE}" \ + summary="NetBird Reverse Proxy" \ + description="NetBird Reverse Proxy provides an identity-aware entrypoint to services in NetBird networks." + +COPY --chmod=0555 ${TARGETPLATFORM}/netbird-proxy /go/bin/netbird-proxy +COPY licenses/ /licenses/ +# Only the writable directories share the root group for arbitrary non-root UIDs. +# Runtime-created private keys retain the application's restrictive file modes. +RUN mkdir -p /var/lib/netbird /certs && \ + chown 1000:0 /var/lib/netbird /certs && \ + chmod 0770 /var/lib/netbird /certs && \ + chmod -R a+rX /licenses + +USER 1000:0 +ENV HOME=/var/lib/netbird +ENV NB_PROXY_ADDRESS=":8443" +# Unprivileged ports: runtimes such as OpenShift and Podman keep the kernel +# default that reserves ports below 1024 for root. 8080 is the health probe. +ENV NB_PROXY_ACME_ADDRESS=":8081" +EXPOSE 8443 +STOPSIGNAL SIGTERM +ENTRYPOINT ["/go/bin/netbird-proxy"] diff --git a/proxy/collect-licenses.sh b/proxy/collect-licenses.sh new file mode 100644 index 000000000..ccf5f4dd6 --- /dev/null +++ b/proxy/collect-licenses.sh @@ -0,0 +1,82 @@ +#!/bin/sh +set -eu + +if [ "$#" -lt 2 ]; then + printf '%s\n' "usage: $0 OUTPUT_DIRECTORY GOARCH..." >&2 + exit 2 +fi + +repo_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +output_name=$(basename "$1") +if [ -z "$output_name" ] || [ "$output_name" = . ] || [ "$output_name" = .. ] || [ "$output_name" = / ]; then + printf '%s\n' "OUTPUT_DIRECTORY must name a directory" >&2 + exit 2 +fi +output_parent=$(CDPATH= cd -- "$(dirname "$1")" && pwd) +output="$output_parent/$output_name" +shift +modules=$(mktemp "${TMPDIR:-/tmp}/netbird-proxy-licenses.modules.XXXXXX") +sorted_modules=$(mktemp "${TMPDIR:-/tmp}/netbird-proxy-licenses.sorted.XXXXXX") + +if [ -e "$output" ] || [ -L "$output" ]; then + printf 'output directory already exists: %s\n' "$output" >&2 + exit 1 +fi +# Assemble beside the target and rename on success, so a failed run leaves +# nothing behind that would block the next attempt. +staging=$(mktemp -d "$output_parent/.$output_name.XXXXXX") +trap 'rm -f "$modules" "$sorted_modules"; rm -rf "$staging"' EXIT HUP INT TERM +mkdir "$staging/third_party" + +cp "$repo_root/proxy/LICENSE" "$staging/AGPL-3.0.txt" +cp "$repo_root/LICENSE" "$staging/BSD-3-Clause.txt" +node "$repo_root/proxy/web/scripts/third-party-licenses.mjs" >"$staging/Web-THIRD-PARTY-LICENSES" + +cd "$repo_root" +for arch in "$@"; do + GOOS=${GOOS:-linux} GOARCH="$arch" CGO_ENABLED=${CGO_ENABLED:-0} \ + go list -deps -f '{{with .Module}}{{if .Replace}}{{.Replace.Path}}{{"\t"}}{{.Replace.Version}}{{"\t"}}{{.Replace.Dir}}{{else}}{{.Path}}{{"\t"}}{{.Version}}{{"\t"}}{{.Dir}}{{end}}{{end}}' ./proxy/cmd/proxy >>"$modules" +done +LC_ALL=C sort -u "$modules" >"$sorted_modules" + +goroot=$(go env GOROOT) +for term in LICENSE PATENTS; do + if [ ! -f "$goroot/$term" ]; then + printf 'missing Go standard-library term: %s\n' "$goroot/$term" >&2 + exit 1 + fi + cp "$goroot/$term" "$staging/Go-$term" +done + +while IFS=' ' read -r module version module_dir; do + [ -n "$module" ] || continue + [ "$module" = "github.com/netbirdio/netbird" ] && continue + + if [ -z "$version" ] || [ ! -d "$module_dir" ]; then + printf 'cannot collect terms for module %s at version %s\n' "$module" "$version" >&2 + exit 1 + fi + + destination="$staging/third_party/$module/$version" + mkdir -p "$destination" + printf 'module: %s\nversion: %s\n' "$module" "$version" >"$destination/MODULE" + + found=false + for term in \ + "$module_dir"/LICENSE* "$module_dir"/License* "$module_dir"/license* \ + "$module_dir"/LICENCE* "$module_dir"/Licence* "$module_dir"/licence* \ + "$module_dir"/COPYING* "$module_dir"/Copying* "$module_dir"/copying* \ + "$module_dir"/NOTICE* "$module_dir"/Notice* "$module_dir"/notice* \ + "$module_dir"/PATENTS* "$module_dir"/Patents* "$module_dir"/patents*; do + [ -f "$term" ] || continue + cp "$term" "$destination/" + found=true + done + + if [ "$found" = false ]; then + printf 'no root license terms found for module %s at %s\n' "$module" "$module_dir" >&2 + exit 1 + fi +done <"$sorted_modules" + +mv "$staging" "$output" diff --git a/proxy/web/scripts/third-party-licenses.mjs b/proxy/web/scripts/third-party-licenses.mjs new file mode 100644 index 000000000..2ebff5f59 --- /dev/null +++ b/proxy/web/scripts/third-party-licenses.mjs @@ -0,0 +1,68 @@ +// Prints the third-party terms for the prebuilt UI in dist/ to stdout. +// Package versions come from package-lock.json and the license texts from an +// installed node_modules (run `npm ci --ignore-scripts` first). +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const webDir = join(dirname(fileURLToPath(import.meta.url)), ".."); + +// Build tools whose own code is emitted into dist: tailwindcss generates the +// preflight and utility CSS, vite injects its modulepreload polyfill. +const bundledTooling = new Set(["node_modules/tailwindcss", "node_modules/vite"]); + +// Inter ships as a font file rather than a package, so its OFL lives beside it. +const staticTerms = [ + { + title: "Inter 4.001 (git-66647c0bb), SIL Open Font License 1.1", + file: "src/assets/fonts/OFL.txt", + }, +]; + +const termPattern = /^(licen[cs]e|copying|notice|patents)/i; + +function fail(message) { + process.stderr.write(`${message}\n`); + process.exit(1); +} + +function shippedPackages(lock) { + return Object.entries(lock.packages) + .filter(([path, meta]) => path !== "" && (!meta.dev || bundledTooling.has(path))) + .sort(([a], [b]) => Number(a > b) - Number(a < b)); +} + +function packageSection(path, meta) { + const dir = join(webDir, path); + const manifest = join(dir, "package.json"); + if (!existsSync(manifest)) { + fail(`${path} is not installed; run npm ci --ignore-scripts in proxy/web`); + } + const installed = JSON.parse(readFileSync(manifest, "utf8")); + if (installed.version !== meta.version) { + fail(`${path} is ${installed.version}, package-lock.json pins ${meta.version}`); + } + + const terms = readdirSync(dir).filter((name) => termPattern.test(name)).sort(); + if (terms.length === 0) { + fail(`no license terms found for ${path}`); + } + + const name = path.slice(path.lastIndexOf("node_modules/") + "node_modules/".length); + return terms + .map((term) => `=== ${name} ${meta.version} (${term}) ===\n\n${readFileSync(join(dir, term), "utf8")}`) + .join("\n\n"); +} + +const lock = JSON.parse(readFileSync(join(webDir, "package-lock.json"), "utf8")); +const sections = shippedPackages(lock).map(([path, meta]) => packageSection(path, meta)); +for (const { title, file } of staticTerms) { + sections.push(`=== ${title} ===\n\n${readFileSync(join(webDir, file), "utf8")}`); +} + +process.stdout.write( + "Third-party terms for the prebuilt authentication UI in proxy/web/dist.\n" + + "Generated from proxy/web/package-lock.json at release time.\n\n\n" + + sections.join("\n\n\n") + + "\n", +); diff --git a/proxy/web/src/assets/fonts/OFL.txt b/proxy/web/src/assets/fonts/OFL.txt new file mode 100644 index 000000000..9b2ca37b3 --- /dev/null +++ b/proxy/web/src/assets/fonts/OFL.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. From ba8bdfc5c7f7558d9cef901d592c63b16f874e41 Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Mon, 28 Sep 2026 12:02:47 +0300 Subject: [PATCH 19/33] [infrastructure] Add proxy support to enterprise setup (#7651) --- .../getting-started-enterprise.sh | 473 +++++++++++++++++- 1 file changed, 453 insertions(+), 20 deletions(-) diff --git a/infrastructure_files/getting-started-enterprise.sh b/infrastructure_files/getting-started-enterprise.sh index e88436e84..344bd2fed 100755 --- a/infrastructure_files/getting-started-enterprise.sh +++ b/infrastructure_files/getting-started-enterprise.sh @@ -6,11 +6,19 @@ set -o pipefail # NetBird Enterprise — Getting Started # Single-node bootstrap for a self-hosted NetBird Enterprise stack with the # embedded identity provider. Owner is created via first-login flow. +# Add features to an existing install with --enable-proxy or --enable-traffic-events. SED_STRIP_PADDING='s/=//g' NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA" +STACK_FILES=(.env docker-compose.yml config.yaml) + +# Host directory of a custom TLS certificate mounted at /certs, see +# https://docs.netbird.io/selfhosted/enterprise/getting-started#appendix-using-a-custom-tls-certificate +CUSTOM_TLS_CERTS="" +PROXY_TOKEN_ID="" + # Static IP for Traefik inside the compose bridge network. The management # server trusts X-Forwarded-* headers from this address only. TRAEFIK_IP="172.30.0.10" @@ -44,6 +52,28 @@ check_openssl() { fi } +die() { + echo "$1" > /dev/stderr + exit 1 +} + +# env_get KEY [DEFAULT] prints KEY's value from .env, or DEFAULT if unset. +env_get() { + local value + value=$(sed -n "s/^$1=//p" .env | tail -n 1) + echo "${value:-$2}" +} + +# merge_env upserts the KEY=VALUE lines from stdin into .env, in place. +merge_env() { + local merged + merged=$(awk -F= 'NR == FNR { v[$1] = $0; o[++n] = $1; next } + $1 in v { print v[$1]; delete v[$1]; next } + { print } + END { for (i = 1; i <= n; i++) if (o[i] in v) print v[o[i]] }' - .env) + printf '%s\n' "$merged" > .env +} + rand_secret() { openssl rand -base64 32 | sed "$SED_STRIP_PADDING" } @@ -171,6 +201,15 @@ read_yes_no() { esac } +read_crowdsec_option() { + echo "" + echo "CrowdSec:" + echo " Checks client IPs against a community threat intelligence database and" + echo " blocks known malicious sources before they reach services exposed through" + echo " the proxy. Adds a CrowdSec container to the stack." + NETBIRD_CROWDSEC=$(read_yes_no "Enable CrowdSec" "n") +} + # Gate the install on explicit acceptance of the NetBird On-Premise EULA. require_eula_acceptance() { cat > /dev/stderr < /dev/null && return 0 + sleep 2 + done + return 1 +} + +admin_token() { + $DOCKER_COMPOSE_COMMAND run --rm --no-deps -T netbird-server admin token "$@" --config /etc/netbird/config.yaml +} + +# revoke_proxy_token revokes the token this run minted if the proxy never started. +# On failure the ID is kept, so rollback retries it. +revoke_proxy_token() { + [[ -n "$PROXY_TOKEN_ID" ]] || return 0 + if admin_token revoke "$PROXY_TOKEN_ID" > /dev/null; then + PROXY_TOKEN_ID="" + return 0 + fi + echo "Could not revoke the unused proxy token ${PROXY_TOKEN_ID}. Revoke it with:" > /dev/stderr + echo " $DOCKER_COMPOSE_COMMAND run --rm netbird-server admin token revoke ${PROXY_TOKEN_ID} --config /etc/netbird/config.yaml" > /dev/stderr +} + +# start_proxy mints the proxy token and CrowdSec bouncer key, then starts the proxy. +start_proxy() { + local out token key + echo "Creating the proxy access token ..." + out=$(admin_token create --name default-proxy) || true + token=$(awk '/^Token:/ {print $2}' <<< "$out") + PROXY_TOKEN_ID=$(awk '/^Token ID:/ {print $3}' <<< "$out") + [[ -n "$token" ]] || die "Could not create the proxy access token. Check the netbird-server logs, then re-run with --enable-proxy." + + if [[ "$NETBIRD_CROWDSEC" == "yes" ]]; then + echo "Registering the CrowdSec bouncer ..." + if wait_crowdsec; then + # "add" fails if an earlier attempt already registered the bouncer. + $DOCKER_COMPOSE_COMMAND exec -T crowdsec cscli bouncers delete netbird-proxy &> /dev/null || true + key=$($DOCKER_COMPOSE_COMMAND exec -T crowdsec cscli bouncers add netbird-proxy -o raw) || true + fi + if [[ -z "$key" ]]; then + revoke_proxy_token + die "Could not register the CrowdSec bouncer. Check the crowdsec logs, then re-run with --enable-proxy." + fi + fi + + # A stored token marks the proxy as set up, so it is cleared again on failure. + { + echo "NETBIRD_PROXY_TOKEN=${token}" + if [[ -n "$key" ]]; then echo "NETBIRD_CROWDSEC_BOUNCER_KEY=${key}"; fi + } | merge_env + if ! $DOCKER_COMPOSE_COMMAND up -d proxy; then + revoke_proxy_token + echo "NETBIRD_PROXY_TOKEN=" | merge_env + die "Could not start the proxy. Check the proxy logs, then re-run with --enable-proxy." + fi + PROXY_TOKEN_ID="" +} + +print_proxy_notes() { + echo "" + echo "NetBird Proxy:" + echo " Every domain other than ${NETBIRD_DOMAIN} is passed through to the proxy," + echo " which issues its own TLS certificates. Point proxy domains at this host:" + echo "" + echo " *.${NETBIRD_DOMAIN} CNAME ${NETBIRD_DOMAIN}" + echo "" + echo " Open 51820/udp (optional) for peer-to-peer proxy connections." + if [[ "$NETBIRD_CROWDSEC" == "yes" ]]; then + echo " CrowdSec is running. Enable it per service in the dashboard under Access Control." + fi +} + init_environment() { check_openssl DOCKER_COMPOSE_COMMAND=$(check_docker_compose) if [[ -f .env ]] || [[ -f docker-compose.yml ]] || [[ -f config.yaml ]]; then echo "Generated files already exist in $(pwd)." + echo "To add the proxy or traffic events to this installation, re-run with" + echo "--enable-proxy or --enable-traffic-events." + echo "" echo "If you want to reinitialize the environment, please remove them first:" echo " $DOCKER_COMPOSE_COMMAND down --volumes # removes all containers and volumes" - echo " rm -f .env docker-compose.yml config.yaml" + echo " rm -rf .env docker-compose.yml config.yaml traefik" echo "Be aware this will remove all data from the database." exit 1 fi @@ -341,6 +465,16 @@ init_environment() { echo " See https://docs.netbird.io/manage/activity/traffic-events-logging" NETBIRD_TRAFFIC_FLOW=$(read_yes_no "Enable traffic flow" "n") + echo "" + echo "NetBird Proxy:" + echo " Exposes selected resources from your NetBird network to the internet." + echo " You choose which resources are exposed from the dashboard." + NETBIRD_PROXY=$(read_yes_no "Enable the NetBird Proxy" "n") + NETBIRD_CROWDSEC="no" + if [[ "$NETBIRD_PROXY" == "yes" ]]; then + read_crowdsec_option + fi + echo "" NETBIRD_DOMAIN=$(read_nb_domain) @@ -364,6 +498,8 @@ init_environment() { echo "" echo "Selected:" echo " Traffic flow: ${NETBIRD_TRAFFIC_FLOW}" + echo " Proxy: ${NETBIRD_PROXY}" + echo " CrowdSec: ${NETBIRD_CROWDSEC}" echo " Domain: ${NETBIRD_DOMAIN}" echo " ACME email: ${NETBIRD_LETSENCRYPT_EMAIL}" echo "" @@ -371,9 +507,9 @@ init_environment() { install -m 600 /dev/null .env render_env >> .env render_docker_compose > docker-compose.yml - - if [[ -z "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then - sed -i.bak '/NETBIRD_LICENSE_SERVER_BASE_URL/d' docker-compose.yml && rm -f docker-compose.yml.bak + mkdir -p traefik + if [[ "$NETBIRD_PROXY" == "yes" ]]; then + render_traefik_proxy > traefik/proxy.yaml fi install -m 600 /dev/null config.yaml render_config_yaml >> config.yaml @@ -390,11 +526,16 @@ init_environment() { echo "" echo "Starting remaining services ..." - $DOCKER_COMPOSE_COMMAND up -d + up_all_but_proxy echo "" wait_for_license_verdict + if [[ "$NETBIRD_PROXY" == "yes" ]]; then + echo "" + start_proxy + fi + echo "" echo "Done." echo "" @@ -402,6 +543,9 @@ init_environment() { echo "" echo "Open the dashboard in a browser to complete the first-login owner setup." echo "All configuration and secrets are stored (mode 600) in $(pwd)/.env" + if [[ "$NETBIRD_PROXY" == "yes" ]]; then + print_proxy_notes + fi echo "" echo "Tail logs:" echo " cd $(pwd) && $DOCKER_COMPOSE_COMMAND logs -f netbird-server traefik" @@ -413,6 +557,148 @@ init_environment() { fi } +# service_block NAME prints a service's definition from the compose file on stdin. +service_block() { + local name="$1" + awk -v s=" ${name}:" '$0 == s { p = 1; print; next } p && (/^[^ ]/ || /^ [^ ]/) { exit } p' +} + +# enable_features adds the proxy and/or traffic events to the install in the +# current directory, restoring the backed-up files if any step fails. +enable_features() { + local want_proxy="$1" want_flow="$2" f compose + DOCKER_COMPOSE_COMMAND=$(check_docker_compose) + + for f in "${STACK_FILES[@]}"; do + [[ -f "$f" ]] || die "$f not found in $(pwd). Run this from an existing installation directory." + done + grep -q '^# Generated by getting-started-enterprise.sh' .env || die ".env was not generated by getting-started-enterprise.sh." + # Installs from before the move to Traefik run Caddy and can't be re-rendered. + [[ -n "$(env_get NETBIRD_TRAEFIK_IP)" ]] || die "This installation predates the Traefik layout and can't be updated in place." + + NETBIRD_DOMAIN=$(env_get NETBIRD_DOMAIN) + NETBIRD_LICENSE_SERVER_BASE_URL=$(env_get NETBIRD_LICENSE_SERVER_BASE_URL) + NETBIRD_TRAFFIC_FLOW=$(env_get NETBIRD_TRAFFIC_FLOW_ENABLED no) + NETBIRD_PROXY=$(env_get NETBIRD_PROXY_ENABLED no) + NETBIRD_CROWDSEC=$(env_get NETBIRD_CROWDSEC_ENABLED no) + # A custom certificate counts only once Traefik mounts it and ACME is already gone, + # so the re-render never removes a working Let's Encrypt setup. + local traefik_block + traefik_block=$(service_block traefik < docker-compose.yml) + if ! grep -q certificatesresolvers <<< "$traefik_block"; then + CUSTOM_TLS_CERTS=$(awk '/:\/certs:ro$/ { sub(/^ *- /, ""); sub(/:\/certs:ro$/, ""); print; exit }' <<< "$traefik_block") + fi + + if [[ "$want_flow" == "yes" && "$NETBIRD_TRAFFIC_FLOW" == "yes" ]]; then + echo "Traffic events are already enabled." + want_flow="no" + fi + # No token means an earlier proxy setup failed, so let it run again. + if [[ "$want_proxy" == "yes" && -n "$(env_get NETBIRD_PROXY_TOKEN)" ]]; then + echo "The NetBird Proxy is already enabled." + want_proxy="no" + fi + if [[ "$want_flow" == "no" && "$want_proxy" == "no" ]]; then + exit 0 + fi + + if [[ "$want_flow" == "yes" ]]; then + NETBIRD_TRAFFIC_FLOW="yes" + fi + # A retry keeps the CrowdSec choice made at install time. + if [[ "$want_proxy" == "yes" && "$NETBIRD_PROXY" != "yes" ]]; then + read_crowdsec_option + fi + if [[ "$want_proxy" == "yes" ]]; then + NETBIRD_PROXY="yes" + fi + + compose=$(render_docker_compose) + echo "" + echo "Changes to docker-compose.yml:" + printf '%s\n' "$compose" | diff -u docker-compose.yml - || true + + # Lines the new file drops are most likely local edits, so don't default to applying. + local lost s restarts="" apply="y" + lost=$(printf '%s\n' "$compose" | awk 'NR == FNR { keep[$0]; next } !($0 in keep)' - docker-compose.yml) + if [[ -n "$lost" ]]; then + echo "" + echo "These lines are not in the new docker-compose.yml and will be lost:" + printf '%s\n' "$lost" + apply="n" + fi + for s in $($DOCKER_COMPOSE_COMMAND config --services); do + if [[ "$(service_block "$s" < docker-compose.yml)" != "$(printf '%s\n' "$compose" | service_block "$s")" ]] \ + || [[ "$s" == "netbird-server" && "$want_flow" == "yes" ]]; then + restarts+=" $s" + fi + done + echo "" + if [[ -n "$restarts" ]]; then + echo "These services will restart:${restarts}" + fi + if [[ "$(read_yes_no "Apply these changes?" "$apply")" != "yes" ]]; then + echo "Aborted." + exit 0 + fi + + BACKUP_SUFFIX=".bak.$(date -u +%Y%m%d%H%M%S)" + for f in "${STACK_FILES[@]}" traefik/proxy.yaml; do + if [[ -f "$f" ]]; then cp -p "$f" "$f$BACKUP_SUFFIX"; fi + done + trap rollback EXIT + + printf '%s\n' "$compose" > docker-compose.yml + { + echo "NETBIRD_TRAFFIC_FLOW_ENABLED=${NETBIRD_TRAFFIC_FLOW}" + echo "NETBIRD_PROXY_ENABLED=${NETBIRD_PROXY}" + echo "NETBIRD_CROWDSEC_ENABLED=${NETBIRD_CROWDSEC}" + if [[ "$want_flow" == "yes" ]]; then render_env_flow; fi + if [[ "$want_proxy" == "yes" ]]; then render_env_proxy; fi + } | merge_env + if [[ "$want_flow" == "yes" ]] && ! grep -q '^ trafficFlow:' config.yaml; then + render_config_flow >> config.yaml + fi + mkdir -p traefik + if [[ "$want_proxy" == "yes" ]]; then + render_traefik_proxy > traefik/proxy.yaml + fi + + up_all_but_proxy + if [[ "$want_flow" == "yes" ]]; then + # Compose does not notice changes to the bind-mounted config.yaml. + $DOCKER_COMPOSE_COMMAND restart netbird-server + fi + if [[ "$want_proxy" == "yes" ]]; then + start_proxy + fi + trap - EXIT + + echo "" + echo "Done. The previous files are kept with the ${BACKUP_SUFFIX} suffix." + if [[ "$want_flow" == "yes" ]]; then + echo "" + echo "Traffic events still have to be turned on from the dashboard settings." + echo " See https://docs.netbird.io/manage/activity/traffic-events-logging" + fi + if [[ "$want_proxy" == "yes" ]]; then + print_proxy_notes + fi +} + +rollback() { + local f + echo "" > /dev/stderr + echo "Enabling failed. Restoring the previous configuration ..." > /dev/stderr + revoke_proxy_token + # Files without a backup were created by this run. + for f in "${STACK_FILES[@]}" traefik/proxy.yaml; do + if [[ -f "$f$BACKUP_SUFFIX" ]]; then cp -p "$f$BACKUP_SUFFIX" "$f"; else rm -f "$f"; fi + done + $DOCKER_COMPOSE_COMMAND up -d --remove-orphans + $DOCKER_COMPOSE_COMMAND restart netbird-server +} + # ------------------------------------------------------------------ # Renderers # ------------------------------------------------------------------ @@ -427,8 +713,10 @@ NETBIRD_EULA_ACCEPTED=yes NETBIRD_EULA_ACCEPTED_AT=${NETBIRD_EULA_ACCEPTED_AT} NETBIRD_EULA_URL=${NETBIRD_EULA_URL} -# Features (set by the script; don't edit without re-running) +# Features (change with --enable-proxy or --enable-traffic-events, not by hand) NETBIRD_TRAFFIC_FLOW_ENABLED=${NETBIRD_TRAFFIC_FLOW} +NETBIRD_PROXY_ENABLED=${NETBIRD_PROXY} +NETBIRD_CROWDSEC_ENABLED=${NETBIRD_CROWDSEC} # Domain NETBIRD_DOMAIN=${NETBIRD_DOMAIN} @@ -444,10 +732,11 @@ NETBIRD_SERVER_TAG=${NETBIRD_SERVER_TAG:-latest} EOF if [[ "$NETBIRD_TRAFFIC_FLOW" == "yes" ]]; then - cat < /dev/stderr; exit 1 ;; + esac + shift + done + + if [[ "$enable_proxy" == "no" && "$enable_flow" == "no" ]]; then + init_environment + else + enable_features "$enable_proxy" "$enable_flow" fi } -init_environment +main "$@" From c6aa6c232e8d2a9fb71177f06ae51b4262a80256 Mon Sep 17 00:00:00 2001 From: Edward <43848523+thomashacker@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:20:18 +0200 Subject: [PATCH 20/33] [doc] Point bug reports at Discussions and add SUPPORT.md (#7647) * add readme section and support file * [doc] Document anonymize levels in README and SUPPORT Reviewer feedback: mention both --anonymize-level values, not just -A. Wording follows the flag help in client/cmd/root.go. * Apply suggestion from @cubic-dev-ai[bot] * Update SUPPORT.md * adjust parameter descriptions * [doc] Fix duplicated sentence in SUPPORT.md The cubic suggestion replaced only part of the -U sentence, leaving the original line orphaned above it and dropping the blank line before the docs links. * [doc] Tighten anonymization claims in README and SUPPORT Strict mode keeps labels under netbird.io, so naming "the NetBird domains" overstated what it masks. Name the three peer domains instead. Anonymization is not full redaction: internal ranges survive at the default level and interface details are never masked, so say that rather than implying the bundle is safe to post unread. --- README.md | 50 ++++++++++++++++++++++ SUPPORT.md | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 SUPPORT.md diff --git a/README.md b/README.md index 336332043..4dbfc7bd0 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,56 @@ export NETBIRD_DOMAIN=netbird.example.com; curl -fsSL https://github.com/netbird See a complete [architecture overview](https://docs.netbird.io/about-netbird/how-netbird-works#architecture) for details. +### Reporting bugs and requesting features + +NetBird uses a discussion-first workflow. Bug reports and feature requests start in +[Discussions](https://github.com/netbirdio/netbird/discussions), not as issues. + +| What you want to do | Where to go | +| --- | --- | +| Report a bug, regression, or unexpected behavior | [Issue Triage](https://github.com/netbirdio/netbird/discussions/new?category=issue-triage) | +| Request a feature or share an idea | [Ideas & Feature Requests](https://github.com/netbirdio/netbird/discussions/new?category=ideas-feature-requests) | +| Ask about setup, configuration, or self-hosting | [Q&A / Support](https://github.com/netbirdio/netbird/discussions/new?category=q-a-support) | +| Report a security vulnerability | [Security policy](https://github.com/netbirdio/netbird/security/policy), never a public thread | + +Our team and maintainers triage discussions, ask follow-up questions, check for duplicates, +and reproduce bugs. Validated reports are promoted to issues. This keeps the issue tracker a clear +answer to one question: what is the team working on. + +Please search existing discussions and issues first, including closed ones. If something similar +already exists, upvote it and add your details there instead of opening a duplicate. + +For bug reports, include your NetBird version, operating system, deployment type (Cloud, +self-hosted, Kubernetes, or Docker), reproduction steps, expected and actual behavior, and a debug +bundle where relevant: + +```shell +netbird version +netbird status -d -A +netbird debug for 1m -A -S -U +``` + +`-U` uploads the bundle and prints a file key you can paste instead of attaching the archive. +`-A` anonymizes the output, which matters on a public thread. It masks most identifying details +but is not full redaction, so read the bundle before posting it. Two levels are available: + +| Level | How to select | What it masks | +| --- | --- | --- | +| `default` | `-A` / `--anonymize` | Public IP addresses, IPv6 ULA addresses, MAC addresses, and domains other than `netbird.io`, `netbird.cloud`, `netbird.selfhosted`, and `netbird.stage`. IPv4 private, CGNAT, and link-local ranges are kept | +| `strict` | `--anonymize-level strict` (implies `-A`) | The above, plus IPv4 private, CGNAT, and link-local ranges, peer names in front of `netbird.cloud`, `netbird.selfhosted`, and `netbird.stage`, and WireGuard public keys. Labels under `netbird.io` are kept, since it only hosts infrastructure | + +See [collecting a debug bundle](https://docs.netbird.io/help/troubleshooting-client#debug-bundle) +and the [CLI reference](https://docs.netbird.io/get-started/cli#debug-for) for details. + +See [How to use Discussions, Issues, and Pull Requests](https://github.com/netbirdio/netbird/discussions/6075) +for the full workflow, or [SUPPORT.md](SUPPORT.md) for a shorter version. + +### Contributing + +Contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) first. NetBird works ticket +first, anything that changes behavior needs an issue the team has agreed on before you open a pull +request. + ### Community projects - [NetBird installer script](https://github.com/physk/netbird-installer) - [netbird-tui](https://github.com/n0pashkov/netbird-tui) - terminal UI for managing NetBird peers, routes, and settings diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 000000000..fade37286 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,121 @@ +# Getting help with NetBird + +Where to go depends on what you need. If you are not sure, start with +[Q&A / Support](https://github.com/netbirdio/netbird/discussions/new?category=q-a-support) +and we will move it. + +## Before you post + +1. Search existing [discussions](https://github.com/netbirdio/netbird/discussions) and + [issues](https://github.com/netbirdio/netbird/issues), including closed ones. +2. Check the [documentation](https://docs.netbird.io) and the troubleshooting guides for + [clients](https://docs.netbird.io/help/troubleshooting-client) and + [self-hosted deployments](https://docs.netbird.io/selfhosted/troubleshooting). +3. Remove or anonymize sensitive information from logs, screenshots, and configuration. + +If a discussion already covers your problem, upvote it and add your details there rather than +opening a duplicate. Extra reproduction detail, affected versions, and deployment notes are +useful even on an existing thread. + +## Community support + +Free, for everyone. Covers the NetBird client, open source self-hosted deployments, and general +questions. + +| What you want to do | Where to go | +| --- | --- | +| Report a bug, regression, or unexpected behavior | [Issue Triage](https://github.com/netbirdio/netbird/discussions/new?category=issue-triage) | +| Request a feature or share an idea | [Ideas & Feature Requests](https://github.com/netbirdio/netbird/discussions/new?category=ideas-feature-requests) | +| Ask about setup, configuration, or self-hosting | [Q&A / Support](https://github.com/netbirdio/netbird/discussions/new?category=q-a-support) | +| Chat with the community | [Slack](https://docs.netbird.io/slack-url) | + +## Paid support + +For NetBird Cloud customers and commercial-license self-hosted deployments, covering the +dashboard, control plane, billing, and subscriptions, see +[reporting bugs and issues](https://docs.netbird.io/help/report-bug-issues). + +## Security + +Do not report security vulnerabilities in public issues or discussions, and do not post secrets, +private keys, internal hostnames, or sensitive logs. Use the +[security policy](https://github.com/netbirdio/netbird/security/policy). + +## What makes a report we can act on + +For a bug, the most useful reports include: + +- NetBird version, and component versions where applicable +- Operating system or environment +- Deployment type: NetBird Cloud, self-hosted, Kubernetes, Docker, or local development +- Current behavior and expected behavior +- The smallest set of steps that reproduces the problem +- Logs, status output, screenshots, or a debug bundle when relevant +- Whether this worked before, and the last known working version + +For client reports, these commands usually give us what we need: + +```shell +netbird version +netbird status -d -A +netbird debug for 1m -A -S -U +``` + +`-A` (`--anonymize`) replaces sensitive values consistently across every file in the bundle, so +it stays readable while masking most identifying details. It is not a guarantee of full redaction: +internal address ranges survive at the default level, and interface names, indexes, MTUs, and +flags are never anonymized. Read the bundle before posting it publicly. Two levels are +available: + +| Level | How to select | What it masks | +| --- | --- | --- | +| `default` | `-A` / `--anonymize`, or `--anonymize-level default` | Public IP addresses, IPv6 ULA addresses, MAC addresses, and domains other than `netbird.io`, `netbird.cloud`, `netbird.selfhosted`, and `netbird.stage`. IPv4 private, CGNAT, and link-local ranges are kept, and interface names are not anonymized | +| `strict` | `--anonymize-level strict` (implies `-A`) | The above, plus IPv4 private, CGNAT, and link-local ranges, peer names in front of `netbird.cloud`, `netbird.selfhosted`, and `netbird.stage`, and WireGuard public keys. Labels under `netbird.io` are kept, since it only hosts infrastructure | + +Use `strict` when internal addressing or peer naming is itself sensitive. Either way, private +keys and SSH keys are never included, and the packet capture (`capture.pcap`) is left out of +anonymized bundles because it holds raw decrypted packets. + +`-U` (`--upload-bundle`) uploads the bundle and returns a file key you can paste into the thread +instead of attaching an archive. Retention is controlled by the upload service; check its policy +before uploading, and configure cleanup for self-hosted deployments. + +For more detail, see [troubleshooting client issues](https://docs.netbird.io/help/troubleshooting-client), +which explains [what a debug bundle contains](https://docs.netbird.io/help/troubleshooting-client#debug-bundle), +and the [CLI reference](https://docs.netbird.io/get-started/cli#debug-for). + +Intermittent problems are still worth reporting. They just need enough detail to investigate: +trigger, frequency, timing, timestamps, and any related logs. + +For a feature request, describe the problem before the solution: what you are trying to +accomplish, who is affected and how often, why the current behavior or workaround is not enough, +and what you would like to see instead. + +## What happens after you post + +Our team, maintainers, or community members may ask for missing details, link related +threads, merge duplicates, move your post to a better category, or try to reproduce the problem. + +Not every discussion becomes an issue. Some are answered in Q&A, some turn out to be +configuration problems, and some need more information before engineering can act. A +well-answered discussion is still a useful outcome. + +When a report is confirmed and actionable, a maintainer opens a validated issue linked back to +the discussion, in whichever repository the fix belongs to. You do not need to know which +repository that is. Routing is part of triage. + +## A note on issues + +Issues in this repository are maintainer-curated work items. Every open issue is something a +maintainer or contributor can pick up and act on. Issues opened without a linked validated +discussion may be closed and redirected here. + +Maintainers can still open issues directly for work found internally, such as regressions caught +during development, planned maintenance, or release blockers. + +## Related reading + +- [How to use Discussions, Issues, and Pull Requests](https://github.com/netbirdio/netbird/discussions/6075) +- [Moving to a discussion-first approach](https://github.com/netbirdio/netbird/discussions/6074) +- [CONTRIBUTING.md](CONTRIBUTING.md) for opening pull requests +- [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) From 979571a99ff03a6bf1aea587c1509bbcec3f3d54 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Mon, 28 Sep 2026 14:14:32 +0200 Subject: [PATCH 21/33] [management] Prevent deleting custom domains used by services (#7515) Deleting a custom domain released its name while services still pointed at it, leaving them on a namespace the account no longer held. Deletion now refuses with 412 when a service in the same account uses the domain or a subdomain, including disabled ones. Service writes revalidate authorization inside their transaction and hold a shared lock on the matching registrations, so a delete racing a create cannot strand either. The dependency lookup is account-scoped: registrations are unique by name, so another account can hold team.example.com under example.com and its services are authorized by its own registration. --- docs/custom-domain-validation.md | 5 + .../domain/manager/deletion_test.go | 80 ++++++++ .../reverseproxy/domain/manager/manager.go | 20 ++ .../service/manager/domain_validation_test.go | 50 +++++ .../reverseproxy/service/manager/manager.go | 17 ++ .../service/manager/manager_test.go | 4 + .../server/store/sql_store_custom_domain.go | 48 ++++- .../store/sql_store_custom_domain_test.go | 182 ++++++++++++++++++ .../store/sql_store_domain_expiration.go | 5 +- management/server/store/store.go | 2 + management/server/store/store_mock.go | 16 ++ shared/management/http/api/openapi.yml | 5 +- 12 files changed, 424 insertions(+), 10 deletions(-) create mode 100644 management/internals/modules/reverseproxy/domain/manager/deletion_test.go create mode 100644 management/server/store/sql_store_custom_domain_test.go diff --git a/docs/custom-domain-validation.md b/docs/custom-domain-validation.md index a70388e4d..4b0f95ae3 100644 --- a/docs/custom-domain-validation.md +++ b/docs/custom-domain-validation.md @@ -26,3 +26,8 @@ window. Restarting management does not extend a previously assigned deadline. Registrations with existing services, including services using subdomains, are retained for operator review. Management logs their account and domain IDs so an operator can identify and resolve those dependencies before cleanup. + +Manual deletion is also refused while any service uses the domain or a subdomain, +including disabled services. Delete those services or move them to another domain +before removing the registration. A refused deletion returns HTTP 412 and leaves +the domain and its services unchanged; no deletion activity event is recorded. diff --git a/management/internals/modules/reverseproxy/domain/manager/deletion_test.go b/management/internals/modules/reverseproxy/domain/manager/deletion_test.go new file mode 100644 index 000000000..b47a2bca0 --- /dev/null +++ b/management/internals/modules/reverseproxy/domain/manager/deletion_test.go @@ -0,0 +1,80 @@ +package manager + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/activity" + nbcontext "github.com/netbirdio/netbird/management/server/context" + nbstore "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/shared/auth" +) + +func TestDeleteDomain_ServiceDependencies(t *testing.T) { + for _, tt := range []struct { + name string + domainName string + serviceHost string + accountID string + enabled bool + protected bool + }{ + {"exact", "example.com", "example.com", accountA, true, true}, + {"subdomain", "example.com", "deep.app.example.com", accountA, true, true}, + {"disabled", "example.com", "app.example.com", accountA, false, true}, + // A service is authorized by its own account's registration, so another + // account's service under this namespace is not a dependency of it. + {"other account", "example.com", "app.example.com", accountB, true, false}, + {"case and trailing dot", "example.com", "APP.EXAMPLE.COM.", accountA, true, true}, + {"suffix boundary", "example.com", "notexample.com", accountA, true, false}, + {"literal underscore", "a_b.example.com", "app.a_b.example.com", accountA, true, true}, + {"underscore wildcard", "a_b.example.com", "app.axb.example.com", accountA, true, false}, + } { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + env := setupDomainTest(t) + events := captureDomainEvents(env) + d, err := env.store.CreateCustomDomain(ctx, accountA, tt.domainName, testCluster, true) + require.NoError(t, err) + svc := &rpservice.Service{ + ID: "dependent", AccountID: tt.accountID, Domain: tt.serviceHost, + Enabled: tt.enabled, ProxyCluster: testCluster, + } + require.NoError(t, env.store.CreateService(ctx, svc)) + router := mux.NewRouter() + RegisterEndpoints(router, env.manager) + deleteDomain := func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodDelete, "/domains/"+d.ID, nil) + req = nbcontext.SetUserAuthInRequest(req, auth.UserAuth{AccountId: accountA, UserId: accountAUser}) + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + return response + } + + response := deleteDomain() + if tt.protected { + require.Equal(t, http.StatusPreconditionFailed, response.Code, "dependent services must block deletion: %s", response.Body.String()) + assert.NotContains(t, response.Body.String(), tt.accountID, "the error must not reveal the service's account") + assert.NotNil(t, storedDomain(t, env.store, accountA, d.Domain), "the namespace must remain reserved") + assert.Empty(t, events.get(), "rejected deletion must not emit DomainDeleted") + stored, err := env.store.GetServiceByID(ctx, nbstore.LockingStrengthNone, tt.accountID, svc.ID) + require.NoError(t, err) + assert.Equal(t, svc.Enabled, stored.Enabled, "rejected deletion must preserve the service") + require.NoError(t, env.store.DeleteService(ctx, tt.accountID, svc.ID)) + response = deleteDomain() + } + require.Equal(t, http.StatusNoContent, response.Code, "deletion must succeed without dependencies: %s", response.Body.String()) + assert.Nil(t, storedDomain(t, env.store, accountA, d.Domain), "the registration must be deleted") + captured := events.get() + require.Len(t, captured, 1, "only successful deletion may emit an event") + assert.Equal(t, activity.DomainDeleted, captured[0].Activity, "the event must describe the successful deletion") + }) + } +} diff --git a/management/internals/modules/reverseproxy/domain/manager/manager.go b/management/internals/modules/reverseproxy/domain/manager/manager.go index c0fb12e9c..1e9ecab43 100644 --- a/management/internals/modules/reverseproxy/domain/manager/manager.go +++ b/management/internals/modules/reverseproxy/domain/manager/manager.go @@ -357,6 +357,26 @@ func (m Manager) DeriveClusterFromDomain(ctx context.Context, accountID, domain return "", fmt.Errorf("domain %s does not match any available proxy cluster", domain) } +// ValidateServiceDomain holds custom domain authorization through a service write transaction. +func (m Manager) ValidateServiceDomain(ctx context.Context, tx nbstore.Store, accountID, serviceDomain, cluster string) error { + if _, ok := ExtractClusterFromFreeDomain(serviceDomain, []string{cluster}); ok { + return nil + } + name, err := nbdomain.FromString(serviceDomain) + if err != nil { + return status.Errorf(status.InvalidArgument, "invalid service domain: %v", err) + } + customDomains, err := tx.LockCustomDomains(ctx, accountID, name) + if err != nil { + return err + } + target, match := extractClusterFromCustomDomains(serviceDomain, customDomains) + if match != customDomainValidated || target != cluster { + return status.Errorf(status.PreconditionFailed, "custom domain authorization changed; retry the service operation") + } + return nil +} + func (m Manager) getClusterAllowList(ctx context.Context, accountID string) ([]string, error) { byopAddresses, err := m.proxyManager.GetActiveClusterAddressesForAccount(ctx, accountID) if err != nil { diff --git a/management/internals/modules/reverseproxy/service/manager/domain_validation_test.go b/management/internals/modules/reverseproxy/service/manager/domain_validation_test.go index 6f641b73b..f491d01c3 100644 --- a/management/internals/modules/reverseproxy/service/manager/domain_validation_test.go +++ b/management/internals/modules/reverseproxy/service/manager/domain_validation_test.go @@ -125,3 +125,53 @@ func TestUpdateService_RefusesMoveToUnvalidatedDomain(t *testing.T) { require.NoError(t, err) assert.Equal(t, "app.proven.example.com", stored.Domain, "the service must keep its original domain") } + +func TestCreateService_DomainDeletedBeforeWrite(t *testing.T) { + ctx := context.Background() + mgr, testStore := setupIntegrationTest(t) + withRealDomainManager(t, mgr, testStore) + + d, err := testStore.CreateCustomDomain(ctx, testAccountID, "proven.example.com", validationTestCluster, true) + require.NoError(t, err) + svc := newTestService("app.proven.example.com") + require.NoError(t, mgr.initializeServiceForCreate(ctx, testAccountID, svc)) + + // Delete after the initial authorization check, before the service transaction starts. + require.NoError(t, testStore.DeleteCustomDomain(ctx, testAccountID, d.ID)) + err = mgr.persistNewService(ctx, testAccountID, svc) + require.Error(t, err, "an earlier validation result must not authorize a deleted registration") + sErr, ok := status.FromError(err) + require.True(t, ok, "the caller must receive a typed precondition error") + assert.Equal(t, status.PreconditionFailed, sErr.Type(), "the service must require current domain authorization") + services, err := testStore.GetAccountServices(ctx, store.LockingStrengthNone, testAccountID) + require.NoError(t, err) + assert.Empty(t, services, "the failed write must not leave a service") +} + +func TestUpdateService_DomainDeletedBeforeWrite(t *testing.T) { + ctx := context.Background() + mgr, testStore := setupIntegrationTest(t) + withRealDomainManager(t, mgr, testStore) + _, err := testStore.CreateCustomDomain(ctx, testAccountID, "original.example.com", validationTestCluster, true) + require.NoError(t, err) + d, err := testStore.CreateCustomDomain(ctx, testAccountID, "destination.example.com", validationTestCluster, true) + require.NoError(t, err) + svc, err := mgr.CreateService(ctx, testAccountID, testUserID, newTestService("app.original.example.com")) + require.NoError(t, err) + moved := svc.Copy() + moved.Domain = "app.destination.example.com" + cluster, err := mgr.resolveEffectiveCluster(ctx, testAccountID, moved) + require.NoError(t, err) + + require.NoError(t, testStore.DeleteCustomDomain(ctx, testAccountID, d.ID)) + err = testStore.ExecuteInTransaction(ctx, func(tx store.Store) error { + return mgr.executeServiceUpdate(ctx, tx, testAccountID, moved, &serviceUpdateInfo{}, nil, cluster) + }) + require.Error(t, err, "a domain deleted after cluster resolution must reject the update") + sErr, ok := status.FromError(err) + require.True(t, ok, "the caller must receive a typed precondition error") + assert.Equal(t, status.PreconditionFailed, sErr.Type(), "the move must require current domain authorization") + stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, svc.ID) + require.NoError(t, err) + assert.Equal(t, svc.Domain, stored.Domain, "the service must retain its authorized domain") +} diff --git a/management/internals/modules/reverseproxy/service/manager/manager.go b/management/internals/modules/reverseproxy/service/manager/manager.go index 9c7f95eb4..62897c9ae 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager.go +++ b/management/internals/modules/reverseproxy/service/manager/manager.go @@ -74,6 +74,7 @@ const unknownHostPlaceholder = "unknown" // ClusterDeriver derives the proxy cluster from a domain. type ClusterDeriver interface { DeriveClusterFromDomain(ctx context.Context, accountID, domain string) (string, error) + ValidateServiceDomain(ctx context.Context, tx store.Store, accountID, domain, cluster string) error GetClusterDomains() []string } @@ -332,6 +333,9 @@ func (m *Manager) persistNewService(ctx context.Context, accountID string, svc * } return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + if err := m.validateServiceDomain(ctx, transaction, accountID, svc, svc.ProxyCluster); err != nil { + return err + } if svc.Domain != "" { if err := m.checkDomainAvailable(ctx, transaction, svc.Domain, ""); err != nil { return err @@ -461,6 +465,9 @@ func (m *Manager) persistNewEphemeralService(ctx context.Context, accountID, pee } return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + if err := m.validateServiceDomain(ctx, transaction, accountID, svc, svc.ProxyCluster); err != nil { + return err + } if err := m.validateEphemeralPreconditions(ctx, transaction, accountID, peerID, svc); err != nil { return err } @@ -622,6 +629,9 @@ func (m *Manager) resolveEffectiveCluster(ctx context.Context, accountID string, } func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.Store, accountID string, service *service.Service, updateInfo *serviceUpdateInfo, customPorts *bool, effectiveCluster string) error { + if err := m.validateServiceDomain(ctx, transaction, accountID, service, effectiveCluster); err != nil { + return err + } existingService, err := transaction.GetServiceByID(ctx, store.LockingStrengthUpdate, accountID, service.ID) if err != nil { return err @@ -677,6 +687,13 @@ func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.St return nil } +func (m *Manager) validateServiceDomain(ctx context.Context, tx store.Store, accountID string, svc *service.Service, cluster string) error { + if m.clusterDeriver == nil { + return nil + } + return m.clusterDeriver.ValidateServiceDomain(ctx, tx, accountID, svc.Domain, cluster) +} + // validateL4PortDiffOnClusterDiff checks if custom L4 ports are configured and validates port changes across clusters. // It ensures no port changes if custom ports are unsupported for a given cluster and protocol mode. // Returns an error if validation fails, otherwise returns nil. diff --git a/management/internals/modules/reverseproxy/service/manager/manager_test.go b/management/internals/modules/reverseproxy/service/manager/manager_test.go index dd0edec60..9704796be 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/service/manager/manager_test.go @@ -655,6 +655,10 @@ func (d *testClusterDeriver) GetClusterDomains() []string { return d.domains } +func (d *testClusterDeriver) ValidateServiceDomain(context.Context, store.Store, string, string, string) error { + return nil +} + const ( testAccountID = "test-account" testPeerID = "test-peer-1" diff --git a/management/server/store/sql_store_custom_domain.go b/management/server/store/sql_store_custom_domain.go index 9eaaf2b10..5b7cbf519 100644 --- a/management/server/store/sql_store_custom_domain.go +++ b/management/server/store/sql_store_custom_domain.go @@ -4,13 +4,16 @@ import ( "context" "errors" "fmt" + "strings" "time" "github.com/rs/xid" log "github.com/sirupsen/logrus" "gorm.io/gorm" + "gorm.io/gorm/clause" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + nbdomain "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/status" ) @@ -131,16 +134,45 @@ func (s *SqlStore) UpdateCustomDomain(ctx context.Context, accountID string, d * return d, nil } -func (s *SqlStore) DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error { - result := s.db.Delete(domain.Domain{}, accountAndIDQueryCondition, accountID, domainID) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to delete reverse proxy custom domain from store: %v", result.Error) - return status.Errorf(status.Internal, "failed to delete reverse proxy custom domain from store") +// LockCustomDomains holds shared locks on registrations covering a service until commit. +func (s *SqlStore) LockCustomDomains(ctx context.Context, accountID string, serviceDomain nbdomain.Domain) ([]*domain.Domain, error) { + var names []string + for name := serviceDomain.PunycodeString(); name != ""; { + names = append(names, name) + _, name, _ = strings.Cut(name, ".") } - if result.RowsAffected == 0 { - return status.Errorf(status.NotFound, "reverse proxy custom domain %s not found", domainID) + var domains []*domain.Domain + if err := s.db.WithContext(ctx).Clauses(clause.Locking{Strength: string(LockingStrengthShare)}). + Where(accountIDCondition, accountID).Where("domain IN ?", names). + Order("id").Find(&domains).Error; err != nil { + return nil, fmt.Errorf("lock custom domains: %w", err) } + return domains, nil +} - return nil +// DeleteCustomDomain removes a registration only when no service uses its namespace. +func (s *SqlStore) DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error { + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var d domain.Domain + // Service writes hold a shared lock on this row through commit, so neither + // operation can proceed against the other's outdated view of the domain. + if err := tx.Clauses(clause.Locking{Strength: string(LockingStrengthUpdate)}). + Take(&d, accountAndIDQueryCondition, accountID, domainID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return status.Errorf(status.NotFound, "custom domain not found") + } + return fmt.Errorf("lock custom domain for deletion: %w", err) + } + + result := tx.Where(accountAndIDQueryCondition, accountID, domainID). + Where("NOT EXISTS (?)", customDomainServices(tx, &d).Select("1")).Delete(&domain.Domain{}) + if result.Error != nil { + return fmt.Errorf("delete custom domain: %w", result.Error) + } + if result.RowsAffected == 0 { + return status.Errorf(status.PreconditionFailed, "custom domain has dependent services; delete or move them before deleting the domain") + } + return nil + }) } diff --git a/management/server/store/sql_store_custom_domain_test.go b/management/server/store/sql_store_custom_domain_test.go new file mode 100644 index 000000000..a274c7271 --- /dev/null +++ b/management/server/store/sql_store_custom_domain_test.go @@ -0,0 +1,182 @@ +package store + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/types" + nbdomain "github.com/netbirdio/netbird/shared/management/domain" + "github.com/netbirdio/netbird/shared/management/status" +) + +func TestLockCustomDomains_ConcurrentServices(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + if store.GetStoreEngine() == types.SqliteStoreEngine { + t.Skip("SQLite serializes transactions on one connection") + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + require.NoError(t, store.SaveAccount(ctx, newAccountWithId(ctx, "owner", "admin", ""))) + _, err := store.CreateCustomDomain(ctx, "owner", "one.example.com", "cluster", true) + require.NoError(t, err) + _, err = store.CreateCustomDomain(ctx, "owner", "two.example.com", "cluster", true) + require.NoError(t, err) + + locked := make(chan error, 1) + release := make(chan struct{}) + done := make(chan error, 1) + go func() { + done <- store.ExecuteInTransaction(ctx, func(tx Store) error { + _, err := tx.LockCustomDomains(ctx, "owner", "app.one.example.com") + locked <- err + if err != nil { + return err + } + select { + case <-release: + return nil + case <-ctx.Done(): + return ctx.Err() + } + }) + }() + var lockErr error + select { + case lockErr = <-locked: + case err := <-done: + t.Fatalf("transaction ended before locking: %v", err) + } + writeCtx, writeCancel := context.WithTimeout(ctx, 3*time.Second) + defer writeCancel() + var writeErr error + for _, name := range []nbdomain.Domain{"app.one.example.com", "app.two.example.com"} { + writeErr = store.ExecuteInTransaction(writeCtx, func(tx Store) error { + if _, err := tx.LockCustomDomains(writeCtx, "owner", name); err != nil { + return err + } + return tx.CreateService(writeCtx, &rpservice.Service{ + ID: name.PunycodeString(), AccountID: "owner", Domain: name.PunycodeString(), + }) + }) + if writeErr != nil { + break + } + } + close(release) + require.NoError(t, <-done) + require.NoError(t, lockErr) + require.NoError(t, writeErr, "domain authorization locks must allow concurrent service writes") + services, err := store.GetAccountServices(ctx, LockingStrengthNone, "owner") + require.NoError(t, err) + assert.Len(t, services, 2, "both services must commit while the first domain is locked") + }) +} + +func TestDeleteCustomDomain_ServiceDependencies(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + ctx := context.Background() + require.NoError(t, store.SaveAccount(ctx, newAccountWithId(ctx, "owner", "admin", ""))) + d, err := store.CreateCustomDomain(ctx, "owner", "example.com", "cluster", true) + require.NoError(t, err) + svc := &rpservice.Service{ID: "service", AccountID: "owner", Domain: "APP.EXAMPLE.COM."} + require.NoError(t, store.CreateService(ctx, svc)) + + err = store.DeleteCustomDomain(ctx, "other", d.ID) + require.Error(t, err) + sErr, ok := status.FromError(err) + require.True(t, ok, "cross-account deletion must return a typed error") + assert.Equal(t, status.NotFound, sErr.Type(), "cross-account deletion must not reveal dependencies") + + err = store.DeleteCustomDomain(ctx, "owner", d.ID) + require.Error(t, err) + sErr, ok = status.FromError(err) + require.True(t, ok, "dependent services must return a typed error") + assert.Equal(t, status.PreconditionFailed, sErr.Type(), "deletion must fail until services are removed") + stored, err := store.GetCustomDomain(ctx, "owner", d.ID) + require.NoError(t, err) + assert.True(t, stored.Validated, "rejected deletion must preserve validation") + + require.NoError(t, store.DeleteService(ctx, "owner", svc.ID)) + require.NoError(t, store.DeleteCustomDomain(ctx, "owner", d.ID)) + _, err = store.GetCustomDomain(ctx, "owner", d.ID) + require.Error(t, err, "the registration must be gone after successful deletion") + }) +} + +func TestDeleteCustomDomain_OtherAccountSubdomainIsNotADependency(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + ctx := context.Background() + require.NoError(t, store.SaveAccount(ctx, newAccountWithId(ctx, "owner", "admin", ""))) + require.NoError(t, store.SaveAccount(ctx, newAccountWithId(ctx, "other", "admin", ""))) + + // Registrations are unique by name, so a second account can hold a + // subdomain of the first account's registration and serve from it. + parent, err := store.CreateCustomDomain(ctx, "owner", "example.com", "cluster", true) + require.NoError(t, err) + _, err = store.CreateCustomDomain(ctx, "other", "team.example.com", "cluster", true) + require.NoError(t, err) + require.NoError(t, store.CreateService(ctx, &rpservice.Service{ + ID: "service", AccountID: "other", Domain: "app.team.example.com", + })) + + require.NoError(t, store.DeleteCustomDomain(ctx, "owner", parent.ID), + "another account's service must not hold the registration open") + }) +} + +func TestDeleteCustomDomain_ConcurrentServiceCreation(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + require.NoError(t, store.SaveAccount(ctx, newAccountWithId(ctx, "owner", "admin", ""))) + for i := range 10 { + d, err := store.CreateCustomDomain(ctx, "owner", fmt.Sprintf("app%d.example.com", i), "cluster", true) + require.NoError(t, err) + svc := &rpservice.Service{ID: fmt.Sprintf("service-%d", i), AccountID: "owner", Domain: "nested." + d.Domain} + start := make(chan struct{}) + created := make(chan error, 1) + deleted := make(chan error, 1) + go func() { + <-start + created <- store.ExecuteInTransaction(ctx, func(tx Store) error { + domains, err := tx.LockCustomDomains(ctx, "owner", nbdomain.Domain(svc.Domain)) + if err != nil { + return err + } + for _, candidate := range domains { + if candidate.ID == d.ID && candidate.Validated { + return tx.CreateService(ctx, svc) + } + } + return status.Errorf(status.PreconditionFailed, "registration was deleted") + }) + }() + go func() { + <-start + deleted <- store.DeleteCustomDomain(ctx, "owner", d.ID) + }() + close(start) + createErr, deleteErr := <-created, <-deleted + require.True(t, createErr == nil || deleteErr == nil, "one operation must succeed: create=%v, delete=%v", createErr, deleteErr) + if createErr == nil { + require.Error(t, deleteErr, "a committed service must block deletion") + stored, err := store.GetCustomDomain(ctx, "owner", d.ID) + require.NoError(t, err) + assert.True(t, stored.Validated, "the service must retain its authorization") + require.NoError(t, store.DeleteService(ctx, "owner", svc.ID)) + require.NoError(t, store.DeleteCustomDomain(ctx, "owner", d.ID)) + continue + } + require.NoError(t, deleteErr) + services, err := store.GetAccountServices(ctx, LockingStrengthNone, "owner") + require.NoError(t, err) + assert.Empty(t, services, "a deleted registration must not leave a new service") + } + }) +} diff --git a/management/server/store/sql_store_domain_expiration.go b/management/server/store/sql_store_domain_expiration.go index 525c54084..e7b09f4ad 100644 --- a/management/server/store/sql_store_domain_expiration.go +++ b/management/server/store/sql_store_domain_expiration.go @@ -53,7 +53,10 @@ func customDomainServices(db *gorm.DB, d *domain.Domain) *gorm.DB { // Shared domain validation permits underscores, and older rows may contain // other LIKE metacharacters. escaped := strings.NewReplacer("!", "!!", "%", "!%", "_", "!_").Replace(name) - return db.Model(&rpservice.Service{}).Where( + // Registrations are unique by name, so another account can hold a subdomain + // of this one and serve from it. Its services derive their cluster from that + // account's own registration and are not dependents of this one. + return db.Model(&rpservice.Service{}).Where(accountIDCondition, d.AccountID).Where( "LOWER(domain) IN ? OR LOWER(domain) LIKE ? ESCAPE '!' OR LOWER(domain) LIKE ? ESCAPE '!'", []string{name, name + "."}, "%."+escaped, "%."+escaped+".", ) diff --git a/management/server/store/store.go b/management/server/store/store.go index b6368f47f..ed53690a5 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -36,6 +36,7 @@ import ( "github.com/netbirdio/netbird/management/server/telemetry" "github.com/netbirdio/netbird/management/server/testutil" "github.com/netbirdio/netbird/management/server/types" + nbdomain "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/util" "github.com/netbirdio/netbird/util/crypt" @@ -307,6 +308,7 @@ type Store interface { GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error) ListFreeDomains(ctx context.Context, accountID string) ([]string, error) ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error) + LockCustomDomains(ctx context.Context, accountID string, serviceDomain nbdomain.Domain) ([]*domain.Domain, error) GetCustomDomainByName(ctx context.Context, domainName string) (*domain.Domain, error) CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error) UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error) diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index 460ba712b..2db74fb86 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -31,6 +31,7 @@ import ( posture "github.com/netbirdio/netbird/management/server/posture" types3 "github.com/netbirdio/netbird/management/server/types" route "github.com/netbirdio/netbird/route" + domain0 "github.com/netbirdio/netbird/shared/management/domain" crypt "github.com/netbirdio/netbird/util/crypt" gomock "go.uber.org/mock/gomock" ) @@ -3302,6 +3303,21 @@ func (mr *MockStoreMockRecorder) ListFreeDomains(ctx, accountID any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListFreeDomains", reflect.TypeOf((*MockStore)(nil).ListFreeDomains), ctx, accountID) } +// LockCustomDomains mocks base method. +func (m *MockStore) LockCustomDomains(ctx context.Context, accountID string, serviceDomain domain0.Domain) ([]*domain.Domain, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LockCustomDomains", ctx, accountID, serviceDomain) + ret0, _ := ret[0].([]*domain.Domain) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LockCustomDomains indicates an expected call of LockCustomDomains. +func (mr *MockStoreMockRecorder) LockCustomDomains(ctx, accountID, serviceDomain any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LockCustomDomains", reflect.TypeOf((*MockStore)(nil).LockCustomDomains), ctx, accountID, serviceDomain) +} + // MarkAccountPrimary mocks base method. func (m *MockStore) MarkAccountPrimary(ctx context.Context, accountID string) error { m.ctrl.T.Helper() diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 5ad682c6b..c19df4fcf 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -13347,7 +13347,7 @@ paths: /api/reverse-proxies/domains/{domainId}: delete: summary: Delete a Custom domain - description: Delete an existing service custom domain + description: Delete an existing service custom domain after removing or moving all services that use it or its subdomains, including disabled services. tags: [ Services ] security: - BearerAuth: [ ] @@ -13370,6 +13370,9 @@ paths: "$ref": "#/components/responses/forbidden" '404': "$ref": "#/components/responses/not_found" + '412': + description: The domain or one of its subdomains is still used by a service + content: { } '500': "$ref": "#/components/responses/internal_error" /api/reverse-proxies/domains/{domainId}/validate: From 5d92c89227be2a5dda77a571457a0e7bc94b64cc Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:44:52 +0200 Subject: [PATCH 22/33] [management] move rate limiter to shared package (#7727) --- management/internals/server/boot.go | 10 +++--- management/server/http/handler.go | 5 +-- management/server/http/handlers/proxy/auth.go | 8 ++--- .../http/handlers/users/invites_handler.go | 4 +-- .../server/http/middleware/auth_middleware.go | 5 +-- .../http/middleware/auth_middleware_test.go | 35 ++++++++++--------- .../ratelimit}/rate_limiter.go | 24 +++++++++++-- .../ratelimit}/rate_limiter_test.go | 4 ++- upload-server/server/local.go | 4 +-- upload-server/server/ratelimit.go | 14 ++++---- upload-server/server/ratelimit_test.go | 16 ++++----- upload-server/server/s3.go | 4 +-- upload-server/server/server.go | 6 ++-- 13 files changed, 81 insertions(+), 58 deletions(-) rename {management/server/http/middleware => shared/ratelimit}/rate_limiter.go (91%) rename {management/server/http/middleware => shared/ratelimit}/rate_limiter_test.go (97%) diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index ea999d82b..dbad2f0c8 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -38,11 +38,11 @@ import ( nbcache "github.com/netbirdio/netbird/management/server/cache" nbContext "github.com/netbirdio/netbird/management/server/context" nbhttp "github.com/netbirdio/netbird/management/server/http" - "github.com/netbirdio/netbird/management/server/http/middleware" "github.com/netbirdio/netbird/management/server/idp" "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/telemetry" mgmtProto "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/shared/ratelimit" "github.com/netbirdio/netbird/util/crypt" ) @@ -171,10 +171,10 @@ func (s *BaseServer) Router() *mux.Router { }) } -func (s *BaseServer) RateLimiter() *middleware.APIRateLimiter { - return Create(s, func() *middleware.APIRateLimiter { - cfg, enabled := middleware.RateLimiterConfigFromEnv() - limiter := middleware.NewAPIRateLimiter(cfg) +func (s *BaseServer) RateLimiter() *ratelimit.APIRateLimiter { + return Create(s, func() *ratelimit.APIRateLimiter { + cfg, enabled := ratelimit.RateLimiterConfigFromEnv() + limiter := ratelimit.NewAPIRateLimiter(cfg) limiter.SetEnabled(enabled) return limiter }) diff --git a/management/server/http/handler.go b/management/server/http/handler.go index a57f44b3c..cd365ec13 100644 --- a/management/server/http/handler.go +++ b/management/server/http/handler.go @@ -58,10 +58,11 @@ import ( "github.com/netbirdio/netbird/management/server/networks/resources" "github.com/netbirdio/netbird/management/server/networks/routers" "github.com/netbirdio/netbird/management/server/telemetry" + "github.com/netbirdio/netbird/shared/ratelimit" ) // NewAPIHandler creates the Management service HTTP API handler registering all the available endpoints. -func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, permissionsManager permissions.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, serviceManager service.Manager, reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, trustedHTTPProxies []netip.Prefix, rateLimiter *middleware.APIRateLimiter, isValidChildAccount middleware.IsValidChildAccountFunc, agentNetworkManager agentnetwork.Manager) (http.Handler, error) { +func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, permissionsManager permissions.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, serviceManager service.Manager, reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, trustedHTTPProxies []netip.Prefix, rateLimiter *ratelimit.APIRateLimiter, isValidChildAccount middleware.IsValidChildAccountFunc, agentNetworkManager agentnetwork.Manager) (http.Handler, error) { // Register bypass paths for unauthenticated endpoints if err := bypass.AddBypassPath("/api/instance"); err != nil { @@ -84,7 +85,7 @@ func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager accou if rateLimiter == nil { log.Warn("NewAPIHandler: nil rate limiter, rate limiting disabled") - rateLimiter = middleware.NewAPIRateLimiter(nil) + rateLimiter = ratelimit.NewAPIRateLimiter(nil) rateLimiter.SetEnabled(false) } diff --git a/management/server/http/handlers/proxy/auth.go b/management/server/http/handlers/proxy/auth.go index 0f4b72e14..15327fd30 100644 --- a/management/server/http/handlers/proxy/auth.go +++ b/management/server/http/handlers/proxy/auth.go @@ -16,21 +16,21 @@ import ( "golang.org/x/oauth2" nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" - "github.com/netbirdio/netbird/management/server/http/middleware" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/shared/ratelimit" ) // AuthCallbackHandler handles OAuth callbacks for proxy authentication. type AuthCallbackHandler struct { proxyService *nbgrpc.ProxyServiceServer - rateLimiter *middleware.APIRateLimiter + rateLimiter *ratelimit.APIRateLimiter trustedProxies []netip.Prefix } // NewAuthCallbackHandler creates a new OAuth callback handler. func NewAuthCallbackHandler(proxyService *nbgrpc.ProxyServiceServer, trustedProxies []netip.Prefix) *AuthCallbackHandler { - rateLimiterConfig := &middleware.RateLimiterConfig{ + rateLimiterConfig := &ratelimit.RateLimiterConfig{ RequestsPerMinute: 10, Burst: 15, CleanupInterval: 5 * time.Minute, @@ -39,7 +39,7 @@ func NewAuthCallbackHandler(proxyService *nbgrpc.ProxyServiceServer, trustedProx return &AuthCallbackHandler{ proxyService: proxyService, - rateLimiter: middleware.NewAPIRateLimiter(rateLimiterConfig), + rateLimiter: ratelimit.NewAPIRateLimiter(rateLimiterConfig), trustedProxies: trustedProxies, } } diff --git a/management/server/http/handlers/users/invites_handler.go b/management/server/http/handlers/users/invites_handler.go index 0f0f57c29..d96e58bde 100644 --- a/management/server/http/handlers/users/invites_handler.go +++ b/management/server/http/handlers/users/invites_handler.go @@ -11,15 +11,15 @@ import ( "github.com/netbirdio/netbird/management/server/account" nbcontext "github.com/netbirdio/netbird/management/server/context" - "github.com/netbirdio/netbird/management/server/http/middleware" "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/management/http/api" "github.com/netbirdio/netbird/shared/management/http/util" "github.com/netbirdio/netbird/shared/management/status" + "github.com/netbirdio/netbird/shared/ratelimit" ) // publicInviteRateLimiter limits public invite requests by IP address to prevent brute-force attacks -var publicInviteRateLimiter = middleware.NewAPIRateLimiter(&middleware.RateLimiterConfig{ +var publicInviteRateLimiter = ratelimit.NewAPIRateLimiter(&ratelimit.RateLimiterConfig{ RequestsPerMinute: 10, // 10 attempts per minute per IP Burst: 5, // Allow burst of 5 requests CleanupInterval: 10 * time.Minute, diff --git a/management/server/http/middleware/auth_middleware.go b/management/server/http/middleware/auth_middleware.go index ba8c66241..1e831e6c3 100644 --- a/management/server/http/middleware/auth_middleware.go +++ b/management/server/http/middleware/auth_middleware.go @@ -18,6 +18,7 @@ import ( "github.com/netbirdio/netbird/shared/auth" "github.com/netbirdio/netbird/shared/management/http/util" "github.com/netbirdio/netbird/shared/management/status" + "github.com/netbirdio/netbird/shared/ratelimit" ) type EnsureAccountFunc func(ctx context.Context, userAuth auth.UserAuth) (string, string, error) @@ -33,7 +34,7 @@ type AuthMiddleware struct { ensureAccount EnsureAccountFunc getUserFromUserAuth GetUserFromUserAuthFunc syncUserJWTGroups SyncUserJWTGroupsFunc - rateLimiter *APIRateLimiter + rateLimiter *ratelimit.APIRateLimiter patUsageTracker *PATUsageTracker isValidChildAccount IsValidChildAccountFunc } @@ -44,7 +45,7 @@ func NewAuthMiddleware( ensureAccount EnsureAccountFunc, syncUserJWTGroups SyncUserJWTGroupsFunc, getUserFromUserAuth GetUserFromUserAuthFunc, - rateLimiter *APIRateLimiter, + rateLimiter *ratelimit.APIRateLimiter, meter metric.Meter, isValidChildAccount IsValidChildAccountFunc, ) *AuthMiddleware { diff --git a/management/server/http/middleware/auth_middleware_test.go b/management/server/http/middleware/auth_middleware_test.go index a34554660..ceefc7c45 100644 --- a/management/server/http/middleware/auth_middleware_test.go +++ b/management/server/http/middleware/auth_middleware_test.go @@ -18,6 +18,7 @@ import ( "github.com/netbirdio/netbird/management/server/util" nbauth "github.com/netbirdio/netbird/shared/auth" nbjwt "github.com/netbirdio/netbird/shared/auth/jwt" + "github.com/netbirdio/netbird/shared/ratelimit" ) const ( @@ -196,7 +197,7 @@ func TestAuthMiddleware_Handler(t *testing.T) { GetPATInfoFunc: mockGetAccountInfoFromPAT, } - disabledLimiter := NewAPIRateLimiter(nil) + disabledLimiter := ratelimit.NewAPIRateLimiter(nil) disabledLimiter.SetEnabled(false) authMiddleware := NewAuthMiddleware( mockAuth, @@ -260,7 +261,7 @@ func TestAuthMiddleware_SyncUserJWTGroupsDetachedFromRequestCancellation(t *test GetPATInfoFunc: mockGetAccountInfoFromPAT, } - disabledLimiter := NewAPIRateLimiter(nil) + disabledLimiter := ratelimit.NewAPIRateLimiter(nil) disabledLimiter.SetEnabled(false) authMiddleware := NewAuthMiddleware( @@ -311,7 +312,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { t.Run("PAT Token Rate Limiting - Burst Works", func(t *testing.T) { // Configure rate limiter: 10 requests per minute with burst of 5 - rateLimitConfig := &RateLimiterConfig{ + rateLimitConfig := &ratelimit.RateLimiterConfig{ RequestsPerMinute: 10, Burst: 5, CleanupInterval: 5 * time.Minute, @@ -329,7 +330,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { return &types.User{}, nil }, - NewAPIRateLimiter(rateLimitConfig), + ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, func(_ context.Context, _, _, _ string) bool { return false }, ) @@ -364,7 +365,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { t.Run("PAT Token Rate Limiting - Rate Limit Enforced", func(t *testing.T) { // Configure very low rate limit: 1 request per minute - rateLimitConfig := &RateLimiterConfig{ + rateLimitConfig := &ratelimit.RateLimiterConfig{ RequestsPerMinute: 1, Burst: 1, CleanupInterval: 5 * time.Minute, @@ -382,7 +383,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { return &types.User{}, nil }, - NewAPIRateLimiter(rateLimitConfig), + ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, func(_ context.Context, _, _, _ string) bool { return false }, ) @@ -408,7 +409,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { t.Run("Bearer Token Not Rate Limited", func(t *testing.T) { // Configure strict rate limit - rateLimitConfig := &RateLimiterConfig{ + rateLimitConfig := &ratelimit.RateLimiterConfig{ RequestsPerMinute: 1, Burst: 1, CleanupInterval: 5 * time.Minute, @@ -426,7 +427,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { return &types.User{}, nil }, - NewAPIRateLimiter(rateLimitConfig), + ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, func(_ context.Context, _, _, _ string) bool { return false }, ) @@ -453,7 +454,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { t.Run("PAT Token Rate Limiting Per Token", func(t *testing.T) { // Configure rate limiter - rateLimitConfig := &RateLimiterConfig{ + rateLimitConfig := &ratelimit.RateLimiterConfig{ RequestsPerMinute: 1, Burst: 1, CleanupInterval: 5 * time.Minute, @@ -471,7 +472,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { return &types.User{}, nil }, - NewAPIRateLimiter(rateLimitConfig), + ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, func(_ context.Context, _, _, _ string) bool { return false }, ) @@ -518,7 +519,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { t.Run("Rate Limiter Cleanup", func(t *testing.T) { // Configure rate limiter with short cleanup interval and TTL for testing - rateLimitConfig := &RateLimiterConfig{ + rateLimitConfig := &ratelimit.RateLimiterConfig{ RequestsPerMinute: 60, Burst: 1, CleanupInterval: 100 * time.Millisecond, @@ -536,7 +537,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { return &types.User{}, nil }, - NewAPIRateLimiter(rateLimitConfig), + ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, func(_ context.Context, _, _, _ string) bool { return false }, ) @@ -578,7 +579,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { }) t.Run("Terraform User Agent Not Rate Limited", func(t *testing.T) { - rateLimitConfig := &RateLimiterConfig{ + rateLimitConfig := &ratelimit.RateLimiterConfig{ RequestsPerMinute: 1, Burst: 1, CleanupInterval: 5 * time.Minute, @@ -596,7 +597,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { return &types.User{}, nil }, - NewAPIRateLimiter(rateLimitConfig), + ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, func(_ context.Context, _, _, _ string) bool { return false }, ) @@ -634,7 +635,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { }) t.Run("Non-Terraform User Agent With PAT Is Rate Limited", func(t *testing.T) { - rateLimitConfig := &RateLimiterConfig{ + rateLimitConfig := &ratelimit.RateLimiterConfig{ RequestsPerMinute: 1, Burst: 1, CleanupInterval: 5 * time.Minute, @@ -652,7 +653,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) { func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) { return &types.User{}, nil }, - NewAPIRateLimiter(rateLimitConfig), + ratelimit.NewAPIRateLimiter(rateLimitConfig), nil, func(_ context.Context, _, _, _ string) bool { return false }, ) @@ -740,7 +741,7 @@ func TestAuthMiddleware_Handler_Child(t *testing.T) { GetPATInfoFunc: mockGetAccountInfoFromPAT, } - disabledLimiter := NewAPIRateLimiter(nil) + disabledLimiter := ratelimit.NewAPIRateLimiter(nil) disabledLimiter.SetEnabled(false) authMiddleware := NewAuthMiddleware( mockAuth, diff --git a/management/server/http/middleware/rate_limiter.go b/shared/ratelimit/rate_limiter.go similarity index 91% rename from management/server/http/middleware/rate_limiter.go rename to shared/ratelimit/rate_limiter.go index 6995e71bb..ff9a123a3 100644 --- a/management/server/http/middleware/rate_limiter.go +++ b/shared/ratelimit/rate_limiter.go @@ -1,7 +1,8 @@ -package middleware +package ratelimit import ( "context" + "encoding/json" "net" "net/http" "os" @@ -13,7 +14,6 @@ import ( log "github.com/sirupsen/logrus" "golang.org/x/time/rate" - "github.com/netbirdio/netbird/shared/management/http/util" "github.com/netbirdio/netbird/trustedproxy" ) @@ -264,13 +264,31 @@ func (rl *APIRateLimiter) Middleware(next http.Handler) http.Handler { } clientIP := getClientIP(r, rl.config.TrustedProxies) if !rl.Allow(clientIP) { - util.WriteErrorResponse("rate limit exceeded, please try again later", http.StatusTooManyRequests, w) + writeTooManyRequests(w) return } next.ServeHTTP(w, r) }) } +// errorResponse is the JSON body of a rejected request. +type errorResponse struct { + Message string `json:"message"` + Code int `json:"code"` +} + +// writeTooManyRequests writes a JSON error response with status 429 Too Many Requests. +func writeTooManyRequests(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json; charset=UTF-8") + w.WriteHeader(http.StatusTooManyRequests) + if err := json.NewEncoder(w).Encode(errorResponse{ + Message: "rate limit exceeded, please try again later", + Code: http.StatusTooManyRequests, + }); err != nil { + log.Debugf("writing rate limit response: %v", err) + } +} + // getClientIP extracts the client IP address from the request. Forwarding headers // are used only when the request arrives from a trusted proxy. func getClientIP(r *http.Request, trusted *trustedproxy.List) string { diff --git a/management/server/http/middleware/rate_limiter_test.go b/shared/ratelimit/rate_limiter_test.go similarity index 97% rename from management/server/http/middleware/rate_limiter_test.go rename to shared/ratelimit/rate_limiter_test.go index c647030c7..c43b8e03d 100644 --- a/management/server/http/middleware/rate_limiter_test.go +++ b/shared/ratelimit/rate_limiter_test.go @@ -1,4 +1,4 @@ -package middleware +package ratelimit import ( "fmt" @@ -66,6 +66,8 @@ func TestAPIRateLimiter_Middleware(t *testing.T) { rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) assert.Equal(t, http.StatusTooManyRequests, rr.Code) + assert.Equal(t, "application/json; charset=UTF-8", rr.Header().Get("Content-Type")) + assert.JSONEq(t, `{"message":"rate limit exceeded, please try again later","code":429}`, rr.Body.String()) } func TestAPIRateLimiter_Middleware_DifferentIPs(t *testing.T) { diff --git a/upload-server/server/local.go b/upload-server/server/local.go index 7db2740f9..859eeb9b2 100644 --- a/upload-server/server/local.go +++ b/upload-server/server/local.go @@ -12,7 +12,7 @@ import ( log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/management/server/http/middleware" + "github.com/netbirdio/netbird/shared/ratelimit" "github.com/netbirdio/netbird/upload-server/types" ) @@ -28,7 +28,7 @@ type local struct { signer *signer } -func configureLocalHandlers(mux *http.ServeMux, limiter *middleware.APIRateLimiter) error { +func configureLocalHandlers(mux *http.ServeMux, limiter *ratelimit.APIRateLimiter) error { envURL, ok := os.LookupEnv("SERVER_URL") if !ok { return fmt.Errorf("SERVER_URL environment variable is required") diff --git a/upload-server/server/ratelimit.go b/upload-server/server/ratelimit.go index 081cf89c1..0a38e5cc3 100644 --- a/upload-server/server/ratelimit.go +++ b/upload-server/server/ratelimit.go @@ -5,27 +5,27 @@ import ( log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/management/server/http/middleware" + "github.com/netbirdio/netbird/shared/ratelimit" ) const defaultUploadBurst = 100 -func newRateLimiter() *middleware.APIRateLimiter { - cfg, enabled := middleware.RateLimiterConfigFromEnv() - if os.Getenv(middleware.RateLimitingBurstEnv) == "" { +func newRateLimiter() *ratelimit.APIRateLimiter { + cfg, enabled := ratelimit.RateLimiterConfigFromEnv() + if os.Getenv(ratelimit.RateLimitingBurstEnv) == "" { cfg.Burst = defaultUploadBurst } // Rate limiting is enabled by default unless explicitly disabled - if os.Getenv(middleware.RateLimitingEnabledEnv) == "" { + if os.Getenv(ratelimit.RateLimitingEnabledEnv) == "" { enabled = true } - limiter := middleware.NewAPIRateLimiter(cfg) + limiter := ratelimit.NewAPIRateLimiter(cfg) limiter.SetEnabled(enabled) log.Infof("Upload URL rate limiting: enabled=%t rate=%.0f/min burst=%d trusted_proxies=%q", - limiter.Enabled(), cfg.RequestsPerMinute, cfg.Burst, os.Getenv(middleware.RateLimitingTrustedProxiesEnv)) + limiter.Enabled(), cfg.RequestsPerMinute, cfg.Burst, os.Getenv(ratelimit.RateLimitingTrustedProxiesEnv)) return limiter } diff --git a/upload-server/server/ratelimit_test.go b/upload-server/server/ratelimit_test.go index 8414a1c09..18b5a34fd 100644 --- a/upload-server/server/ratelimit_test.go +++ b/upload-server/server/ratelimit_test.go @@ -7,11 +7,11 @@ import ( "github.com/stretchr/testify/require" - "github.com/netbirdio/netbird/management/server/http/middleware" + "github.com/netbirdio/netbird/shared/ratelimit" "github.com/netbirdio/netbird/upload-server/types" ) -func newTestRateLimiter(t *testing.T) *middleware.APIRateLimiter { +func newTestRateLimiter(t *testing.T) *ratelimit.APIRateLimiter { t.Helper() limiter := newRateLimiter() @@ -32,8 +32,8 @@ func getUploadURL(t *testing.T, mux *http.ServeMux) int { } func Test_GetUploadURLIsRateLimited(t *testing.T) { - t.Setenv(middleware.RateLimitingBurstEnv, "2") - t.Setenv(middleware.RateLimitingRPMEnv, "1") + t.Setenv(ratelimit.RateLimitingBurstEnv, "2") + t.Setenv(ratelimit.RateLimitingRPMEnv, "1") mux, _ := newLocalMux(t) require.Equal(t, http.StatusOK, getUploadURL(t, mux)) @@ -42,8 +42,8 @@ func Test_GetUploadURLIsRateLimited(t *testing.T) { } func Test_RateLimitingIsOnByDefault(t *testing.T) { - t.Setenv(middleware.RateLimitingEnabledEnv, "") - t.Setenv(middleware.RateLimitingBurstEnv, "1") + t.Setenv(ratelimit.RateLimitingEnabledEnv, "") + t.Setenv(ratelimit.RateLimitingBurstEnv, "1") mux, _ := newLocalMux(t) require.Equal(t, http.StatusOK, getUploadURL(t, mux)) @@ -51,8 +51,8 @@ func Test_RateLimitingIsOnByDefault(t *testing.T) { } func Test_RateLimitingCanBeDisabled(t *testing.T) { - t.Setenv(middleware.RateLimitingEnabledEnv, "false") - t.Setenv(middleware.RateLimitingBurstEnv, "1") + t.Setenv(ratelimit.RateLimitingEnabledEnv, "false") + t.Setenv(ratelimit.RateLimitingBurstEnv, "1") mux, _ := newLocalMux(t) require.Equal(t, http.StatusOK, getUploadURL(t, mux)) diff --git a/upload-server/server/s3.go b/upload-server/server/s3.go index ffc5df01f..55046fb9b 100644 --- a/upload-server/server/s3.go +++ b/upload-server/server/s3.go @@ -12,7 +12,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/s3" log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/management/server/http/middleware" + "github.com/netbirdio/netbird/shared/ratelimit" "github.com/netbirdio/netbird/upload-server/types" ) @@ -23,7 +23,7 @@ type sThree struct { presignClient *s3.PresignClient } -func configureS3Handlers(mux *http.ServeMux, limiter *middleware.APIRateLimiter) error { +func configureS3Handlers(mux *http.ServeMux, limiter *ratelimit.APIRateLimiter) error { bucket := os.Getenv(bucketVar) region, ok := os.LookupEnv("AWS_REGION") if !ok { diff --git a/upload-server/server/server.go b/upload-server/server/server.go index 607c5bdad..cd9b25c2e 100644 --- a/upload-server/server/server.go +++ b/upload-server/server/server.go @@ -10,7 +10,7 @@ import ( "github.com/google/uuid" log "github.com/sirupsen/logrus" - "github.com/netbirdio/netbird/management/server/http/middleware" + "github.com/netbirdio/netbird/shared/ratelimit" "github.com/netbirdio/netbird/upload-server/types" ) @@ -21,7 +21,7 @@ const ( type Server struct { srv *http.Server - limiter *middleware.APIRateLimiter + limiter *ratelimit.APIRateLimiter } func NewServer() *Server { @@ -63,7 +63,7 @@ func (s *Server) Stop() error { return nil } -func configureMux(mux *http.ServeMux) (*middleware.APIRateLimiter, error) { +func configureMux(mux *http.ServeMux) (*ratelimit.APIRateLimiter, error) { limiter := newRateLimiter() _, ok := os.LookupEnv(bucketVar) From a8162aaac568da98887970587c8a854a8599b262 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 28 Sep 2026 15:46:54 +0200 Subject: [PATCH 23/33] [client] Bump wails to v3.0.0-beta.25 (#7640) * [client] Bump wails to v3.0.0-beta.25 * [client] Point wails to the merged integration commit --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 543dcc713..2d0653ec0 100644 --- a/go.mod +++ b/go.mod @@ -368,7 +368,7 @@ replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-2 replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0 -replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260902163841-4a71f7b1d9e1 +replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260928122122-d1db5fa35318 tool ( github.com/goreleaser/chglog/cmd/chglog diff --git a/go.sum b/go.sum index 6e5fd0693..e65c32f26 100644 --- a/go.sum +++ b/go.sum @@ -527,8 +527,8 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ= github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ= -github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260902163841-4a71f7b1d9e1 h1:n5aXV/U6I9bLc+yWN088TyVR4OfF64Gy+L6Hrffc+n4= -github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260902163841-4a71f7b1d9e1/go.mod h1:/6QR46/nhGCSADHbS++XtDb9dkTnenTHlGskTPRo9S0= +github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260928122122-d1db5fa35318 h1:Qv2jYeucRkuqkkM9aAVFcO9avmSfPEoB+gxKKKuI4vA= +github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260928122122-d1db5fa35318/go.mod h1:62UsqQRqanuCbFi6xElIw3/2X+yGF+TKlyiWImA5taU= github.com/netbirdio/wireguard-go v0.0.0-20260914123147-8bf8fa968f1a h1:Nt8BgkTkI56LGBPPEBywM406MVKJDqeDIVdgsZyYs80= github.com/netbirdio/wireguard-go v0.0.0-20260914123147-8bf8fa968f1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= From 164d92e78da0ce53592d7d8ba4abe6863e74ac0a Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:03:42 +0200 Subject: [PATCH 24/33] [client] Stop dumping the whole device to clear one peer endpoint (#7632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [client] Stop dumping the whole device to clear one peer endpoint Clearing a peer's endpoint has to remove and re-add the peer, because neither the netlink API nor the wireguard-go UAPI can clear an endpoint in place. To keep the peer's allowed IPs across that dance, RemoveEndpointAddress read them back from the device: a full wgctrl.Device() dump on the kernel path, a full IpcGet plus text parse on the userspace one. Both cost a round trip proportional to the entire network map, both run under the interface lock, and both run on every relay and ICE transition. On a routing peer with ~15700 peers that is megabytes of netlink traffic per transition, at a measured 713 transitions per minute, with every other configuration operation queued behind it. RemoveAllowedIP paid the same price for the same reason. The allowed IPs cannot come from the caller: peer.Conn knows the peer's own overlay addresses, while the routed prefixes are attached separately by the route manager's refcounter, so a caller-supplied set would silently drop every route behind the peer. The configurer is the only writer of its device's peer set, so it can keep an authoritative mirror of what it configured and answer from memory instead. The mirror is fed by every operation that changes a peer's allowed IPs and reset by a device reconfiguration that replaces the peer set. A peer the mirror has not seen, which is what an out-of-band reconfiguration leaves behind, still falls back to reading the device and seeds the mirror from it. Prefixes are unmapped on the way in, so a v4-mapped address compares equal to the plain v4 prefix for the same network rather than registering as a second entry. Measured on a userspace device, allocations to clear one endpoint: peers 64 256 1024 4096 before 1452 - 21617 - after 91 91 91 91 * [client] Keep update-only allowed IP adds out of the peer mirror AddAllowedIP configures the device with update_only, which is a silent no-op when the peer does not exist, so its success says nothing about whether the device took the prefix. Recording it unconditionally let the mirror hold a peer the device had dropped, and RemoveEndpointAddress re-adds a peer without update_only: clearing the endpoint of such a peer recreated it, carrying allowed IPs the device never held. Allowed IPs are unique per device, so the recreated peer takes those prefixes away from the peer that legitimately holds them. This is not a theoretical window. Under lazy connections a routing peer's device entry is torn down and re-created on the idle transition, and a routed prefix re-added during that window is lost exactly because of update_only (#6863). Allowed IP adds now merge only onto a peer the store already knows, which mirrors the device: the operations that can create a peer record it, the update-only ones do not. A peer missing from the store still falls back to reading the device. * [client] Hand a prefix over to its new owner in the peer mirror An allowed IP belongs to exactly one peer: configuring a prefix on a peer takes it away from whichever peer held it before, and the configurer leaves that handover to the device rather than removing the prefix from the previous holder itself, which is what UpdatePeer's "wg will handle duplicated peer IP" refers to. The mirror recorded the prefix on the new peer while leaving it listed under the old one, so clearing the old peer's endpoint rewrote its allowed IPs from that stale list and took the prefix back from the peer that now owns it. Traffic for the routed prefix then went to the wrong peer. Reading the device before each write used to rule this out. The store now tracks the owner of each prefix and performs the same handover, so rewriting one peer's list cannot reclaim a prefix another peer holds. Prefixes are also masked on the way in. A device stores them masked, so a caller passing host bits would otherwise fail to match what a device fallback seeded and could never remove that prefix by value. Conversion back from the device now keys the v4-mapped decision on the mask width as well, so a genuine v6 prefix inside the mapped range stays v6 instead of being dropped as an invalid v4 prefix. * [client] Keep a mapped v6 prefix below /96 out of the v4 form normalizePrefix unmapped any v4-mapped address before masking it, keeping the original prefix length. For a genuine v6 prefix inside the mapped range, such as ::ffff:0:0/64, that pairs a v4 address with a v6 sized mask: netip.PrefixFrom returns an invalid prefix and Masked turns it into the zero prefix. The store then held a prefix whose Bits is -1, which cannot reproduce the allowed IP the device was given, so re-adding the peer after an endpoint removal could fail once the peer had already been removed. Masking now comes first, and it also decides the address family: only a prefix at least 96 bits long keeps the mapped marker through the mask, so anything shorter inside that range is v6 and stays v6. * [client] Record a peer created by a preshared key write Setting a preshared key without updateOnly creates the peer when it is absent, and Rosenpass applies a peer's first key exactly that way, since applyKeyLocked passes the peer's initialized flag. The store ignored that operation, so the peer could exist on the device while the store treated it as unknown. An update-only allowed IP add on such a peer then succeeded on the device, which moved the prefix away from its previous holder, while the store skipped the peer and left the previous holder still claiming it. Clearing that holder's endpoint rewrote it from the stale claim and took the prefix back, leaving the peer that owns the route with nothing. Every device operation that can create a peer now records it, which is the same rule the update-only operations already follow from the other side. * [client] Match a peer on the parsed key instead of its base64 form getPeer scanned the device comparing Key.String to the caller's key. wgtypes.Key is a 32 byte array, so it compares directly, while String base64 encodes it into a fresh allocation on every iteration. The scan therefore allocated once per peer on the device to find a single peer, and on a large network that is tens of thousands of allocations per lookup. The key is parsed once up front and the arrays are compared. Behaviour is unchanged: the callers already parse the same key before reaching here, so the new parse error is unreachable in practice and only guards the helper on its own. * [client] Normalize prefixes on their way to the device Prefixes were normalized when recorded but not when written, so a caller's raw prefix reached the device while a different form was kept for it. The conversion is also where a mapped prefix goes wrong: net.IPNet prints a v4-mapped address as v4 but takes the length from its 16 byte mask, so ::ffff:10.1.2.3/64 is handed to a userspace device as 10.1.2.3/0 — an allowed IP matching every v4 address, on a peer that was meant to carry one /64. prefixesToIPNets now normalizes, and the two hand-built conversions in AddAllowedIP go through it, so there is a single place where a prefix is turned into something a device is given and it cannot disagree with what is recorded for it. * [client] Parse the endpoint before configuring the peer The userspace UpdatePeer parsed the endpoint address after the device had already been configured, and returned on a parse failure. The device was then left holding a peer that neither the activity recorder nor the allowed IP store had been told about, so the peer was invisible to the wake path and the prefix handover for its allowed IPs never happened, leaving the previous holder still claiming them. The parse now happens before anything is written, so the only failure left after the device is touched is one the caller cannot cause. * [client] Keep the record when a peer removal fails The two configurers disagreed: the kernel one dropped its record only once the device had accepted the removal, the userspace one dropped it either way. Removing a peer is a single device write, so a failure leaves the peer exactly as it was, with the allowed IPs the record still describes. Dropping it there asserts nothing useful and only sends the next caller to read the whole device back for an answer it already had. The userspace one now follows the kernel and returns early on failure. * [client] Write down what the allowed IP store does not guarantee Two properties were relied on without being stated. The store's lock covers its map and not the device write beside it, so consistency between the two rests on callers being serialized, which WGIface does with its mutex; anyone removing that would have no way to learn it mattered. And the fallback to the device only covers a peer the store has never seen, so a peer first recorded from empty while the device already held prefixes keeps only what was recorded, and the next endpoint removal drops the rest. * [client] Key the allowed IP store on the parsed peer key The store keyed on the textual key, so a lookup compared 44 byte strings while the callers all held the parsed key already and the configurer had to carry both forms. wgtypes.Key is a 32 byte array and compares directly, which is what getPeer was changed to do for the same reason. The store and its helpers now take wgtypes.Key, the callers pass the key they parsed on entry, and the textual form survives only where something outside speaks it: parseStatus reports peers that way, so the userspace fallback converts once for its scan. * [client] Document the configurer methods the store changed The exported configurer methods now carry what the allowed IP store made true of them: when the mirror is reset, that a peer update merges its prefixes and takes them from their previous owner, that an update-only add on an absent peer does nothing, and what each side does with its record when a device write fails — where the two configurers differ, since the userspace one reports a prefix it does not have and the kernel one treats it as a no-op. mergeLocked states the lock its callers must already hold. Docstrings that only restated the name of a test are left out; the tests explain the scenario they set up in the body, where the explanation belongs. --- client/iface/configurer/allowedips.go | 226 +++++++++++++ client/iface/configurer/allowedips_test.go | 263 +++++++++++++++ client/iface/configurer/common.go | 10 +- client/iface/configurer/kernel_unix.go | 108 ++++-- client/iface/configurer/usp.go | 212 +++++++----- .../iface/configurer/usp_allowedips_test.go | 318 ++++++++++++++++++ 6 files changed, 1009 insertions(+), 128 deletions(-) create mode 100644 client/iface/configurer/allowedips.go create mode 100644 client/iface/configurer/allowedips_test.go create mode 100644 client/iface/configurer/usp_allowedips_test.go diff --git a/client/iface/configurer/allowedips.go b/client/iface/configurer/allowedips.go new file mode 100644 index 000000000..193197d4a --- /dev/null +++ b/client/iface/configurer/allowedips.go @@ -0,0 +1,226 @@ +package configurer + +import ( + "net" + "net/netip" + "slices" + "sync" + + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +// allowedIPStore mirrors the allowed IPs configured on each peer of a device. +// +// A configurer is the only writer of its device's peer set, so the mirror is authoritative +// by construction. It spares the paths that have to rewrite one peer's allowed IPs a full +// device dump just to recover prefixes the process already configured itself. +// +// An allowed IP belongs to exactly one peer: configuring a prefix on a peer takes it away +// from whichever peer held it before, and the configurer leaves that handover to the device +// rather than removing the prefix from the previous holder itself. The store tracks the +// owner of each prefix and performs the same handover, so rewriting one peer's list never +// takes a prefix back from the peer that owns it now. +// +// Its own lock guards the map alone, not the device write it accompanies. Consistency +// between the two rests on the caller serializing every configurer call, which WGIface +// does with its mutex; two unserialized writers would interleave a device write with the +// record of a different one. +// +// An operator reconfiguring the device out of band, through `wg set` or the UAPI socket, +// is the one way the mirror can still go stale. A peer missing from it falls back to the +// device, which reseats that peer's prefixes and their ownership; a peer that is present +// does not, so one recorded from empty while the device already held prefixes keeps only +// what was recorded, and the next endpoint removal drops the rest. +type allowedIPStore struct { + mu sync.RWMutex + peers map[wgtypes.Key][]netip.Prefix + owners map[netip.Prefix]wgtypes.Key +} + +func newAllowedIPStore() *allowedIPStore { + return &allowedIPStore{ + peers: make(map[wgtypes.Key][]netip.Prefix), + owners: make(map[netip.Prefix]wgtypes.Key), + } +} + +// get returns the prefixes recorded for a peer, and whether the peer is known at all. +// The caller receives a copy and may retain or modify it freely. +func (s *allowedIPStore) get(key wgtypes.Key) ([]netip.Prefix, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + prefixes, ok := s.peers[key] + if !ok { + return nil, false + } + return slices.Clone(prefixes), true +} + +// set replaces the prefixes recorded for a peer. +func (s *allowedIPStore) set(key wgtypes.Key, prefixes []netip.Prefix) { + s.mu.Lock() + defer s.mu.Unlock() + + k := key + s.releaseLocked(k) + + normalized := normalizePrefixes(prefixes) + for _, prefix := range normalized { + s.claimLocked(k, prefix) + } + s.peers[k] = normalized +} + +// add records prefixes on a peer without dropping the ones already there, matching the +// union semantics of a peer update that does not replace its allowed IPs. It records the +// peer if it is not known yet, so it belongs to the operations that create a peer on the +// device rather than to the update-only ones. +func (s *allowedIPStore) add(key wgtypes.Key, prefixes []netip.Prefix) { + s.mu.Lock() + defer s.mu.Unlock() + + s.mergeLocked(key, prefixes) +} + +// addExisting is add for an update-only device operation. Such an operation is a silent +// no-op when the peer is absent, so recording a peer here would leave the store claiming +// prefixes the device never took, and the peer would then be recreated by the next endpoint +// removal, stealing those allowed IPs from the peer that legitimately holds them. +func (s *allowedIPStore) addExisting(key wgtypes.Key, prefixes []netip.Prefix) { + s.mu.Lock() + defer s.mu.Unlock() + + k := key + if _, ok := s.peers[k]; !ok { + return + } + s.mergeLocked(k, prefixes) +} + +// ensure records a peer with no prefixes unless it is already known. A device operation +// that is not update-only creates the peer when it is absent, so it has to be recorded even +// when it configures nothing else; otherwise the peer exists on the device while the store +// treats it as unknown, and a prefix later handed over to it is not accounted for. +func (s *allowedIPStore) ensure(key wgtypes.Key) { + s.mu.Lock() + defer s.mu.Unlock() + + k := key + if _, ok := s.peers[k]; !ok { + s.peers[k] = nil + } +} + +// forget drops every prefix recorded for a peer. +func (s *allowedIPStore) forget(key wgtypes.Key) { + s.mu.Lock() + defer s.mu.Unlock() + + k := key + s.releaseLocked(k) + delete(s.peers, k) +} + +// reset drops every peer, mirroring a device reconfiguration that replaces the peer set. +func (s *allowedIPStore) reset() { + s.mu.Lock() + defer s.mu.Unlock() + + s.peers = make(map[wgtypes.Key][]netip.Prefix) + s.owners = make(map[netip.Prefix]wgtypes.Key) +} + +// mergeLocked unions normalized prefixes into a peer and transfers their ownership. +// The caller must hold s.mu for writing. +func (s *allowedIPStore) mergeLocked(k wgtypes.Key, prefixes []netip.Prefix) { + merged := s.peers[k] + for _, prefix := range prefixes { + prefix = normalizePrefix(prefix) + s.claimLocked(k, prefix) + if !slices.Contains(merged, prefix) { + merged = append(merged, prefix) + } + } + s.peers[k] = merged +} + +// claimLocked hands a prefix over to a peer, taking it from its previous owner the way the +// device does when the same prefix is configured on a second peer. +func (s *allowedIPStore) claimLocked(k wgtypes.Key, prefix netip.Prefix) { + if owner, ok := s.owners[prefix]; ok && owner != k { + s.peers[owner] = slices.DeleteFunc(s.peers[owner], func(p netip.Prefix) bool { + return p == prefix + }) + } + s.owners[prefix] = k +} + +// releaseLocked drops a peer's claim on every prefix it currently holds. +func (s *allowedIPStore) releaseLocked(k wgtypes.Key) { + for _, prefix := range s.peers[k] { + if s.owners[prefix] == k { + delete(s.owners, prefix) + } + } +} + +// normalizePrefix puts a prefix into the form the store recognises it by. It clears the +// host bits, which a device does on its own, so a caller passing 10.20.0.1/16 still matches +// the 10.20.0.0/16 read back from the device; and it unmaps a v4-mapped prefix so that it +// compares equal to, and marshals like, the plain v4 prefix for the same network. +// +// Masking comes first because it also decides the address family: only a prefix at least 96 +// bits long keeps the mapped marker through the mask, so a shorter prefix inside the mapped +// range is a genuine v6 prefix and unmapping it would yield an invalid v4 prefix. +func normalizePrefix(prefix netip.Prefix) netip.Prefix { + masked := prefix.Masked() + + addr := masked.Addr() + if !addr.Is4In6() { + return masked + } + return netip.PrefixFrom(addr.Unmap(), masked.Bits()-96) +} + +// normalizePrefixes returns a normalized copy without changing the caller's slice. +func normalizePrefixes(prefixes []netip.Prefix) []netip.Prefix { + normalized := make([]netip.Prefix, len(prefixes)) + for i, prefix := range prefixes { + normalized[i] = normalizePrefix(prefix) + } + return normalized +} + +// ipNetsToPrefixes converts addresses read back from a device. Unmap keeps a v4-mapped v6 +// address comparable to the plain v4 prefix the configurer was given. +func ipNetsToPrefixes(ipNets []net.IPNet) []netip.Prefix { + prefixes := make([]netip.Prefix, 0, len(ipNets)) + for _, ipNet := range ipNets { + addr, ok := netip.AddrFromSlice(ipNet.IP) + if !ok { + continue + } + + ones, maskBits := ipNet.Mask.Size() + // A device may report a v4 prefix as a v4-mapped address. Align the address form with + // the mask rather than unmapping on sight: a 32 bit mask always describes v4, while a + // 128 bit mask describes v4 only when it covers the mapped prefix, so a genuine v6 + // prefix inside the mapped range stays v6 instead of being dropped as invalid. + if addr.Is4In6() { + switch { + case maskBits == 32: + addr = addr.Unmap() + case maskBits == 128 && ones >= 96: + addr, ones = addr.Unmap(), ones-96 + } + } + + prefix := netip.PrefixFrom(addr, ones) + if !prefix.IsValid() { + continue + } + prefixes = append(prefixes, prefix.Masked()) + } + return prefixes +} diff --git a/client/iface/configurer/allowedips_test.go b/client/iface/configurer/allowedips_test.go new file mode 100644 index 000000000..1d272d8c4 --- /dev/null +++ b/client/iface/configurer/allowedips_test.go @@ -0,0 +1,263 @@ +package configurer + +import ( + "net" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +// The store keys on the parsed key, so the tests use two distinct ones rather than names. +var ( + testPeer = wgtypes.Key{1} + otherPeer = wgtypes.Key{2} +) + +func TestAllowedIPStoreUnknownPeer(t *testing.T) { + s := newAllowedIPStore() + + prefixes, ok := s.get(testPeer) + assert.False(t, ok, "an unconfigured peer must be reported as unknown, not as one without prefixes") + assert.Nil(t, prefixes, "an unknown peer has no prefixes") +} + +func TestAllowedIPStoreAddUnions(t *testing.T) { + s := newAllowedIPStore() + overlay := netip.MustParsePrefix("100.64.0.1/32") + routed := netip.MustParsePrefix("10.20.0.0/16") + + s.set(testPeer, []netip.Prefix{overlay}) + // A peer update does not replace allowed IPs, and a repeated prefix must not be doubled. + s.add(testPeer, []netip.Prefix{overlay, routed}) + + prefixes, ok := s.get(testPeer) + require.True(t, ok, "peer must be known after set") + assert.Equal(t, []netip.Prefix{overlay, routed}, prefixes, "add must union rather than replace") +} + +func TestAllowedIPStoreGetReturnsCopy(t *testing.T) { + s := newAllowedIPStore() + overlay := netip.MustParsePrefix("100.64.0.1/32") + s.set(testPeer, []netip.Prefix{overlay}) + + prefixes, ok := s.get(testPeer) + require.True(t, ok, "peer must be known after set") + prefixes[0] = netip.MustParsePrefix("0.0.0.0/0") + + stored, _ := s.get(testPeer) + assert.Equal(t, []netip.Prefix{overlay}, stored, "a caller mutating the returned slice must not corrupt the store") +} + +func TestAllowedIPStoreForgetAndReset(t *testing.T) { + s := newAllowedIPStore() + s.set(testPeer, []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")}) + s.set(otherPeer, []netip.Prefix{netip.MustParsePrefix("100.64.0.2/32")}) + + s.forget(testPeer) + _, ok := s.get(testPeer) + assert.False(t, ok, "a forgotten peer must be unknown") + _, ok = s.get(otherPeer) + assert.True(t, ok, "forgetting one peer must not touch the others") + + s.reset() + _, ok = s.get(otherPeer) + assert.False(t, ok, "reset must drop every peer") +} + +func TestIPNetsToPrefixes(t *testing.T) { + tests := []struct { + name string + ipNet net.IPNet + want string + }{ + { + name: "v4", + ipNet: net.IPNet{IP: net.IP{10, 20, 0, 0}, Mask: net.CIDRMask(16, 32)}, + want: "10.20.0.0/16", + }, + { + name: "v4 mapped under a 128 bit mask", + ipNet: net.IPNet{IP: net.ParseIP("10.20.0.0"), Mask: net.CIDRMask(112, 128)}, + want: "10.20.0.0/16", + }, + { + name: "v6", + ipNet: net.IPNet{IP: net.ParseIP("fd00::"), Mask: net.CIDRMask(64, 128)}, + want: "fd00::/64", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := ipNetsToPrefixes([]net.IPNet{tc.ipNet}) + require.Len(t, got, 1, "the address must be converted, not dropped") + assert.Equal(t, tc.want, got[0].String(), "converted prefix") + }) + } +} + +func TestIPNetsToPrefixesRoundTrip(t *testing.T) { + prefixes := []netip.Prefix{ + netip.MustParsePrefix("100.64.0.1/32"), + netip.MustParsePrefix("10.20.0.0/16"), + netip.MustParsePrefix("fd00::/64"), + } + + assert.Equal(t, prefixes, ipNetsToPrefixes(prefixesToIPNets(prefixes)), + "prefixes handed to a device must come back unchanged") +} + +func TestAllowedIPStoreNormalizesMappedPrefixes(t *testing.T) { + s := newAllowedIPStore() + v4 := netip.MustParsePrefix("10.20.0.0/16") + mapped := netip.PrefixFrom(netip.AddrFrom16(v4.Addr().As16()), 112) + + s.set(testPeer, []netip.Prefix{mapped}) + // A v4 rule only matches a v4-mapped address once it has been unmapped, so the store must + // hold the plain form and recognise the two spellings as the same prefix. + s.add(testPeer, []netip.Prefix{v4}) + + prefixes, ok := s.get(testPeer) + require.True(t, ok, "peer must be known after set") + assert.Equal(t, []netip.Prefix{v4}, prefixes, "a mapped prefix must be stored unmapped and not duplicated") +} + +func TestNormalizePrefix(t *testing.T) { + v4 := netip.MustParsePrefix("10.20.0.0/16") + v6 := netip.MustParsePrefix("fd00::/64") + + assert.Equal(t, v4, normalizePrefix(v4), "a plain v4 prefix is unchanged") + assert.Equal(t, v6, normalizePrefix(v6), "a real v6 prefix is unchanged") + assert.Equal(t, v4, normalizePrefix(netip.PrefixFrom(netip.AddrFrom16(v4.Addr().As16()), 112)), + "a mapped prefix under a 128 bit mask becomes plain v4") + // A prefix shorter than /96 inside the mapped range is a genuine v6 prefix. Unmapping it + // would pair a v4 address with a v6 sized mask, which is invalid, and the store would then + // record a zero prefix that can never recreate the allowed IP. + for _, tc := range []string{"::ffff:0:0/64", "::ffff:1.2.3.4/80", "::ffff:1.2.3.4/95"} { + got := normalizePrefix(netip.MustParsePrefix(tc)) + assert.True(t, got.IsValid(), "%s must normalize to a valid prefix", tc) + assert.False(t, got.Addr().Is4(), "%s must stay v6", tc) + } +} + +func TestAllowedIPStoreAddExistingDoesNotCreate(t *testing.T) { + s := newAllowedIPStore() + routed := netip.MustParsePrefix("10.20.0.0/16") + + // An update-only device operation on an absent peer is a silent no-op, so nothing may be + // recorded for a peer the store does not already know. + s.addExisting(testPeer, []netip.Prefix{routed}) + _, ok := s.get(testPeer) + assert.False(t, ok, "addExisting must not record an unknown peer") + + overlay := netip.MustParsePrefix("100.64.0.1/32") + s.set(testPeer, []netip.Prefix{overlay}) + s.addExisting(testPeer, []netip.Prefix{routed}) + + prefixes, _ := s.get(testPeer) + assert.Equal(t, []netip.Prefix{overlay, routed}, prefixes, "addExisting must union onto a known peer") +} + +func TestAllowedIPStoreHandsPrefixOverToTheNewOwner(t *testing.T) { + s := newAllowedIPStore() + routed := netip.MustParsePrefix("10.20.0.0/16") + other := otherPeer + + s.set(testPeer, []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32"), routed}) + s.set(other, []netip.Prefix{netip.MustParsePrefix("100.64.0.2/32")}) + + // The device takes an allowed IP away from its previous holder when it is configured on + // another peer, so the store must do the same rather than list it under both. + s.addExisting(other, []netip.Prefix{routed}) + + previous, _ := s.get(testPeer) + assert.NotContains(t, previous, routed, "the previous owner must lose the prefix") + current, _ := s.get(other) + assert.Contains(t, current, routed, "the new owner must hold the prefix") +} + +func TestAllowedIPStoreForgetReleasesOwnership(t *testing.T) { + s := newAllowedIPStore() + routed := netip.MustParsePrefix("10.20.0.0/16") + + s.set(testPeer, []netip.Prefix{routed}) + s.forget(testPeer) + s.set(otherPeer, []netip.Prefix{routed}) + + // A forgotten peer must not be resurrected as a key in the peer map by a later claim. + _, ok := s.get(testPeer) + assert.False(t, ok, "the forgotten peer must stay unknown") + current, _ := s.get(otherPeer) + assert.Equal(t, []netip.Prefix{routed}, current, "the new owner must hold the prefix") +} + +func TestNormalizePrefixClearsHostBits(t *testing.T) { + // A device stores a prefix masked, so a caller passing host bits must still match what a + // device fallback seeded, otherwise that prefix could never be removed by value. + assert.Equal(t, netip.MustParsePrefix("10.20.0.0/16"), + normalizePrefix(netip.MustParsePrefix("10.20.0.1/16")), "host bits must be cleared") + assert.Equal(t, netip.MustParsePrefix("fd00::/64"), + normalizePrefix(netip.MustParsePrefix("fd00::1/64")), "host bits must be cleared for v6") +} + +func TestIPNetsToPrefixesKeepsV6InTheMappedRange(t *testing.T) { + // ::ffff:0:0/64 reads as v4-mapped but is a genuine v6 prefix: unmapping it would leave a + // v4 address under a 64 bit mask, which is invalid, and the allowed IP would be dropped. + got := ipNetsToPrefixes([]net.IPNet{{ + IP: net.ParseIP("::ffff:0:0"), + Mask: net.CIDRMask(64, 128), + }}) + + require.Len(t, got, 1, "the prefix must be converted, not dropped") + assert.False(t, got[0].Addr().Is4(), "a v6 prefix in the mapped range must not become v4") + assert.Equal(t, 64, got[0].Bits(), "the prefix length must survive the conversion") +} + +func TestPrefixesToIPNetsNormalizes(t *testing.T) { + // net.IPNet prints a v4-mapped address as v4 but takes the length from its 16 byte + // mask, so an unnormalized ::ffff:10.1.2.3/64 reaches a userspace device as 10.1.2.3/0, + // an allowed IP that matches every v4 address. + tests := []struct { + name string + given string + want string + }{ + {name: "mapped below /96", given: "::ffff:10.1.2.3/64", want: "::/64"}, + {name: "mapped at /112", given: "::ffff:10.1.2.3/112", want: "10.1.0.0/16"}, + {name: "host bits are cleared", given: "10.20.0.1/16", want: "10.20.0.0/16"}, + {name: "v6 is untouched", given: "fd00::1/64", want: "fd00::/64"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := prefixesToIPNets([]netip.Prefix{netip.MustParsePrefix(tc.given)}) + require.Len(t, got, 1, "the prefix must be converted, not dropped") + assert.Equal(t, tc.want, got[0].String(), "what the device is given") + assert.NotEqual(t, 0, mustOnes(t, got[0]), "a device must never be given a zero length allowed IP") + }) + } +} + +func mustOnes(t *testing.T, ipNet net.IPNet) int { + t.Helper() + + ones, _ := ipNet.Mask.Size() + return ones +} + +// TestPrefixesToIPNetsAgreesWithTheStore pins the property the store depends on: what a +// device is given and what is recorded for it are the same prefix. +func TestPrefixesToIPNetsAgreesWithTheStore(t *testing.T) { + for _, given := range []string{"::ffff:10.1.2.3/64", "::ffff:10.1.2.3/112", "10.20.0.1/16", "fd00::1/64"} { + prefix := netip.MustParsePrefix(given) + + toDevice := prefixesToIPNets([]netip.Prefix{prefix}) + recorded := normalizePrefix(prefix) + + assert.Equal(t, recorded.String(), toDevice[0].String(), + "%s must reach the device in the form the store records", given) + } +} diff --git a/client/iface/configurer/common.go b/client/iface/configurer/common.go index 10162d703..40f8209e9 100644 --- a/client/iface/configurer/common.go +++ b/client/iface/configurer/common.go @@ -19,12 +19,18 @@ func buildPresharedKeyConfig(peerKey wgtypes.Key, psk wgtypes.Key, updateOnly bo } } +// prefixesToIPNets converts prefixes on their way to a device. It is the only place that +// conversion happens, so it also normalizes: the device is then given the same form the +// store records, and a v4-mapped prefix cannot reach net.IPNet, which prints such an +// address as v4 while taking the length from its 16 byte mask and so turns +// ::ffff:10.1.2.3/64 into 10.1.2.3/0 — an allowed IP matching every v4 address. func prefixesToIPNets(prefixes []netip.Prefix) []net.IPNet { ipNets := make([]net.IPNet, len(prefixes)) for i, prefix := range prefixes { + normalized := normalizePrefix(prefix) ipNets[i] = net.IPNet{ - IP: prefix.Addr().AsSlice(), // Convert netip.Addr to net.IP - Mask: net.CIDRMask(prefix.Bits(), prefix.Addr().BitLen()), // Create subnet mask + IP: normalized.Addr().AsSlice(), + Mask: net.CIDRMask(normalized.Bits(), normalized.Addr().BitLen()), } } return ipNets diff --git a/client/iface/configurer/kernel_unix.go b/client/iface/configurer/kernel_unix.go index da69c2a35..3a95249c1 100644 --- a/client/iface/configurer/kernel_unix.go +++ b/client/iface/configurer/kernel_unix.go @@ -6,6 +6,7 @@ import ( "fmt" "net" "net/netip" + "slices" "time" log "github.com/sirupsen/logrus" @@ -18,16 +19,22 @@ import ( type KernelConfigurer struct { deviceName string statsCache *statsCache + allowedIPs *allowedIPStore } +// NewKernelConfigurer creates a configurer with an empty allowed IP mirror +// and a statistics cache for the named kernel device. func NewKernelConfigurer(deviceName string) *KernelConfigurer { c := &KernelConfigurer{ deviceName: deviceName, + allowedIPs: newAllowedIPStore(), } c.statsCache = newStatsCache(statsCacheTTL, c.fetchStats) return c } +// ConfigureInterface sets the device key, port and firewall mark, replacing all peers. +// The allowed IP mirror is reset only after the device accepts the configuration. func (c *KernelConfigurer) ConfigureInterface(privateKey string, port int) error { log.Debugf("adding Wireguard private key") key, err := wgtypes.ParseKey(privateKey) @@ -46,6 +53,8 @@ func (c *KernelConfigurer) ConfigureInterface(privateKey string, port int) error if err != nil { return fmt.Errorf(`received error "%w" while configuring interface %s with port %d`, err, c.deviceName, port) } + + c.allowedIPs.reset() return nil } @@ -58,9 +67,20 @@ func (c *KernelConfigurer) SetPresharedKey(peerKey string, psk wgtypes.Key, upda } cfg := buildPresharedKeyConfig(parsedPeerKey, psk, updateOnly) - return c.configure(cfg) + if err := c.configure(cfg); err != nil { + return err + } + + // Without updateOnly this creates the peer when it is absent, so the store has to + // know about it even though no allowed IP was configured. + if !updateOnly { + c.allowedIPs.ensure(parsedPeerKey) + } + return nil } +// UpdatePeer creates or updates a peer, merging allowed IPs with its existing set. +// Prefixes assigned to this peer are transferred from their previous owners. func (c *KernelConfigurer) UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error { peerKeyParsed, err := wgtypes.ParseKey(peerKey) if err != nil { @@ -83,19 +103,23 @@ func (c *KernelConfigurer) UpdatePeer(peerKey string, allowedIps []netip.Prefix, if err != nil { return fmt.Errorf(`received error "%w" while updating peer on interface %s with settings: allowed ips %s, endpoint %s`, err, c.deviceName, allowedIps, endpoint.String()) } + + c.allowedIPs.add(peerKeyParsed, allowedIps) return nil } +// RemoveEndpointAddress clears the endpoint of a peer while keeping it configured. +// Neither the netlink API nor the userspace one can clear an endpoint in place, so the peer +// is removed and re-added with the allowed IPs it already had. func (c *KernelConfigurer) RemoveEndpointAddress(peerKey string) error { peerKeyParsed, err := wgtypes.ParseKey(peerKey) if err != nil { return err } - // Get the existing peer to preserve its allowed IPs - existingPeer, err := c.getPeer(c.deviceName, peerKey) + allowedIPs, err := c.peerAllowedIPs(peerKeyParsed) if err != nil { - return fmt.Errorf("get peer: %w", err) + return err } removePeerCfg := wgtypes.PeerConfig{ @@ -104,26 +128,27 @@ func (c *KernelConfigurer) RemoveEndpointAddress(peerKey string) error { } if err := c.configure(wgtypes.Config{Peers: []wgtypes.PeerConfig{removePeerCfg}}); err != nil { - return fmt.Errorf(`error removing peer %s from interface %s: %w`, peerKey, c.deviceName, err) + return fmt.Errorf("remove peer %s from interface %s: %w", peerKey, c.deviceName, err) } - //Re-add the peer without the endpoint but same AllowedIPs reAddPeerCfg := wgtypes.PeerConfig{ PublicKey: peerKeyParsed, - AllowedIPs: existingPeer.AllowedIPs, + AllowedIPs: prefixesToIPNets(allowedIPs), ReplaceAllowedIPs: true, } if err := c.configure(wgtypes.Config{Peers: []wgtypes.PeerConfig{reAddPeerCfg}}); err != nil { + c.allowedIPs.forget(peerKeyParsed) return fmt.Errorf( - `error re-adding peer %s to interface %s with allowed IPs %v: %w`, - peerKey, c.deviceName, existingPeer.AllowedIPs, err, + "re-add peer %s to interface %s with allowed IPs %v: %w", + peerKey, c.deviceName, allowedIPs, err, ) } return nil } +// RemovePeer removes a peer and forgets its allowed IPs after a successful device write. func (c *KernelConfigurer) RemovePeer(peerKey string) error { peerKeyParsed, err := wgtypes.ParseKey(peerKey) if err != nil { @@ -142,15 +167,13 @@ func (c *KernelConfigurer) RemovePeer(peerKey string) error { if err != nil { return fmt.Errorf(`received error "%w" while removing peer %s from interface %s`, err, peerKey, c.deviceName) } + + c.allowedIPs.forget(peerKeyParsed) return nil } +// AddAllowedIP adds a prefix to an existing peer; an absent peer is a silent no-op. func (c *KernelConfigurer) AddAllowedIP(peerKey string, allowedIP netip.Prefix) error { - ipNet := net.IPNet{ - IP: allowedIP.Addr().AsSlice(), - Mask: net.CIDRMask(allowedIP.Bits(), allowedIP.Addr().BitLen()), - } - peerKeyParsed, err := wgtypes.ParseKey(peerKey) if err != nil { return err @@ -159,7 +182,7 @@ func (c *KernelConfigurer) AddAllowedIP(peerKey string, allowedIP netip.Prefix) PublicKey: peerKeyParsed, UpdateOnly: true, ReplaceAllowedIPs: false, - AllowedIPs: []net.IPNet{ipNet}, + AllowedIPs: prefixesToIPNets([]netip.Prefix{allowedIP}), } config := wgtypes.Config{ @@ -169,52 +192,69 @@ func (c *KernelConfigurer) AddAllowedIP(peerKey string, allowedIP netip.Prefix) if err != nil { return fmt.Errorf(`received error "%w" while adding allowed Ip to peer on interface %s with settings: allowed ips %s`, err, c.deviceName, allowedIP) } + + c.allowedIPs.addExisting(peerKeyParsed, []netip.Prefix{allowedIP}) return nil } +// RemoveAllowedIP removes a prefix while preserving the peer's other allowed IPs. +// A prefix not assigned to the peer is a no-op. func (c *KernelConfigurer) RemoveAllowedIP(peerKey string, allowedIP netip.Prefix) error { - ipNet := net.IPNet{ - IP: allowedIP.Addr().AsSlice(), - Mask: net.CIDRMask(allowedIP.Bits(), allowedIP.Addr().BitLen()), - } - peerKeyParsed, err := wgtypes.ParseKey(peerKey) if err != nil { return fmt.Errorf("parse peer key: %w", err) } - existingPeer, err := c.getPeer(c.deviceName, peerKey) + currentAllowedIPs, err := c.peerAllowedIPs(peerKeyParsed) if err != nil { - return fmt.Errorf("get peer: %w", err) + return err } - newAllowedIPs := existingPeer.AllowedIPs - - for i, existingAllowedIP := range existingPeer.AllowedIPs { - if existingAllowedIP.String() == ipNet.String() { - newAllowedIPs = append(existingPeer.AllowedIPs[:i], existingPeer.AllowedIPs[i+1:]...) //nolint:gocritic - break - } + idx := slices.Index(currentAllowedIPs, normalizePrefix(allowedIP)) + if idx < 0 { + return nil } + newAllowedIPs := slices.Delete(currentAllowedIPs, idx, idx+1) peer := wgtypes.PeerConfig{ PublicKey: peerKeyParsed, UpdateOnly: true, ReplaceAllowedIPs: true, - AllowedIPs: newAllowedIPs, + AllowedIPs: prefixesToIPNets(newAllowedIPs), } config := wgtypes.Config{ Peers: []wgtypes.PeerConfig{peer}, } - err = c.configure(config) - if err != nil { + if err := c.configure(config); err != nil { return fmt.Errorf("remove allowed IP %s on interface %s: %w", allowedIP, c.deviceName, err) } + + c.allowedIPs.set(peerKeyParsed, newAllowedIPs) return nil } -func (c *KernelConfigurer) getPeer(ifaceName, peerPubKey string) (wgtypes.Peer, error) { +// peerAllowedIPs returns the allowed IPs configured for a peer, reading them from the device +// only for a peer the store has not seen. Dumping the device costs a netlink round trip +// proportional to the whole network map, and this runs on every relay and ICE transition. +func (c *KernelConfigurer) peerAllowedIPs(peerKey wgtypes.Key) ([]netip.Prefix, error) { + if prefixes, ok := c.allowedIPs.get(peerKey); ok { + return prefixes, nil + } + + existingPeer, err := c.getPeer(c.deviceName, peerKey) + if err != nil { + return nil, fmt.Errorf("get peer: %w", err) + } + + prefixes := ipNetsToPrefixes(existingPeer.AllowedIPs) + c.allowedIPs.set(peerKey, prefixes) + return prefixes, nil +} + +// getPeer scans the device for one peer. wgtypes.Key is an array, so the comparison is a +// plain equality: Key.String would base64 encode into a fresh allocation for every peer. +func (c *KernelConfigurer) getPeer(ifaceName string, peerPubKey wgtypes.Key) (wgtypes.Peer, error) { wg, err := wgctrl.New() if err != nil { return wgtypes.Peer{}, fmt.Errorf("wgctl: %w", err) @@ -231,7 +271,7 @@ func (c *KernelConfigurer) getPeer(ifaceName, peerPubKey string) (wgtypes.Peer, return wgtypes.Peer{}, fmt.Errorf("get device %s: %w", ifaceName, err) } for _, peer := range wgDevice.Peers { - if peer.PublicKey.String() == peerPubKey { + if peer.PublicKey == peerPubKey { return peer, nil } } diff --git a/client/iface/configurer/usp.go b/client/iface/configurer/usp.go index 2be1b861e..334d99369 100644 --- a/client/iface/configurer/usp.go +++ b/client/iface/configurer/usp.go @@ -8,6 +8,7 @@ import ( "net/netip" "os" "runtime" + "slices" "strconv" "strings" "time" @@ -41,31 +42,38 @@ type WGUSPConfigurer struct { deviceName string activityRecorder *bind.ActivityRecorder statsCache *statsCache + allowedIPs *allowedIPStore uapiListener net.Listener } +// NewUSPConfigurer creates a userspace configurer and starts its UAPI listener. func NewUSPConfigurer(device *device.Device, deviceName string, activityRecorder *bind.ActivityRecorder) *WGUSPConfigurer { wgCfg := &WGUSPConfigurer{ device: device, deviceName: deviceName, activityRecorder: activityRecorder, + allowedIPs: newAllowedIPStore(), } wgCfg.statsCache = newStatsCache(statsCacheTTL, wgCfg.fetchStats) wgCfg.startUAPI() return wgCfg } +// NewUSPConfigurerNoUAPI creates a userspace configurer without a UAPI listener. func NewUSPConfigurerNoUAPI(device *device.Device, deviceName string, activityRecorder *bind.ActivityRecorder) *WGUSPConfigurer { wgCfg := &WGUSPConfigurer{ device: device, deviceName: deviceName, activityRecorder: activityRecorder, + allowedIPs: newAllowedIPStore(), } wgCfg.statsCache = newStatsCache(statsCacheTTL, wgCfg.fetchStats) return wgCfg } +// ConfigureInterface sets the device key, port and firewall mark, replacing all peers. +// The allowed IP mirror is reset only after the device accepts the configuration. func (c *WGUSPConfigurer) ConfigureInterface(privateKey string, port int) error { log.Debugf("adding Wireguard private key") key, err := wgtypes.ParseKey(privateKey) @@ -80,7 +88,12 @@ func (c *WGUSPConfigurer) ConfigureInterface(privateKey string, port int) error ListenPort: &port, } - return c.device.IpcSet(toWgUserspaceString(config)) + if err := c.device.IpcSet(toWgUserspaceString(config)); err != nil { + return err + } + + c.allowedIPs.reset() + return nil } // SetPresharedKey sets the preshared key for a peer. @@ -92,14 +105,38 @@ func (c *WGUSPConfigurer) SetPresharedKey(peerKey string, psk wgtypes.Key, updat } cfg := buildPresharedKeyConfig(parsedPeerKey, psk, updateOnly) - return c.device.IpcSet(toWgUserspaceString(cfg)) + if err := c.device.IpcSet(toWgUserspaceString(cfg)); err != nil { + return err + } + + // Without updateOnly this creates the peer when it is absent, so the store has to + // know about it even though no allowed IP was configured. + if !updateOnly { + c.allowedIPs.ensure(parsedPeerKey) + } + return nil } +// UpdatePeer creates or updates a peer, merging allowed IPs with its existing set. +// It validates the endpoint before writing and records changes after a successful write. func (c *WGUSPConfigurer) UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error { peerKeyParsed, err := wgtypes.ParseKey(peerKey) if err != nil { return err } + + // Everything that can fail is done before the device is touched, so a failure here + // cannot leave the device holding a peer that the activity recorder and the allowed + // IP store never learned about. + var addrPort netip.AddrPort + if endpoint != nil { + addr, err := netip.ParseAddr(endpoint.IP.String()) + if err != nil { + return fmt.Errorf("parse endpoint address: %w", err) + } + addrPort = netip.AddrPortFrom(addr.Unmap(), uint16(endpoint.Port)) + } + peer := wgtypes.PeerConfig{ PublicKey: peerKeyParsed, ReplaceAllowedIPs: false, @@ -119,47 +156,27 @@ func (c *WGUSPConfigurer) UpdatePeer(peerKey string, allowedIps []netip.Prefix, } if endpoint != nil { - addr, err := netip.ParseAddr(endpoint.IP.String()) - if err != nil { - return fmt.Errorf("failed to parse endpoint address: %w", err) - } - addrPort := netip.AddrPortFrom(addr.Unmap(), uint16(endpoint.Port)) c.activityRecorder.UpsertAddress(peerKey, addrPort) } + + c.allowedIPs.add(peerKeyParsed, allowedIps) return nil } +// RemoveEndpointAddress clears the endpoint of a peer while keeping it configured. +// The UAPI cannot clear an endpoint in place, so the peer is removed and re-added with the +// allowed IPs it already had. func (c *WGUSPConfigurer) RemoveEndpointAddress(peerKey string) error { peerKeyParsed, err := wgtypes.ParseKey(peerKey) if err != nil { return fmt.Errorf("parse peer key: %w", err) } - ipcStr, err := c.device.IpcGet() + allowedIPs, err := c.peerAllowedIPs(peerKeyParsed) if err != nil { - return fmt.Errorf("get IPC config: %w", err) + return err } - // Parse current status to get allowed IPs for the peer - stats, err := parseStatus(c.deviceName, ipcStr) - if err != nil { - return fmt.Errorf("parse IPC config: %w", err) - } - - var allowedIPs []net.IPNet - found := false - for _, peer := range stats.Peers { - if peer.PublicKey == peerKey { - allowedIPs = peer.AllowedIPs - found = true - break - } - } - if !found { - return fmt.Errorf("peer %s not found", peerKey) - } - - // remove the peer from the WireGuard configuration peer := wgtypes.PeerConfig{ PublicKey: peerKeyParsed, Remove: true, @@ -169,14 +186,13 @@ func (c *WGUSPConfigurer) RemoveEndpointAddress(peerKey string) error { Peers: []wgtypes.PeerConfig{peer}, } if ipcErr := c.device.IpcSet(toWgUserspaceString(config)); ipcErr != nil { - return fmt.Errorf("failed to remove peer: %s", ipcErr) + return fmt.Errorf("remove peer: %w", ipcErr) } - // Build the peer config peer = wgtypes.PeerConfig{ PublicKey: peerKeyParsed, ReplaceAllowedIPs: true, - AllowedIPs: allowedIPs, + AllowedIPs: prefixesToIPNets(allowedIPs), } config = wgtypes.Config{ @@ -184,12 +200,15 @@ func (c *WGUSPConfigurer) RemoveEndpointAddress(peerKey string) error { } if err := c.device.IpcSet(toWgUserspaceString(config)); err != nil { - return fmt.Errorf("remove endpoint address: %w", err) + c.allowedIPs.forget(peerKeyParsed) + return fmt.Errorf("re-add peer without endpoint: %w", err) } return nil } +// RemovePeer removes a peer, then clears its activity and allowed IP records. +// A failed device write leaves both records intact. func (c *WGUSPConfigurer) RemovePeer(peerKey string) error { peerKeyParsed, err := wgtypes.ParseKey(peerKey) if err != nil { @@ -204,18 +223,17 @@ func (c *WGUSPConfigurer) RemovePeer(peerKey string) error { config := wgtypes.Config{ Peers: []wgtypes.PeerConfig{peer}, } - ipcErr := c.device.IpcSet(toWgUserspaceString(config)) - - c.activityRecorder.Remove(peerKey) - return ipcErr -} - -func (c *WGUSPConfigurer) AddAllowedIP(peerKey string, allowedIP netip.Prefix) error { - ipNet := net.IPNet{ - IP: allowedIP.Addr().AsSlice(), - Mask: net.CIDRMask(allowedIP.Bits(), allowedIP.Addr().BitLen()), + if ipcErr := c.device.IpcSet(toWgUserspaceString(config)); ipcErr != nil { + return ipcErr } + c.activityRecorder.Remove(peerKey) + c.allowedIPs.forget(peerKeyParsed) + return nil +} + +// AddAllowedIP adds a prefix to an existing peer; an absent peer is a silent no-op. +func (c *WGUSPConfigurer) AddAllowedIP(peerKey string, allowedIP netip.Prefix) error { peerKeyParsed, err := wgtypes.ParseKey(peerKey) if err != nil { return err @@ -224,79 +242,89 @@ func (c *WGUSPConfigurer) AddAllowedIP(peerKey string, allowedIP netip.Prefix) e PublicKey: peerKeyParsed, UpdateOnly: true, ReplaceAllowedIPs: false, - AllowedIPs: []net.IPNet{ipNet}, + AllowedIPs: prefixesToIPNets([]netip.Prefix{allowedIP}), } config := wgtypes.Config{ Peers: []wgtypes.PeerConfig{peer}, } - return c.device.IpcSet(toWgUserspaceString(config)) + if err := c.device.IpcSet(toWgUserspaceString(config)); err != nil { + return err + } + + c.allowedIPs.addExisting(peerKeyParsed, []netip.Prefix{allowedIP}) + return nil } +// RemoveAllowedIP removes a prefix while preserving the peer's other allowed IPs. +// It returns ErrAllowedIPNotFound if the prefix is not assigned to the peer. func (c *WGUSPConfigurer) RemoveAllowedIP(peerKey string, allowedIP netip.Prefix) error { - ipc, err := c.device.IpcGet() - if err != nil { - return err - } - peerKeyParsed, err := wgtypes.ParseKey(peerKey) + if err != nil { + return fmt.Errorf("parse peer key: %w", err) + } + + currentAllowedIPs, err := c.peerAllowedIPs(peerKeyParsed) if err != nil { return err } - hexKey := hex.EncodeToString(peerKeyParsed[:]) - lines := strings.Split(ipc, "\n") + idx := slices.Index(currentAllowedIPs, normalizePrefix(allowedIP)) + if idx < 0 { + return ErrAllowedIPNotFound + } + newAllowedIPs := slices.Delete(currentAllowedIPs, idx, idx+1) peer := wgtypes.PeerConfig{ PublicKey: peerKeyParsed, UpdateOnly: true, ReplaceAllowedIPs: true, - AllowedIPs: []net.IPNet{}, + AllowedIPs: prefixesToIPNets(newAllowedIPs), } - foundPeer := false - removedAllowedIP := false - ip := allowedIP.String() - - for _, line := range lines { - line = strings.TrimSpace(line) - - // If we're within the details of the found peer and encounter another public key, - // this means we're starting another peer's details. So, reset the flag. - if strings.HasPrefix(line, "public_key=") && foundPeer { - foundPeer = false - } - - // Identify the peer with the specific public key - if line == fmt.Sprintf("public_key=%s", hexKey) { - foundPeer = true - } - - // If we're within the details of the found peer and find the specific allowed IP, skip this line - if foundPeer && line == "allowed_ip="+ip { - removedAllowedIP = true - continue - } - - // Append the line to the output string - if foundPeer && strings.HasPrefix(line, "allowed_ip=") { - allowedIPStr := strings.TrimPrefix(line, "allowed_ip=") - _, ipNet, err := net.ParseCIDR(allowedIPStr) - if err != nil { - return err - } - peer.AllowedIPs = append(peer.AllowedIPs, *ipNet) - } - } - - if !removedAllowedIP { - return ErrAllowedIPNotFound - } config := wgtypes.Config{ Peers: []wgtypes.PeerConfig{peer}, } - return c.device.IpcSet(toWgUserspaceString(config)) + if err := c.device.IpcSet(toWgUserspaceString(config)); err != nil { + return fmt.Errorf("remove allowed IP %s: %w", allowedIP, err) + } + + c.allowedIPs.set(peerKeyParsed, newAllowedIPs) + return nil +} + +// peerAllowedIPs returns the allowed IPs configured for a peer, reading them from the device +// only for a peer the store has not seen. Reading them back means dumping and parsing the +// whole device configuration, and this runs on every relay and ICE transition. +func (c *WGUSPConfigurer) peerAllowedIPs(peerKey wgtypes.Key) ([]netip.Prefix, error) { + if prefixes, ok := c.allowedIPs.get(peerKey); ok { + return prefixes, nil + } + + ipcStr, err := c.device.IpcGet() + if err != nil { + return nil, fmt.Errorf("get IPC config: %w", err) + } + + stats, err := parseStatus(c.deviceName, ipcStr) + if err != nil { + return nil, fmt.Errorf("parse IPC config: %w", err) + } + + // parseStatus reports keys in their textual form, so the comparison needs it once. + wanted := peerKey.String() + for _, peer := range stats.Peers { + if peer.PublicKey != wanted { + continue + } + + prefixes := ipNetsToPrefixes(peer.AllowedIPs) + c.allowedIPs.set(peerKey, prefixes) + return prefixes, nil + } + + return nil, ErrPeerNotFound } func (c *WGUSPConfigurer) FullStats() (*Stats, error) { diff --git a/client/iface/configurer/usp_allowedips_test.go b/client/iface/configurer/usp_allowedips_test.go new file mode 100644 index 000000000..fba0ca546 --- /dev/null +++ b/client/iface/configurer/usp_allowedips_test.go @@ -0,0 +1,318 @@ +package configurer + +import ( + "net" + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + wgconn "golang.zx2c4.com/wireguard/conn" + wgdevice "golang.zx2c4.com/wireguard/device" + "golang.zx2c4.com/wireguard/tun/tuntest" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + "github.com/netbirdio/netbird/client/iface/bind" +) + +// newTestUSPConfigurer builds a configurer over a real wireguard-go device backed by an +// in-memory TUN. The device stays down, so no socket is opened and no privileges are needed. +func newTestUSPConfigurer(t *testing.T) *WGUSPConfigurer { + t.Helper() + + tun := tuntest.NewChannelTUN() + dev := wgdevice.NewDevice(tun.TUN(), wgconn.NewDefaultBind(), wgdevice.NewLogger(wgdevice.LogLevelSilent, "")) + t.Cleanup(dev.Close) + + c := NewUSPConfigurerNoUAPI(dev, "wgtest0", bind.NewActivityRecorder()) + + key, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err, "generate device private key") + require.NoError(t, c.ConfigureInterface(key.String(), 0), "configure test device") + + return c +} + +// seedPeers adds count peers, each with a /32 overlay address, and returns their public keys. +func seedPeers(t *testing.T, c *WGUSPConfigurer, count int) []string { + t.Helper() + + keys := make([]string, 0, count) + for i := 0; i < count; i++ { + priv, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err, "generate peer private key") + pub := priv.PublicKey().String() + + addr := netip.PrefixFrom(netip.AddrFrom4([4]byte{100, 64, byte(i >> 8), byte(i)}), 32) + require.NoError(t, c.UpdatePeer(pub, []netip.Prefix{addr}, 25*time.Second, nil, nil), "add peer") + keys = append(keys, pub) + } + return keys +} + +func peerAllowedIPs(t *testing.T, c *WGUSPConfigurer, peerKey string) []string { + t.Helper() + + stats, err := c.FullStats() + require.NoError(t, err, "read device stats") + + for _, p := range stats.Peers { + if p.PublicKey != peerKey { + continue + } + got := make([]string, 0, len(p.AllowedIPs)) + for _, ipNet := range p.AllowedIPs { + got = append(got, ipNet.String()) + } + return got + } + t.Fatalf("peer %s not found on device", peerKey) + return nil +} + +// TestRemoveEndpointAddressPreservesRoutedAllowedIPs covers the prefixes the route manager +// attaches to a routing peer through AddAllowedIP. Those are not known to the peer.Conn that +// triggers the endpoint removal, so dropping them here would silently blackhole every route +// behind that peer on each relay or ICE disconnect. +func TestRemoveEndpointAddressPreservesRoutedAllowedIPs(t *testing.T) { + c := newTestUSPConfigurer(t) + peerKey := seedPeers(t, c, 3)[1] + + routed := []netip.Prefix{ + netip.MustParsePrefix("10.20.0.0/16"), + netip.MustParsePrefix("192.168.7.0/24"), + } + for _, prefix := range routed { + require.NoError(t, c.AddAllowedIP(peerKey, prefix), "add routed prefix") + } + + before := peerAllowedIPs(t, c, peerKey) + require.Len(t, before, 3, "peer should hold its overlay address plus both routed prefixes") + + require.NoError(t, c.RemoveEndpointAddress(peerKey), "remove endpoint address") + + assert.ElementsMatch(t, before, peerAllowedIPs(t, c, peerKey), + "allowed IPs must survive the endpoint removal unchanged") +} + +// TestRemoveEndpointAddressDoesNotScaleWithPeerCount is the regression guard for the actual +// defect: clearing one peer's endpoint used to dump and parse the whole device, so its cost +// grew with the size of the network map. On a routing peer with thousands of peers that dump +// runs on every relay and ICE transition, under the interface lock. +func TestRemoveEndpointAddressDoesNotScaleWithPeerCount(t *testing.T) { + measure := func(peerCount int) float64 { + c := newTestUSPConfigurer(t) + peerKey := seedPeers(t, c, peerCount)[peerCount/2] + + return testing.AllocsPerRun(5, func() { + require.NoError(t, c.RemoveEndpointAddress(peerKey), "remove endpoint address") + }) + } + + small := measure(64) + large := measure(1024) + + assert.Less(t, large, small*2, + "clearing one endpoint allocated %.0f objects with 1024 peers against %.0f with 64: the cost still scales with the peer count", + large, small) +} + +// TestRemoveEndpointAddressFallsBackToDevice covers a peer the store never saw, which is what +// an out-of-band reconfiguration of the device leaves behind. The device stays the source of +// truth in that case, so the allowed IPs must still be preserved. +func TestRemoveEndpointAddressFallsBackToDevice(t *testing.T) { + c := newTestUSPConfigurer(t) + peerKey := seedPeers(t, c, 3)[1] + require.NoError(t, c.AddAllowedIP(peerKey, netip.MustParsePrefix("10.20.0.0/16")), "add routed prefix") + + before := peerAllowedIPs(t, c, peerKey) + c.allowedIPs.reset() + + require.NoError(t, c.RemoveEndpointAddress(peerKey), "remove endpoint address") + + assert.ElementsMatch(t, before, peerAllowedIPs(t, c, peerKey), + "allowed IPs recovered from the device must be preserved") + + recovered, ok := c.allowedIPs.get(mustParseKey(t, peerKey)) + assert.True(t, ok, "the fallback must seed the store so the next call skips the device dump") + assert.Len(t, recovered, 2, "seeded prefixes") +} + +func TestRemoveAllowedIPKeepsTheOtherPrefixes(t *testing.T) { + c := newTestUSPConfigurer(t) + peerKey := seedPeers(t, c, 3)[0] + routed := netip.MustParsePrefix("10.20.0.0/16") + require.NoError(t, c.AddAllowedIP(peerKey, routed), "add routed prefix") + require.NoError(t, c.AddAllowedIP(peerKey, netip.MustParsePrefix("192.168.7.0/24")), "add routed prefix") + + require.NoError(t, c.RemoveAllowedIP(peerKey, routed), "remove routed prefix") + + assert.ElementsMatch(t, []string{"100.64.0.0/32", "192.168.7.0/24"}, peerAllowedIPs(t, c, peerKey), + "only the removed prefix should be gone") + + assert.ErrorIs(t, c.RemoveAllowedIP(peerKey, routed), ErrAllowedIPNotFound, + "removing a prefix that is no longer configured must be reported") +} + +// TestAddAllowedIPOnAbsentPeerDoesNotResurrectIt covers the lazy connection window documented +// in #6863: AddAllowedIP is update-only, a silent no-op when the peer is absent, so it must not +// leave the store claiming prefixes the device never took. RemoveEndpointAddress re-adds a peer +// without update-only, so a phantom entry would create a peer the device had dropped, and a +// created peer would steal those allowed IPs from whichever peer legitimately holds them. +func TestAddAllowedIPOnAbsentPeerDoesNotResurrectIt(t *testing.T) { + c := newTestUSPConfigurer(t) + seedPeers(t, c, 2) + + priv, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err, "generate peer private key") + absent := priv.PublicKey().String() + + require.NoError(t, c.AddAllowedIP(absent, netip.MustParsePrefix("10.20.0.0/16")), + "update-only add on an absent peer is a silent no-op") + + stats, err := c.FullStats() + require.NoError(t, err, "read device stats") + require.Len(t, stats.Peers, 2, "the absent peer must not have been created by AddAllowedIP") + + assert.ErrorIs(t, c.RemoveEndpointAddress(absent), ErrPeerNotFound, + "clearing the endpoint of a peer the device does not have must fail") + + stats, err = c.FullStats() + require.NoError(t, err, "read device stats") + assert.Len(t, stats.Peers, 2, "no peer may be created while clearing an endpoint") +} + +// TestRemoveEndpointAddressDoesNotStealAPrefixFromAnotherPeer covers WireGuard's rule that an +// allowed IP belongs to exactly one peer: configuring a prefix on a peer takes it away from +// whichever peer held it before. UpdatePeer relies on that rule rather than removing the prefix +// from the previous holder itself, so a prefix handed over between peers must not come back. +func TestRemoveEndpointAddressDoesNotStealAPrefixFromAnotherPeer(t *testing.T) { + c := newTestUSPConfigurer(t) + keys := seedPeers(t, c, 2) + peerA, peerB := keys[0], keys[1] + routed := netip.MustParsePrefix("10.20.0.0/16") + + require.NoError(t, c.AddAllowedIP(peerA, routed), "give the prefix to A") + require.Contains(t, peerAllowedIPs(t, c, peerA), routed.String(), "A must hold the prefix") + + // The route moves to B. The device takes it away from A on its own. + require.NoError(t, c.AddAllowedIP(peerB, routed), "hand the prefix over to B") + require.Contains(t, peerAllowedIPs(t, c, peerB), routed.String(), "B must hold the prefix") + require.NotContains(t, peerAllowedIPs(t, c, peerA), routed.String(), "the device must have taken it from A") + + require.NoError(t, c.RemoveEndpointAddress(peerA), "clear A's endpoint") + + assert.NotContains(t, peerAllowedIPs(t, c, peerA), routed.String(), + "clearing A's endpoint must not take the prefix back from B") + assert.Contains(t, peerAllowedIPs(t, c, peerB), routed.String(), + "B must still hold the prefix") +} + +// TestPresharedKeyCreatedPeerTakesPartInPrefixHandover covers a peer created by a preshared +// key write rather than by a peer update. Rosenpass applies a peer's first key without +// updateOnly, which creates the peer on the device, so a store that ignored that operation +// would treat the peer as unknown and would not account for a prefix later handed over to it. +func TestPresharedKeyCreatedPeerTakesPartInPrefixHandover(t *testing.T) { + c := newTestUSPConfigurer(t) + peerA := seedPeers(t, c, 1)[0] + routed := netip.MustParsePrefix("10.20.0.0/16") + require.NoError(t, c.AddAllowedIP(peerA, routed), "give the prefix to A") + + priv, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err, "generate peer private key") + peerB := priv.PublicKey().String() + + psk, err := wgtypes.GenerateKey() + require.NoError(t, err, "generate preshared key") + require.NoError(t, c.SetPresharedKey(peerB, psk, false), "a first key creates the peer") + + require.NoError(t, c.AddAllowedIP(peerB, routed), "hand the prefix over to B") + require.Contains(t, peerAllowedIPs(t, c, peerB), routed.String(), "B must hold the prefix") + + require.NoError(t, c.RemoveEndpointAddress(peerA), "clear A's endpoint") + + assert.NotContains(t, peerAllowedIPs(t, c, peerA), routed.String(), + "clearing A's endpoint must not take the prefix back from B") + assert.Contains(t, peerAllowedIPs(t, c, peerB), routed.String(), "B must still hold the prefix") +} + +// TestUpdatePeerDoesNotWidenAMappedPrefixOnTheDevice is the end to end form of the +// conversion: a v4-mapped prefix must not reach the device as a zero length allowed IP, +// which would route every v4 address to that peer. +func TestUpdatePeerDoesNotWidenAMappedPrefixOnTheDevice(t *testing.T) { + c := newTestUSPConfigurer(t) + + priv, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err, "generate peer private key") + peerKey := priv.PublicKey().String() + + mapped := netip.MustParsePrefix("::ffff:10.1.2.3/112") + require.NoError(t, c.UpdatePeer(peerKey, []netip.Prefix{mapped}, 25*time.Second, nil, nil), "add peer") + + onDevice := peerAllowedIPs(t, c, peerKey) + assert.NotContains(t, onDevice, "0.0.0.0/0", "the device must not be given a catch-all allowed IP") + assert.Equal(t, []string{"10.1.0.0/16"}, onDevice, "the device holds the normalized prefix") + + recorded, ok := c.allowedIPs.get(mustParseKey(t, peerKey)) + require.True(t, ok, "the peer must be recorded") + require.Len(t, recorded, 1, "one prefix recorded") + assert.Equal(t, onDevice[0], recorded[0].String(), "device and store must agree") +} + +// TestUpdatePeerWithAnUnusableEndpointTouchesNothing pins the ordering: the endpoint is +// parsed before the device is configured, so a failure cannot leave the device holding a +// peer that the store never learned about, with the prefix handover skipped along with it. +func TestUpdatePeerWithAnUnusableEndpointTouchesNothing(t *testing.T) { + c := newTestUSPConfigurer(t) + seedPeers(t, c, 2) + + priv, err := wgtypes.GeneratePrivateKey() + require.NoError(t, err, "generate peer private key") + peerKey := priv.PublicKey().String() + + // A three byte address has no textual form netip can parse back. + endpoint := &net.UDPAddr{IP: net.IP{1, 2, 3}, Port: 51820} + require.Error(t, c.UpdatePeer(peerKey, []netip.Prefix{netip.MustParsePrefix("10.30.0.0/16")}, + 25*time.Second, endpoint, nil), "an unusable endpoint must fail the update") + + stats, err := c.FullStats() + require.NoError(t, err, "read device stats") + assert.Len(t, stats.Peers, 2, "the peer must not have reached the device") + + _, ok := c.allowedIPs.get(mustParseKey(t, peerKey)) + assert.False(t, ok, "the peer must not have been recorded either") +} + +// TestRemovePeerKeepsTheRecordWhenTheDeviceRefuses covers a removal that never reached the +// device. A single peer removal is one write, so a failure leaves the peer on the device +// exactly as it was, and the record still describes it; dropping it would only force the +// next caller to read the whole device back for an answer it already had. +func TestRemovePeerKeepsTheRecordWhenTheDeviceRefuses(t *testing.T) { + c := newTestUSPConfigurer(t) + peerKey := seedPeers(t, c, 1)[0] + require.NoError(t, c.AddAllowedIP(peerKey, netip.MustParsePrefix("10.20.0.0/16")), "add routed prefix") + + before, ok := c.allowedIPs.get(mustParseKey(t, peerKey)) + require.True(t, ok, "the peer must be recorded before the removal") + require.Len(t, before, 2, "overlay address plus routed prefix") + + // A closed device refuses every write, which is the shape of any failed removal. + c.device.Close() + + require.Error(t, c.RemovePeer(peerKey), "the removal must report the failure") + + after, ok := c.allowedIPs.get(mustParseKey(t, peerKey)) + require.True(t, ok, "a peer still on the device must stay recorded") + assert.Equal(t, before, after, "the record must describe the peer the device kept") +} + +// mustParseKey turns the textual key the configurer API takes into the form the store +// keys on. +func mustParseKey(t *testing.T, key string) wgtypes.Key { + t.Helper() + + parsed, err := wgtypes.ParseKey(key) + require.NoError(t, err, "parse peer key") + return parsed +} From 002755c41239b4457084bc81d17346754d7e1dde Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Mon, 28 Sep 2026 21:26:40 +0300 Subject: [PATCH 25/33] [infrastructure] Fix flow auth secret for external Relay migrations (#7731) --- infrastructure_files/migrate-to-enterprise.sh | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/infrastructure_files/migrate-to-enterprise.sh b/infrastructure_files/migrate-to-enterprise.sh index 2b10250c9..8abde2f68 100755 --- a/infrastructure_files/migrate-to-enterprise.sh +++ b/infrastructure_files/migrate-to-enterprise.sh @@ -220,6 +220,21 @@ detect_exposed_address() { yq eval '.server.exposedAddress // ""' "$CONFIG_YAML_HOST" } +detect_relay_auth_secret() { + local secret="" + local external_relay_count + external_relay_count=$(yq eval '(.server.relays.addresses // []) | length' "$CONFIG_YAML_HOST") + + if (( external_relay_count > 0 )); then + secret=$(yq eval '.server.relays.secret // ""' "$CONFIG_YAML_HOST") + fi + if [[ -z "$secret" ]] || [[ "$secret" == "null" ]]; then + secret=$(yq eval '.server.authSecret // ""' "$CONFIG_YAML_HOST") + fi + + printf '%s' "$secret" +} + # The engine is a config.yaml-only setting — there is no env override for it # (combined/cmd/root.go reads it from YAML and derives the env vars), so # config.yaml is authoritative. Absent means the sqlite default. @@ -945,11 +960,10 @@ init_migration() { if [[ "$MIGRATE_POSTGRES" == "yes" ]] || [[ "$EXISTING_POSTGRES" == "yes" ]]; then ENABLE_FLOW=$(read_yes_no "Step 3: Enable traffic flow? (requires Postgres)" "n") if [[ "$ENABLE_FLOW" == "yes" ]]; then - # Auth secret MUST match server.authSecret from config.yaml - NB_FLOW_AUTH_SECRET=$(yq eval '.server.authSecret // ""' "$CONFIG_YAML_HOST") + NB_FLOW_AUTH_SECRET=$(detect_relay_auth_secret) if [[ -z "$NB_FLOW_AUTH_SECRET" ]] || [[ "$NB_FLOW_AUTH_SECRET" == "null" ]]; then - echo "Could not read server.authSecret from $CONFIG_YAML_HOST." > /dev/stderr - echo "Flow receiver auth must match the combined server's authSecret." > /dev/stderr + echo "Could not resolve the Relay auth secret from $CONFIG_YAML_HOST." > /dev/stderr + echo "Set server.relays.secret for external Relays or server.authSecret for the local Relay." > /dev/stderr exit 1 fi From 782c9434106f68fe83e45d612d756896a8179237 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:45:55 +0200 Subject: [PATCH 26/33] [management] extract shared db conn + data repository (#7649) * extract shared db conn + data repository * extract repository interface * protect against nested transactions * fix mysql and db conn creation * fix context management * remove query warpper * remove withContext and withLock wrapper * remove context from function call * remove in memory mode * fix nested transaction handling * use db directly * remove leftover test * remove pool close on error during conn creation --- .../network_map_db/sqlite_test_store.go | 8 +- .../accesslogs/manager/manager.go | 11 +- .../accesslogs/manager/manager_test.go | 86 ++-- .../accesslogs/manager/repository.go} | 98 ++--- .../accesslogs/manager/repository_test.go | 125 ++++++ .../reverseproxy/accesslogs/repository.go | 18 + .../accesslogs/repository_mock.go | 102 +++++ management/internals/server/boot.go | 16 +- management/internals/shared/db/conn.go | 121 ++++++ management/internals/shared/db/conn_test.go | 148 +++++++ .../internals/shared/db/dbtest/dbtest.go | 23 + .../internals/shared/db/dbtest/dbtest_test.go | 31 ++ management/internals/shared/db/engine.go | 10 + management/internals/shared/db/lock.go | 12 + management/internals/shared/db/open.go | 160 +++++++ management/internals/shared/db/open_test.go | 12 + management/internals/shared/db/transaction.go | 105 +++++ .../testing/testing_tools/channel/channel.go | 10 +- management/server/store/sql_store.go | 397 ++++-------------- management/server/store/sql_store_account.go | 8 +- .../store/sql_store_account_onboarding.go | 2 +- .../sql_store_agent_network_access_log.go | 4 +- .../store/sql_store_agent_network_usage.go | 2 +- .../server/store/sql_store_agentnetwork.go | 2 +- management/server/store/sql_store_group.go | 6 +- .../server/store/sql_store_group_peer.go | 2 +- .../server/store/sql_store_idp_migration.go | 10 +- .../store/sql_store_name_server_group.go | 2 +- management/server/store/sql_store_network.go | 2 +- .../store/sql_store_network_resource.go | 2 +- .../server/store/sql_store_network_router.go | 2 +- management/server/store/sql_store_peer.go | 4 +- .../store/sql_store_personal_access_token.go | 2 +- management/server/store/sql_store_policy.go | 4 +- .../server/store/sql_store_policy_rule.go | 2 +- .../server/store/sql_store_posture_checks.go | 2 +- management/server/store/sql_store_route.go | 2 +- management/server/store/sql_store_service.go | 4 +- .../server/store/sql_store_service_target.go | 2 +- .../server/store/sql_store_setup_key.go | 2 +- management/server/store/sql_store_test.go | 88 +++- management/server/store/sql_store_user.go | 4 +- .../server/store/sqlstore_bench_test.go | 10 +- management/server/store/store.go | 84 ++-- management/server/store/store_mock.go | 52 +-- management/server/types/store.go | 10 +- 46 files changed, 1247 insertions(+), 562 deletions(-) rename management/{server/store/sql_store_access_log.go => internals/modules/reverseproxy/accesslogs/manager/repository.go} (52%) create mode 100644 management/internals/modules/reverseproxy/accesslogs/manager/repository_test.go create mode 100644 management/internals/modules/reverseproxy/accesslogs/repository.go create mode 100644 management/internals/modules/reverseproxy/accesslogs/repository_mock.go create mode 100644 management/internals/shared/db/conn.go create mode 100644 management/internals/shared/db/conn_test.go create mode 100644 management/internals/shared/db/dbtest/dbtest.go create mode 100644 management/internals/shared/db/dbtest/dbtest_test.go create mode 100644 management/internals/shared/db/engine.go create mode 100644 management/internals/shared/db/lock.go create mode 100644 management/internals/shared/db/open.go create mode 100644 management/internals/shared/db/open_test.go create mode 100644 management/internals/shared/db/transaction.go diff --git a/integration_tests/management/network_map_db/sqlite_test_store.go b/integration_tests/management/network_map_db/sqlite_test_store.go index 1c70c93d4..622ac1c83 100644 --- a/integration_tests/management/network_map_db/sqlite_test_store.go +++ b/integration_tests/management/network_map_db/sqlite_test_store.go @@ -9,8 +9,8 @@ import ( "strings" networkmap_sqlite "github.com/netbirdio/netbird/management/internals/network_map_db/sqlite" + nbdb "github.com/netbirdio/netbird/management/internals/shared/db" gormstore "github.com/netbirdio/netbird/management/server/store" - "github.com/netbirdio/netbird/management/server/types" log "github.com/sirupsen/logrus" "gorm.io/driver/sqlite" "gorm.io/gorm" @@ -28,7 +28,11 @@ func createSqliteTestStore(baseData string) (*networkmap_sqlite.SqliteStore, fun if err != nil { log.Fatalf("error initializing db: %s", err.Error()) } - _, err = gormstore.NewSqlStore(context.TODO(), db, types.SqliteStoreEngine, nil, false) + conn, err := nbdb.NewConn(context.TODO(), db, nbdb.SqliteStoreEngine, nil) + if err != nil { + log.Fatalf("error initializing db: %s", err.Error()) + } + _, err = gormstore.NewSqlStore(context.TODO(), conn, nil, false) if err != nil { log.Fatalf("error initializing db: %s", err.Error()) } diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/manager.go b/management/internals/modules/reverseproxy/accesslogs/manager/manager.go index 4b24adaa1..d22cdb980 100644 --- a/management/internals/modules/reverseproxy/accesslogs/manager/manager.go +++ b/management/internals/modules/reverseproxy/accesslogs/manager/manager.go @@ -9,6 +9,7 @@ import ( "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/management/internals/shared/db" "github.com/netbirdio/netbird/management/server/geolocation" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/permissions/modules" @@ -18,14 +19,16 @@ import ( ) type managerImpl struct { + repo accesslogs.Repository store store.Store permissionsManager permissions.Manager geo geolocation.Geolocation cleanupCancel context.CancelFunc } -func NewManager(store store.Store, permissionsManager permissions.Manager, geo geolocation.Geolocation) accesslogs.Manager { +func NewManager(repo accesslogs.Repository, store store.Store, permissionsManager permissions.Manager, geo geolocation.Geolocation) accesslogs.Manager { return &managerImpl{ + repo: repo, store: store, permissionsManager: permissionsManager, geo: geo, @@ -54,7 +57,7 @@ func (m *managerImpl) SaveAccessLog(ctx context.Context, logEntry *accesslogs.Ac } } - if err := m.store.CreateAccessLog(ctx, logEntry); err != nil { + if err := m.repo.Create(ctx, logEntry); err != nil { log.WithContext(ctx).WithFields(log.Fields{ "service_id": logEntry.ServiceID, "method": logEntry.Method, @@ -82,7 +85,7 @@ func (m *managerImpl) GetAllAccessLogs(ctx context.Context, accountID, userID st log.WithContext(ctx).Warnf("failed to resolve user filters: %v", err) } - logs, totalCount, err := m.store.GetAccountAccessLogs(ctx, store.LockingStrengthNone, accountID, *filter) + logs, totalCount, err := m.repo.ListByAccount(ctx, db.LockingStrengthNone, accountID, *filter) if err != nil { return nil, 0, err } @@ -98,7 +101,7 @@ func (m *managerImpl) CleanupOldAccessLogs(ctx context.Context, retentionDays in } cutoffTime := time.Now().AddDate(0, 0, -retentionDays) - deletedCount, err := m.store.DeleteOldAccessLogs(ctx, cutoffTime) + deletedCount, err := m.repo.DeleteOlderThan(ctx, cutoffTime) if err != nil { log.WithContext(ctx).Errorf("failed to cleanup old access logs: %v", err) return 0, err diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/manager_test.go b/management/internals/modules/reverseproxy/accesslogs/manager/manager_test.go index 8e941d7e5..83dab7df6 100644 --- a/management/internals/modules/reverseproxy/accesslogs/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/accesslogs/manager/manager_test.go @@ -5,27 +5,27 @@ import ( "testing" "time" - "go.uber.org/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" - "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" ) func TestCleanupOldAccessLogs(t *testing.T) { tests := []struct { name string retentionDays int - setupMock func(*store.MockStore) + setupMock func(*accesslogs.MockRepository) expectedCount int64 expectedError bool }{ { name: "cleanup logs older than retention period", retentionDays: 30, - setupMock: func(mockStore *store.MockStore) { - mockStore.EXPECT(). - DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + setupMock: func(mockRepo *accesslogs.MockRepository) { + mockRepo.EXPECT(). + DeleteOlderThan(gomock.Any(), gomock.Any()). DoAndReturn(func(ctx context.Context, olderThan time.Time) (int64, error) { expectedCutoff := time.Now().AddDate(0, 0, -30) timeDiff := olderThan.Sub(expectedCutoff) @@ -41,9 +41,9 @@ func TestCleanupOldAccessLogs(t *testing.T) { { name: "no logs to cleanup", retentionDays: 30, - setupMock: func(mockStore *store.MockStore) { - mockStore.EXPECT(). - DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + setupMock: func(mockRepo *accesslogs.MockRepository) { + mockRepo.EXPECT(). + DeleteOlderThan(gomock.Any(), gomock.Any()). Return(int64(0), nil) }, expectedCount: 0, @@ -52,8 +52,8 @@ func TestCleanupOldAccessLogs(t *testing.T) { { name: "zero retention days skips cleanup", retentionDays: 0, - setupMock: func(mockStore *store.MockStore) { - // No expectations - DeleteOldAccessLogs should not be called + setupMock: func(mockRepo *accesslogs.MockRepository) { + // No expectations - DeleteOlderThan should not be called }, expectedCount: 0, expectedError: false, @@ -61,8 +61,8 @@ func TestCleanupOldAccessLogs(t *testing.T) { { name: "negative retention days skips cleanup", retentionDays: -10, - setupMock: func(mockStore *store.MockStore) { - // No expectations - DeleteOldAccessLogs should not be called + setupMock: func(mockRepo *accesslogs.MockRepository) { + // No expectations - DeleteOlderThan should not be called }, expectedCount: 0, expectedError: false, @@ -74,11 +74,11 @@ func TestCleanupOldAccessLogs(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - mockStore := store.NewMockStore(ctrl) - tt.setupMock(mockStore) + mockRepo := accesslogs.NewMockRepository(ctrl) + tt.setupMock(mockRepo) manager := &managerImpl{ - store: mockStore, + repo: mockRepo, } ctx := context.Background() @@ -98,10 +98,10 @@ func TestCleanupWithExactBoundary(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - mockStore := store.NewMockStore(ctrl) + mockRepo := accesslogs.NewMockRepository(ctrl) - mockStore.EXPECT(). - DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + mockRepo.EXPECT(). + DeleteOlderThan(gomock.Any(), gomock.Any()). DoAndReturn(func(ctx context.Context, olderThan time.Time) (int64, error) { expectedCutoff := time.Now().AddDate(0, 0, -30) timeDiff := olderThan.Sub(expectedCutoff) @@ -110,7 +110,7 @@ func TestCleanupWithExactBoundary(t *testing.T) { }) manager := &managerImpl{ - store: mockStore, + repo: mockRepo, } ctx := context.Background() @@ -125,11 +125,11 @@ func TestStartPeriodicCleanup(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - mockStore := store.NewMockStore(ctrl) + mockRepo := accesslogs.NewMockRepository(ctrl) // No expectations - cleanup should not run manager := &managerImpl{ - store: mockStore, + repo: mockRepo, } ctx, cancel := context.WithCancel(context.Background()) @@ -139,22 +139,22 @@ func TestStartPeriodicCleanup(t *testing.T) { time.Sleep(100 * time.Millisecond) - // If DeleteOldAccessLogs was called, the test will fail due to unexpected call + // If DeleteOlderThan was called, the test will fail due to unexpected call }) t.Run("periodic cleanup runs immediately on start", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - mockStore := store.NewMockStore(ctrl) + mockRepo := accesslogs.NewMockRepository(ctrl) - mockStore.EXPECT(). - DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + mockRepo.EXPECT(). + DeleteOlderThan(gomock.Any(), gomock.Any()). Return(int64(2), nil). Times(1) manager := &managerImpl{ - store: mockStore, + repo: mockRepo, } ctx, cancel := context.WithCancel(context.Background()) @@ -171,15 +171,15 @@ func TestStartPeriodicCleanup(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - mockStore := store.NewMockStore(ctrl) + mockRepo := accesslogs.NewMockRepository(ctrl) - mockStore.EXPECT(). - DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + mockRepo.EXPECT(). + DeleteOlderThan(gomock.Any(), gomock.Any()). Return(int64(1), nil). Times(1) manager := &managerImpl{ - store: mockStore, + repo: mockRepo, } ctx, cancel := context.WithCancel(context.Background()) @@ -198,15 +198,15 @@ func TestStartPeriodicCleanup(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - mockStore := store.NewMockStore(ctrl) + mockRepo := accesslogs.NewMockRepository(ctrl) - mockStore.EXPECT(). - DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + mockRepo.EXPECT(). + DeleteOlderThan(gomock.Any(), gomock.Any()). Return(int64(0), nil). Times(1) manager := &managerImpl{ - store: mockStore, + repo: mockRepo, } ctx, cancel := context.WithCancel(context.Background()) @@ -223,15 +223,15 @@ func TestStartPeriodicCleanup(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - mockStore := store.NewMockStore(ctrl) + mockRepo := accesslogs.NewMockRepository(ctrl) - mockStore.EXPECT(). - DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + mockRepo.EXPECT(). + DeleteOlderThan(gomock.Any(), gomock.Any()). Return(int64(3), nil). Times(1) manager := &managerImpl{ - store: mockStore, + repo: mockRepo, } ctx, cancel := context.WithCancel(context.Background()) @@ -249,15 +249,15 @@ func TestStopPeriodicCleanup(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - mockStore := store.NewMockStore(ctrl) + mockRepo := accesslogs.NewMockRepository(ctrl) - mockStore.EXPECT(). - DeleteOldAccessLogs(gomock.Any(), gomock.Any()). + mockRepo.EXPECT(). + DeleteOlderThan(gomock.Any(), gomock.Any()). Return(int64(1), nil). Times(1) manager := &managerImpl{ - store: mockStore, + repo: mockRepo, } ctx := context.Background() diff --git a/management/server/store/sql_store_access_log.go b/management/internals/modules/reverseproxy/accesslogs/manager/repository.go similarity index 52% rename from management/server/store/sql_store_access_log.go rename to management/internals/modules/reverseproxy/accesslogs/manager/repository.go index 56092eb74..105696ab7 100644 --- a/management/server/store/sql_store_access_log.go +++ b/management/internals/modules/reverseproxy/accesslogs/manager/repository.go @@ -1,4 +1,4 @@ -package store +package manager import ( "context" @@ -10,91 +10,78 @@ import ( "gorm.io/gorm/clause" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/management/internals/shared/db" "github.com/netbirdio/netbird/shared/management/status" ) -// CreateAccessLog creates a new access log entry in the database -func (s *SqlStore) CreateAccessLog(ctx context.Context, logEntry *accesslogs.AccessLogEntry) error { - result := s.db.Create(logEntry) - if result.Error != nil { +type sqlRepository struct { + conn *db.Conn + db *gorm.DB +} + +// NewRepository returns the access log repository backed by conn. +func NewRepository(conn *db.Conn) accesslogs.Repository { + return &sqlRepository{conn: conn, db: conn.DB(nil)} +} + +func (r *sqlRepository) WithTx(tx *db.Tx) accesslogs.Repository { + return &sqlRepository{conn: r.conn, db: r.conn.DB(tx)} +} + +func (r *sqlRepository) Create(ctx context.Context, entry *accesslogs.AccessLogEntry) error { + if err := r.db.Create(entry).Error; err != nil { log.WithContext(ctx).WithFields(log.Fields{ - "service_id": logEntry.ServiceID, - "method": logEntry.Method, - "host": logEntry.Host, - "path": logEntry.Path, - }).Errorf("failed to create access log entry in store: %v", result.Error) + "service_id": entry.ServiceID, + "method": entry.Method, + "host": entry.Host, + "path": entry.Path, + }).Errorf("failed to create access log entry in store: %v", err) return status.Errorf(status.Internal, "failed to create access log entry in store") } return nil } -// GetAccountAccessLogs retrieves access logs for a given account with pagination and filtering -func (s *SqlStore) GetAccountAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) { - var logs []*accesslogs.AccessLogEntry +// ListByAccount returns one page of an account's access logs together with the +// total number of entries matching the filter. +func (r *sqlRepository) ListByAccount(ctx context.Context, lockStrength db.LockingStrength, accountID string, filter accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) { var totalCount int64 - - baseQuery := s.db. - Model(&accesslogs.AccessLogEntry{}). - Where(accountIDCondition, accountID) - - baseQuery = s.applyAccessLogFilters(baseQuery, filter) - - if err := baseQuery.Count(&totalCount).Error; err != nil { + countQuery := applyFilters(r.db.Model(&accesslogs.AccessLogEntry{}).Where("account_id = ?", accountID), filter) + if err := countQuery.Count(&totalCount).Error; err != nil { log.WithContext(ctx).Errorf("failed to count access logs: %v", err) return nil, 0, status.Errorf(status.Internal, "failed to count access logs") } - query := s.db. - Where(accountIDCondition, accountID) - - query = s.applyAccessLogFilters(query, filter) - - sortColumns := filter.GetSortColumn() + query := applyFilters(r.db.Where("account_id = ?", accountID), filter) sortOrder := strings.ToUpper(filter.GetSortOrder()) - - var orderClauses []string - for _, col := range strings.Split(sortColumns, ",") { - col = strings.TrimSpace(col) - if col != "" { - orderClauses = append(orderClauses, col+" "+sortOrder) + for _, column := range strings.Split(filter.GetSortColumn(), ",") { + if column = strings.TrimSpace(column); column != "" { + query = query.Order(column + " " + sortOrder) } } - orderClause := strings.Join(orderClauses, ", ") - - query = query. - Order(orderClause). - Limit(filter.GetLimit()). - Offset(filter.GetOffset()) - - if lockStrength != LockingStrengthNone { + query = query.Limit(filter.GetLimit()).Offset(filter.GetOffset()) + if lockStrength != db.LockingStrengthNone { query = query.Clauses(clause.Locking{Strength: string(lockStrength)}) } - result := query.Find(&logs) - if result.Error != nil { - log.WithContext(ctx).Errorf("failed to get access logs from store: %v", result.Error) + var logs []*accesslogs.AccessLogEntry + if err := query.Find(&logs).Error; err != nil { + log.WithContext(ctx).Errorf("failed to get access logs from store: %v", err) return nil, 0, status.Errorf(status.Internal, "failed to get access logs from store") } return logs, totalCount, nil } -// DeleteOldAccessLogs deletes all access logs older than the specified time -func (s *SqlStore) DeleteOldAccessLogs(ctx context.Context, olderThan time.Time) (int64, error) { - result := s.db. - Where("timestamp < ?", olderThan). - Delete(&accesslogs.AccessLogEntry{}) - +func (r *sqlRepository) DeleteOlderThan(ctx context.Context, olderThan time.Time) (int64, error) { + result := r.db.Where("timestamp < ?", olderThan).Delete(&accesslogs.AccessLogEntry{}) if result.Error != nil { log.WithContext(ctx).Errorf("failed to delete old access logs: %v", result.Error) return 0, status.Errorf(status.Internal, "failed to delete old access logs") } - return result.RowsAffected, nil } -// applyAccessLogFilters applies filter conditions to the query -func (s *SqlStore) applyAccessLogFilters(query *gorm.DB, filter accesslogs.AccessLogFilter) *gorm.DB { +func applyFilters(query *gorm.DB, filter accesslogs.AccessLogFilter) *gorm.DB { if filter.Search != nil { searchPattern := "%" + *filter.Search + "%" query = query.Where( @@ -112,7 +99,6 @@ func (s *SqlStore) applyAccessLogFilters(query *gorm.DB, filter accesslogs.Acces } if filter.Path != nil { - // Support LIKE pattern for path filtering query = query.Where("path LIKE ?", "%"+*filter.Path+"%") } @@ -127,9 +113,9 @@ func (s *SqlStore) applyAccessLogFilters(query *gorm.DB, filter accesslogs.Acces if filter.Status != nil { switch *filter.Status { case "success": - query = query.Where("status_code >= ? AND status_code < ?", 200, 400) + query = query.Where("(status_code >= ? AND status_code < ?)", 200, 400) case "failed": - query = query.Where("status_code < ? OR status_code >= ?", 200, 400) + query = query.Where("((status_code >= ? AND status_code < ?) OR status_code >= ?)", 100, 200, 400) } } diff --git a/management/internals/modules/reverseproxy/accesslogs/manager/repository_test.go b/management/internals/modules/reverseproxy/accesslogs/manager/repository_test.go new file mode 100644 index 000000000..9931cc27c --- /dev/null +++ b/management/internals/modules/reverseproxy/accesslogs/manager/repository_test.go @@ -0,0 +1,125 @@ +package manager + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" + "github.com/netbirdio/netbird/management/internals/shared/db" + "github.com/netbirdio/netbird/management/internals/shared/db/dbtest" +) + +func newTestRepository(t *testing.T) (accesslogs.Repository, *db.Conn) { + conn := dbtest.NewConn(t, &accesslogs.AccessLogEntry{}) + return NewRepository(conn), conn +} + +func newEntry(id, accountID, method string, age time.Duration) *accesslogs.AccessLogEntry { + return &accesslogs.AccessLogEntry{ + ID: id, + AccountID: accountID, + Method: method, + Host: "app.example.com", + Path: "/", + StatusCode: 200, + Timestamp: time.Now().Add(-age), + } +} + +func TestSqlRepository_ListByAccount(t *testing.T) { + repo, _ := newTestRepository(t) + ctx := context.Background() + for _, entry := range []*accesslogs.AccessLogEntry{ + newEntry("a1", "acc-a", "GET", 3*time.Hour), + newEntry("a2", "acc-a", "POST", 2*time.Hour), + newEntry("a3", "acc-a", "GET", time.Hour), + newEntry("b1", "acc-b", "GET", time.Hour), + } { + require.NoError(t, repo.Create(ctx, entry)) + } + + logs, total, err := repo.ListByAccount(ctx, db.LockingStrengthNone, "acc-a", accesslogs.AccessLogFilter{Page: 1, PageSize: 2}) + require.NoError(t, err) + assert.EqualValues(t, 3, total) + require.Len(t, logs, 2) + assert.Equal(t, "a3", logs[0].ID) + assert.Equal(t, "a2", logs[1].ID) + + method := "GET" + logs, total, err = repo.ListByAccount(ctx, db.LockingStrengthNone, "acc-a", accesslogs.AccessLogFilter{Page: 1, PageSize: 10, Method: &method, SortOrder: "asc"}) + require.NoError(t, err) + assert.EqualValues(t, 2, total) + require.Len(t, logs, 2) + assert.Equal(t, "a1", logs[0].ID) + assert.Equal(t, "a3", logs[1].ID) +} + +func TestSqlRepository_DeleteOlderThan(t *testing.T) { + repo, _ := newTestRepository(t) + ctx := context.Background() + require.NoError(t, repo.Create(ctx, newEntry("old", "acc", "GET", 48*time.Hour))) + require.NoError(t, repo.Create(ctx, newEntry("new", "acc", "GET", time.Hour))) + + deleted, err := repo.DeleteOlderThan(ctx, time.Now().Add(-24*time.Hour)) + require.NoError(t, err) + assert.EqualValues(t, 1, deleted) + + logs, total, err := repo.ListByAccount(ctx, db.LockingStrengthNone, "acc", accesslogs.AccessLogFilter{Page: 1, PageSize: 10}) + require.NoError(t, err) + assert.EqualValues(t, 1, total) + require.Len(t, logs, 1) + assert.Equal(t, "new", logs[0].ID) +} + +func TestSqlRepository_CreateInsideTransactionRollsBack(t *testing.T) { + repo, conn := newTestRepository(t) + ctx := context.Background() + failure := errors.New("abort") + + err := conn.RunInTx(ctx, func(tx *db.Tx) error { + txRepo := repo.WithTx(tx) + require.NoError(t, txRepo.Create(ctx, newEntry("tx", "acc", "GET", 0))) + _, total, err := txRepo.ListByAccount(ctx, db.LockingStrengthNone, "acc", accesslogs.AccessLogFilter{Page: 1, PageSize: 10}) + require.NoError(t, err) + assert.EqualValues(t, 1, total) + return failure + }) + require.ErrorIs(t, err, failure) + + _, total, err := repo.ListByAccount(ctx, db.LockingStrengthNone, "acc", accesslogs.AccessLogFilter{Page: 1, PageSize: 10}) + require.NoError(t, err) + assert.Zero(t, total) +} + +func TestSqlRepository_ListByAccount_StatusFilter(t *testing.T) { + repo, _ := newTestRepository(t) + ctx := context.Background() + statusCodes := map[string]int{"l4": 0, "info": 101, "ok": 200, "notfound": 404} + for id, code := range statusCodes { + entry := newEntry(id, "acc", "GET", time.Hour) + entry.StatusCode = code + require.NoError(t, repo.Create(ctx, entry)) + } + foreign := newEntry("foreign", "other", "GET", time.Hour) + foreign.StatusCode = 500 + require.NoError(t, repo.Create(ctx, foreign)) + + listIDs := func(status string) []string { + logs, total, err := repo.ListByAccount(ctx, db.LockingStrengthNone, "acc", accesslogs.AccessLogFilter{Page: 1, PageSize: 10, Status: &status, SortBy: "status_code", SortOrder: "asc"}) + require.NoError(t, err) + require.EqualValues(t, len(logs), total) + ids := make([]string, 0, len(logs)) + for _, entry := range logs { + ids = append(ids, entry.ID) + } + return ids + } + + assert.Equal(t, []string{"info", "notfound"}, listIDs("failed")) + assert.Equal(t, []string{"ok"}, listIDs("success")) +} diff --git a/management/internals/modules/reverseproxy/accesslogs/repository.go b/management/internals/modules/reverseproxy/accesslogs/repository.go new file mode 100644 index 000000000..5945f454c --- /dev/null +++ b/management/internals/modules/reverseproxy/accesslogs/repository.go @@ -0,0 +1,18 @@ +package accesslogs + +import ( + "context" + "time" + + "github.com/netbirdio/netbird/management/internals/shared/db" +) + +//go:generate go tool mockgen -package accesslogs -destination=repository_mock.go -source=./repository.go -build_flags=-mod=mod + +// Repository persists reverse proxy access log entries. +type Repository interface { + WithTx(tx *db.Tx) Repository + Create(ctx context.Context, entry *AccessLogEntry) error + ListByAccount(ctx context.Context, lockStrength db.LockingStrength, accountID string, filter AccessLogFilter) ([]*AccessLogEntry, int64, error) + DeleteOlderThan(ctx context.Context, olderThan time.Time) (int64, error) +} diff --git a/management/internals/modules/reverseproxy/accesslogs/repository_mock.go b/management/internals/modules/reverseproxy/accesslogs/repository_mock.go new file mode 100644 index 000000000..7fbb05b7f --- /dev/null +++ b/management/internals/modules/reverseproxy/accesslogs/repository_mock.go @@ -0,0 +1,102 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./repository.go +// +// Generated by this command: +// +// mockgen -package accesslogs -destination=repository_mock.go -source=./repository.go -build_flags=-mod=mod +// + +// Package accesslogs is a generated GoMock package. +package accesslogs + +import ( + context "context" + reflect "reflect" + time "time" + + db "github.com/netbirdio/netbird/management/internals/shared/db" + gomock "go.uber.org/mock/gomock" +) + +// MockRepository is a mock of Repository interface. +type MockRepository struct { + ctrl *gomock.Controller + recorder *MockRepositoryMockRecorder + isgomock struct{} +} + +// MockRepositoryMockRecorder is the mock recorder for MockRepository. +type MockRepositoryMockRecorder struct { + mock *MockRepository +} + +// NewMockRepository creates a new mock instance. +func NewMockRepository(ctrl *gomock.Controller) *MockRepository { + mock := &MockRepository{ctrl: ctrl} + mock.recorder = &MockRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockRepository) Create(ctx context.Context, entry *AccessLogEntry) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", ctx, entry) + ret0, _ := ret[0].(error) + return ret0 +} + +// Create indicates an expected call of Create. +func (mr *MockRepositoryMockRecorder) Create(ctx, entry any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockRepository)(nil).Create), ctx, entry) +} + +// DeleteOlderThan mocks base method. +func (m *MockRepository) DeleteOlderThan(ctx context.Context, olderThan time.Time) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteOlderThan", ctx, olderThan) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DeleteOlderThan indicates an expected call of DeleteOlderThan. +func (mr *MockRepositoryMockRecorder) DeleteOlderThan(ctx, olderThan any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOlderThan", reflect.TypeOf((*MockRepository)(nil).DeleteOlderThan), ctx, olderThan) +} + +// ListByAccount mocks base method. +func (m *MockRepository) ListByAccount(ctx context.Context, lockStrength db.LockingStrength, accountID string, filter AccessLogFilter) ([]*AccessLogEntry, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListByAccount", ctx, lockStrength, accountID, filter) + ret0, _ := ret[0].([]*AccessLogEntry) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ListByAccount indicates an expected call of ListByAccount. +func (mr *MockRepositoryMockRecorder) ListByAccount(ctx, lockStrength, accountID, filter any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByAccount", reflect.TypeOf((*MockRepository)(nil).ListByAccount), ctx, lockStrength, accountID, filter) +} + +// WithTx mocks base method. +func (m *MockRepository) WithTx(tx *db.Tx) Repository { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WithTx", tx) + ret0, _ := ret[0].(Repository) + return ret0 +} + +// WithTx indicates an expected call of WithTx. +func (mr *MockRepositoryMockRecorder) WithTx(tx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithTx", reflect.TypeOf((*MockRepository)(nil).WithTx), tx) +} diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index dbad2f0c8..9eb8b7748 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -32,6 +32,7 @@ import ( networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db" networkmapdbfactory "github.com/netbirdio/netbird/management/internals/network_map_db/factory" nbconfig "github.com/netbirdio/netbird/management/internals/server/config" + "github.com/netbirdio/netbird/management/internals/shared/db" nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" "github.com/netbirdio/netbird/management/server/activity" activitystore "github.com/netbirdio/netbird/management/server/activity/store" @@ -84,9 +85,20 @@ func (s *BaseServer) CacheStore() nbcache.Store { }) } +// DBConn opens the database connection shared by the store and the domain repositories. +func (s *BaseServer) DBConn() *db.Conn { + return Create(s, func() *db.Conn { + conn, err := store.OpenConn(context.Background(), s.Config.StoreConfig.Engine, s.Config.Datadir) + if err != nil { + log.Fatalf("failed to open database connection: %v", err) + } + return conn + }) +} + func (s *BaseServer) Store() store.Store { return Create(s, func() store.Store { - store, err := store.NewStore(context.Background(), s.Config.StoreConfig.Engine, s.Config.Datadir, s.Metrics(), false) + store, err := store.NewSqlStore(context.Background(), s.DBConn(), s.Metrics(), false) if err != nil { log.Fatalf("failed to create store: %v", err) } @@ -308,7 +320,7 @@ func (s *BaseServer) ProxyActivityManager() proxyactivity.Manager { func (s *BaseServer) AccessLogsManager() accesslogs.Manager { return Create(s, func() accesslogs.Manager { - accessLogManager := accesslogsmanager.NewManager(s.Store(), s.PermissionsManager(), s.GeoLocationManager()) + accessLogManager := accesslogsmanager.NewManager(accesslogsmanager.NewRepository(s.DBConn()), s.Store(), s.PermissionsManager(), s.GeoLocationManager()) accessLogManager.StartPeriodicCleanup( context.Background(), s.Config.ReverseProxy.AccessLogRetentionDays, diff --git a/management/internals/shared/db/conn.go b/management/internals/shared/db/conn.go new file mode 100644 index 000000000..8edaa1e4e --- /dev/null +++ b/management/internals/shared/db/conn.go @@ -0,0 +1,121 @@ +package db + +import ( + "context" + "fmt" + "os" + "runtime" + "strconv" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + log "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +const ( + defaultTransactionTimeout = 5 * time.Minute + connMaxLifetime = time.Hour + connMaxIdleTime = 3 * time.Minute +) + +// TxMetrics receives the duration of every committed top-level transaction. +type TxMetrics interface { + CountTransactionDuration(duration time.Duration) +} + +// Conn is the database connection shared by all repositories: one gorm handle, +// the pgx pool of a Postgres deployment and the engine they talk to. +type Conn struct { + db *gorm.DB + pool *pgxpool.Pool + engine Engine + txTimeout time.Duration + metrics TxMetrics +} + +// NewConn takes ownership of an open gorm handle and pool once it returns +// without error, applying the connection limits and transaction timeout +// configured through the environment. +func NewConn(ctx context.Context, gormDB *gorm.DB, engine Engine, pool *pgxpool.Pool) (*Conn, error) { + sqlDB, err := gormDB.DB() + if err != nil { + return nil, err + } + + txTimeout := defaultTransactionTimeout + if v := os.Getenv("NB_STORE_TRANSACTION_TIMEOUT"); v != "" { + if parsed, err := time.ParseDuration(v); err == nil { + txTimeout = parsed + } + } + log.WithContext(ctx).Infof("Setting transaction timeout to %v", txTimeout) + + conns := runtime.NumCPU() + configuredConns, err := strconv.Atoi(os.Getenv("NB_SQL_MAX_OPEN_CONNS")) + connsConfigured := err == nil + if connsConfigured { + conns = configuredConns + } + if engine == SqliteStoreEngine { + if connsConfigured { + log.WithContext(ctx).Warnf("setting NB_SQL_MAX_OPEN_CONNS is not supported for sqlite, using default value 1") + } + conns = 1 + } + + sqlDB.SetMaxOpenConns(conns) + sqlDB.SetMaxIdleConns(conns) + sqlDB.SetConnMaxLifetime(connMaxLifetime) + sqlDB.SetConnMaxIdleTime(connMaxIdleTime) + + log.WithContext(ctx).Infof("Set max open db connections to %d, max idle to %d, max lifetime to %v, max idle time to %v", + conns, conns, connMaxLifetime, connMaxIdleTime) + + return &Conn{db: gormDB, pool: pool, engine: engine, txTimeout: txTimeout}, nil +} + +// DB returns the handle a query must run on: the transaction when tx is set, +// otherwise the shared connection. +func (c *Conn) DB(tx *Tx) *gorm.DB { + if tx != nil { + return tx.db + } + return c.db +} + +// Pool returns the pgx pool for read paths that bypass gorm. It is nil on +// engines other than Postgres and inside a transaction, where the pool would +// not see the uncommitted writes. +func (c *Conn) Pool(tx *Tx) *pgxpool.Pool { + if tx != nil { + return nil + } + return c.pool +} + +func (c *Conn) Engine() Engine { + return c.engine +} + +// SetTxMetrics registers the sink that receives transaction durations. +func (c *Conn) SetTxMetrics(metrics TxMetrics) { + c.metrics = metrics +} + +// AutoMigrate creates or updates the tables of the given models. +func (c *Conn) AutoMigrate(models ...any) error { + return c.db.AutoMigrate(models...) +} + +// Close releases the gorm connection and the pgx pool. +func (c *Conn) Close() error { + if c.pool != nil { + c.pool.Close() + } + sqlDB, err := c.db.DB() + if err != nil { + return fmt.Errorf("get db: %w", err) + } + return sqlDB.Close() +} diff --git a/management/internals/shared/db/conn_test.go b/management/internals/shared/db/conn_test.go new file mode 100644 index 000000000..4f1a234b2 --- /dev/null +++ b/management/internals/shared/db/conn_test.go @@ -0,0 +1,148 @@ +package db + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type testRow struct { + ID uint `gorm:"primaryKey"` + Name string +} + +func openTestConn(t *testing.T) *Conn { + t.Helper() + conn, err := OpenSqliteFile(context.Background(), t.TempDir(), SqliteFileName) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, conn.Close()) }) + require.NoError(t, conn.AutoMigrate(&testRow{})) + return conn +} + +func countRows(t *testing.T, conn *Conn) int64 { + t.Helper() + var count int64 + require.NoError(t, conn.DB(nil).Model(&testRow{}).Count(&count).Error) + return count +} + +func TestNewConn_ReadsTransactionTimeoutFromEnv(t *testing.T) { + t.Setenv("NB_STORE_TRANSACTION_TIMEOUT", "1s") + conn := openTestConn(t) + assert.Equal(t, time.Second, conn.txTimeout) + assert.Equal(t, SqliteStoreEngine, conn.Engine()) +} + +func TestRunInTx_CommitsOnSuccess(t *testing.T) { + conn := openTestConn(t) + + err := conn.RunInTx(context.Background(), func(tx *Tx) error { + return conn.DB(tx).Create(&testRow{Name: "a"}).Error + }) + require.NoError(t, err) + assert.EqualValues(t, 1, countRows(t, conn)) +} + +func TestRunInTx_RollsBackOnError(t *testing.T) { + conn := openTestConn(t) + failure := errors.New("boom") + + err := conn.RunInTx(context.Background(), func(tx *Tx) error { + require.NoError(t, conn.DB(tx).Create(&testRow{Name: "a"}).Error) + return failure + }) + require.ErrorIs(t, err, failure) + assert.EqualValues(t, 0, countRows(t, conn)) +} + +func TestRunInTx_RollsBackOnPanic(t *testing.T) { + conn := openTestConn(t) + + require.Panics(t, func() { + _ = conn.RunInTx(context.Background(), func(tx *Tx) error { + require.NoError(t, conn.DB(tx).Create(&testRow{Name: "a"}).Error) + panic("boom") + }) + }) + assert.EqualValues(t, 0, countRows(t, conn)) +} + +func TestRunInTx_FailsWhenTimeoutExceeded(t *testing.T) { + t.Setenv("NB_STORE_TRANSACTION_TIMEOUT", "50ms") + conn := openTestConn(t) + + err := conn.RunInTx(context.Background(), func(tx *Tx) error { + time.Sleep(100 * time.Millisecond) + return conn.DB(tx).Create(&testRow{Name: "a"}).Error + }) + require.ErrorIs(t, err, context.DeadlineExceeded) + assert.EqualValues(t, 0, countRows(t, conn)) +} + +func TestRunInTx_ReportsDurationToMetrics(t *testing.T) { + conn := openTestConn(t) + metrics := &recordingMetrics{} + conn.SetTxMetrics(metrics) + + require.NoError(t, conn.RunInTx(context.Background(), func(*Tx) error { return nil })) + assert.Equal(t, 1, metrics.calls) +} + +func TestConn_DBSelectsTransactionHandle(t *testing.T) { + conn := openTestConn(t) + assert.Same(t, conn.db, conn.DB(nil)) + + err := conn.RunInTx(context.Background(), func(tx *Tx) error { + assert.Same(t, tx.db, conn.DB(tx)) + assert.NotSame(t, conn.db, conn.DB(tx)) + return nil + }) + require.NoError(t, err) +} + +func TestConn_PoolIsUnavailableInsideTransaction(t *testing.T) { + conn := openTestConn(t) + conn.pool = &pgxpool.Pool{} + defer func() { conn.pool = nil }() + + assert.Same(t, conn.pool, conn.Pool(nil)) + err := conn.RunInTx(context.Background(), func(tx *Tx) error { + assert.Nil(t, conn.Pool(tx)) + return nil + }) + require.NoError(t, err) +} + +type recordingMetrics struct { + calls int +} + +func (m *recordingMetrics) CountTransactionDuration(time.Duration) { + m.calls++ +} + +func TestNewConn_MaxOpenConnsFromEnv(t *testing.T) { + t.Setenv("NB_SQL_MAX_OPEN_CONNS", "7") + + gormDB, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "store.db")), GormConfig()) + require.NoError(t, err) + conn, err := NewConn(context.Background(), gormDB, PostgresStoreEngine, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + sqlDB, err := conn.DB(nil).DB() + require.NoError(t, err) + assert.Equal(t, 7, sqlDB.Stats().MaxOpenConnections) + + sqliteDB, err := openTestConn(t).DB(nil).DB() + require.NoError(t, err) + assert.Equal(t, 1, sqliteDB.Stats().MaxOpenConnections) +} diff --git a/management/internals/shared/db/dbtest/dbtest.go b/management/internals/shared/db/dbtest/dbtest.go new file mode 100644 index 000000000..0f0fe0356 --- /dev/null +++ b/management/internals/shared/db/dbtest/dbtest.go @@ -0,0 +1,23 @@ +package dbtest + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/shared/db" +) + +// NewConn opens a fresh SQLite database in a temporary directory, migrates the +// given models and closes the connection when the test ends. It ignores +// NB_STORE_ENGINE_SQLITE_FILE, so a developer's configured database is never +// touched, and is safe to call from parallel tests. +func NewConn(t testing.TB, models ...any) *db.Conn { + t.Helper() + conn, err := db.OpenSqliteFile(context.Background(), t.TempDir(), db.SqliteFileName) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + require.NoError(t, conn.AutoMigrate(models...)) + return conn +} diff --git a/management/internals/shared/db/dbtest/dbtest_test.go b/management/internals/shared/db/dbtest/dbtest_test.go new file mode 100644 index 000000000..3f8566876 --- /dev/null +++ b/management/internals/shared/db/dbtest/dbtest_test.go @@ -0,0 +1,31 @@ +package dbtest + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/management/internals/shared/db" +) + +func TestNewConn_IgnoresSqliteFileOverride(t *testing.T) { + override := filepath.Join(t.TempDir(), "configured.db") + t.Setenv("NB_STORE_ENGINE_SQLITE_FILE", override) + + conn := NewConn(t) + + assert.Equal(t, db.SqliteStoreEngine, conn.Engine()) + _, err := os.Stat(override) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestNewConn_Parallel(t *testing.T) { + t.Parallel() + + conn := NewConn(t) + + assert.Equal(t, db.SqliteStoreEngine, conn.Engine()) +} diff --git a/management/internals/shared/db/engine.go b/management/internals/shared/db/engine.go new file mode 100644 index 000000000..02a3f5570 --- /dev/null +++ b/management/internals/shared/db/engine.go @@ -0,0 +1,10 @@ +package db + +// Engine identifies the SQL engine behind a Conn. +type Engine string + +const ( + SqliteStoreEngine Engine = "sqlite" + PostgresStoreEngine Engine = "postgres" + MysqlStoreEngine Engine = "mysql" +) diff --git a/management/internals/shared/db/lock.go b/management/internals/shared/db/lock.go new file mode 100644 index 000000000..bf6fc9b5a --- /dev/null +++ b/management/internals/shared/db/lock.go @@ -0,0 +1,12 @@ +package db + +// LockingStrength is the row lock a query holds until its transaction ends. +type LockingStrength string + +const ( + LockingStrengthUpdate LockingStrength = "UPDATE" + LockingStrengthShare LockingStrength = "SHARE" + LockingStrengthNoKeyUpdate LockingStrength = "NO KEY UPDATE" + LockingStrengthKeyShare LockingStrength = "KEY SHARE" + LockingStrengthNone LockingStrength = "NONE" +) diff --git a/management/internals/shared/db/open.go b/management/internals/shared/db/open.go new file mode 100644 index 000000000..020aff422 --- /dev/null +++ b/management/internals/shared/db/open.go @@ -0,0 +1,160 @@ +package db + +import ( + "context" + "fmt" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// SqliteFileName is the default SQLite database file inside the data directory. +const SqliteFileName = "store.db" + +// PoolConfig sizes the pgx pool a Postgres deployment uses for the read paths +// that bypass gorm. +type PoolConfig struct { + MaxConns int32 + MinConns int32 + MaxConnLifetime time.Duration + HealthCheckPeriod time.Duration +} + +var DefaultPoolConfig = PoolConfig{ + MaxConns: 30, + MinConns: 1, + MaxConnLifetime: 60 * time.Minute, + HealthCheckPeriod: time.Minute, +} + +// GormConfig is the configuration every engine is opened with. +func GormConfig() *gorm.Config { + return &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + CreateBatchSize: 400, + } +} + +// OpenSqlite opens the SQLite database in dataDir, or the file named by +// NB_STORE_ENGINE_SQLITE_FILE. +func OpenSqlite(ctx context.Context, dataDir string) (*Conn, error) { + storeFile := SqliteFileName + if envFile, ok := os.LookupEnv("NB_STORE_ENGINE_SQLITE_FILE"); ok && envFile != "" { + storeFile = envFile + } + return OpenSqliteFile(ctx, dataDir, storeFile) +} + +// OpenSqliteFile opens the SQLite database storeFile, resolved against dataDir +// when relative. storeFile may carry SQLite URI query parameters. +func OpenSqliteFile(ctx context.Context, dataDir, storeFile string) (*Conn, error) { + // Separate file path from any SQLite URI query parameters (e.g., "store.db?mode=rwc") + filePath, query, hasQuery := strings.Cut(storeFile, "?") + + connStr := filePath + if !filepath.IsAbs(filePath) { + connStr = filepath.Join(dataDir, filePath) + } + + // Compose query parameters. User-provided ?_busy_timeout (or its mattn alias + // ?_timeout) overrides our default; otherwise inject 30s so SQLite waits at + // most that long on a lock instead of blocking the only Go-side connection. + // mattn/go-sqlite3 applies PRAGMA from the DSN on every fresh connection, so + // the value survives ConnMaxIdleTime/ConnMaxLifetime recycling. cache=shared + // stays the default on non-Windows for the same reason as before. + parsed, _ := url.ParseQuery(query) + var defaults []string + if parsed.Get("_busy_timeout") == "" && parsed.Get("_timeout") == "" { + defaults = append(defaults, "_busy_timeout=30000") + } + if !hasQuery && runtime.GOOS != "windows" { + // To avoid `The process cannot access the file because it is being used by another process` on Windows + defaults = append(defaults, "cache=shared") + } + parts := defaults + if hasQuery { + parts = append(parts, query) + } + if len(parts) > 0 { + connStr += "?" + strings.Join(parts, "&") + } + + gormDB, err := gorm.Open(sqlite.Open(connStr), GormConfig()) + if err != nil { + return nil, err + } + return NewConn(ctx, gormDB, SqliteStoreEngine, nil) +} + +// OpenPostgres opens a Postgres database through gorm and a pgx pool sized by pool. +func OpenPostgres(ctx context.Context, dsn string, pool PoolConfig) (*Conn, error) { + gormDB, err := gorm.Open(postgres.Open(dsn), GormConfig()) + if err != nil { + return nil, err + } + pgxPool, err := newPgxPool(ctx, dsn, pool) + if err != nil { + closeGorm(gormDB) + return nil, err + } + return NewConn(ctx, gormDB, PostgresStoreEngine, pgxPool) +} + +// MysqlDSN adds the connection parameters every MySQL handle needs, keeping +// the options already present in dsn. +func MysqlDSN(dsn string) string { + separator := "?" + if strings.Contains(dsn, "?") { + separator = "&" + } + return dsn + separator + "charset=utf8&parseTime=True&loc=Local" +} + +// OpenMysql opens a MySQL database through gorm. +func OpenMysql(ctx context.Context, dsn string) (*Conn, error) { + gormDB, err := gorm.Open(mysql.Open(MysqlDSN(dsn)), GormConfig()) + if err != nil { + return nil, err + } + return NewConn(ctx, gormDB, MysqlStoreEngine, nil) +} + +func newPgxPool(ctx context.Context, dsn string, cfg PoolConfig) (*pgxpool.Pool, error) { + config, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, fmt.Errorf("unable to parse database config: %w", err) + } + + config.MaxConns = cfg.MaxConns + config.MinConns = cfg.MinConns + config.MaxConnLifetime = cfg.MaxConnLifetime + config.HealthCheckPeriod = cfg.HealthCheckPeriod + + 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 +} + +func closeGorm(gormDB *gorm.DB) { + if sqlDB, err := gormDB.DB(); err == nil { + _ = sqlDB.Close() + } +} diff --git a/management/internals/shared/db/open_test.go b/management/internals/shared/db/open_test.go new file mode 100644 index 000000000..e6b506b78 --- /dev/null +++ b/management/internals/shared/db/open_test.go @@ -0,0 +1,12 @@ +package db + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMysqlDSN(t *testing.T) { + assert.Equal(t, "user:pw@tcp(host:3306)/db?charset=utf8&parseTime=True&loc=Local", MysqlDSN("user:pw@tcp(host:3306)/db")) + assert.Equal(t, "user:pw@tcp(host:3306)/db?tls=true&charset=utf8&parseTime=True&loc=Local", MysqlDSN("user:pw@tcp(host:3306)/db?tls=true")) +} diff --git a/management/internals/shared/db/transaction.go b/management/internals/shared/db/transaction.go new file mode 100644 index 000000000..9699aaee6 --- /dev/null +++ b/management/internals/shared/db/transaction.go @@ -0,0 +1,105 @@ +package db + +import ( + "context" + "errors" + "fmt" + "runtime/debug" + "time" + + log "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +// Tx is an open transaction handed to repository calls; nil means autocommit. +type Tx struct { + db *gorm.DB +} + +// RunInTx runs fn in one transaction that commits when fn returns nil and rolls +// back otherwise, bounded by the configured transaction timeout. +func (c *Conn) RunInTx(ctx context.Context, fn func(tx *Tx) error) error { + timeoutCtx, cancel := context.WithTimeout(ctx, c.txTimeout) + defer cancel() + + startTime := time.Now() + tx := c.db.WithContext(timeoutCtx).Begin() + if tx.Error != nil { + return tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + panic(r) + } + }() + + if err := c.applyStatementTimeouts(tx); err != nil { + tx.Rollback() + return err + } + + err := c.withForeignKeyChecksDisabled(tx, func() error { + return fn(&Tx{db: tx}) + }) + if err != nil { + tx.Rollback() + c.logIfTimedOut(ctx, timeoutCtx, err, "transaction", startTime) + return err + } + + if err := tx.Commit().Error; err != nil { + c.logIfTimedOut(ctx, timeoutCtx, err, "transaction commit", startTime) + return err + } + + log.WithContext(ctx).Tracef("transaction took %v", time.Since(startTime)) + if c.metrics != nil { + c.metrics.CountTransactionDuration(time.Since(startTime)) + } + return nil +} + +func (c *Conn) applyStatementTimeouts(tx *gorm.DB) error { + if c.engine != PostgresStoreEngine { + return nil + } + if err := tx.Exec("SET LOCAL statement_timeout = '1min'").Error; err != nil { + return fmt.Errorf("failed to set statement timeout: %w", err) + } + if err := tx.Exec("SET LOCAL lock_timeout = '1min'").Error; err != nil { + return fmt.Errorf("failed to set lock timeout: %w", err) + } + return nil +} + +// withForeignKeyChecksDisabled runs fn with MySQL's FK checks off, which avoids +// deadlocks on MySQL and Aurora without needing SUPER privilege. The setting is +// session-scoped and survives a rollback, so it is turned back on whenever fn +// returns or panics; otherwise the pooled connection would keep it disabled. +func (c *Conn) withForeignKeyChecksDisabled(tx *gorm.DB, fn func() error) (err error) { + if c.engine != MysqlStoreEngine { + return fn() + } + if err := tx.Exec("SET FOREIGN_KEY_CHECKS = 0").Error; err != nil { + return fmt.Errorf("failed to disable FK checks: %w", err) + } + defer func() { + restoreErr := tx.Exec("SET FOREIGN_KEY_CHECKS = 1").Error + if restoreErr == nil { + return + } + if err == nil { + err = fmt.Errorf("failed to re-enable FK checks: %w", restoreErr) + return + } + log.WithContext(tx.Statement.Context).Warnf("failed to re-enable FK checks after failed transaction: %v", restoreErr) + }() + return fn() +} + +func (c *Conn) logIfTimedOut(ctx, timeoutCtx context.Context, err error, phase string, startTime time.Time) { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) { + log.WithContext(ctx).Warnf("%s exceeded %s timeout after %v, stack: %s", phase, c.txTimeout, time.Since(startTime), debug.Stack()) + } +} diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go index ab992b1ca..6ea26e634 100644 --- a/management/server/http/testing/testing_tools/channel/channel.go +++ b/management/server/http/testing/testing_tools/channel/channel.go @@ -46,14 +46,14 @@ import ( "github.com/netbirdio/netbird/management/server/networks/routers" "github.com/netbirdio/netbird/management/server/permissions" "github.com/netbirdio/netbird/management/server/settings" - "github.com/netbirdio/netbird/management/server/store" + nbstore "github.com/netbirdio/netbird/management/server/store" "github.com/netbirdio/netbird/management/server/telemetry" "github.com/netbirdio/netbird/management/server/users" "github.com/netbirdio/netbird/shared/auth" ) func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPeerUpdate *network_map.UpdateMessage, validateUpdate bool) (http.Handler, account.Manager, chan struct{}) { - store, cleanup, err := store.NewTestStoreFromSQL(context.Background(), sqlFile, t.TempDir()) + store, cleanup, err := nbstore.NewTestStoreFromSQL(context.Background(), sqlFile, t.TempDir()) if err != nil { t.Fatalf("Failed to create test store: %v", err) } @@ -108,7 +108,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee t.Fatalf("Failed to create manager: %v", err) } - accessLogsManager := accesslogsmanager.NewManager(store, permissionsManager, nil) + accessLogsManager := accesslogsmanager.NewManager(accesslogsmanager.NewRepository(store.(*nbstore.SqlStore).Conn()), store, permissionsManager, nil) proxyTokenStore := nbgrpc.NewOneTimeTokenStore(ctx, cacheStore) pkceverifierStore := nbgrpc.NewPKCEVerifierStore(ctx, cacheStore) noopMeter := noop.NewMeterProvider().Meter("") @@ -204,7 +204,7 @@ func PeerShouldNotReceiveAnyUpdate(t testing_tools.TB, updateMessage <-chan *net // BuildApiBlackBoxWithDBStateAndPeerChannel creates the API handler and returns // the peer update channel directly so tests can verify updates inline. func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile string) (http.Handler, account.Manager, <-chan *network_map.UpdateMessage) { - store, cleanup, err := store.NewTestStoreFromSQL(context.Background(), sqlFile, t.TempDir()) + store, cleanup, err := nbstore.NewTestStoreFromSQL(context.Background(), sqlFile, t.TempDir()) if err != nil { t.Fatalf("Failed to create test store: %v", err) } @@ -248,7 +248,7 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin t.Fatalf("Failed to create manager: %v", err) } - accessLogsManager := accesslogsmanager.NewManager(store, permissionsManager, nil) + accessLogsManager := accesslogsmanager.NewManager(accesslogsmanager.NewRepository(store.(*nbstore.SqlStore).Conn()), store, permissionsManager, nil) proxyTokenStore := nbgrpc.NewOneTimeTokenStore(ctx, cacheStore) pkceverifierStore := nbgrpc.NewPKCEVerifierStore(ctx, cacheStore) noopMeter := noop.NewMeterProvider().Meter("") diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 425c1a0cb..b705ba0a2 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -2,25 +2,13 @@ package store import ( "context" - "errors" "fmt" - "net/url" - "os" - "path/filepath" - "runtime" - "runtime/debug" - "strconv" - "strings" "sync" "time" "github.com/jackc/pgx/v5/pgxpool" log "github.com/sirupsen/logrus" - "gorm.io/driver/mysql" - "gorm.io/driver/postgres" - "gorm.io/driver/sqlite" "gorm.io/gorm" - "gorm.io/gorm/logger" nbdns "github.com/netbirdio/netbird/dns" agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" @@ -30,6 +18,7 @@ import ( rpservice "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" + "github.com/netbirdio/netbird/management/internals/shared/db" 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" @@ -42,7 +31,6 @@ import ( ) const ( - storeSqliteFileName = "store.db" idQueryCondition = "id = ?" keyQueryCondition = "key = ?" mysqlKeyQueryCondition = "`key` = ?" @@ -52,71 +40,44 @@ const ( accountAndIDsQueryCondition = "account_id = ? AND id IN ?" accountIDCondition = "account_id = ?" peerNotFoundFMT = "peer %s not found" - - pgMaxConnections = 30 - pgMinConnections = 1 - pgMaxConnLifetime = 60 * time.Minute - pgHealthCheckPeriod = 1 * time.Minute ) +var testPoolConfig = db.PoolConfig{ + MaxConns: 5, + MinConns: 1, + MaxConnLifetime: 30 * time.Second, + HealthCheckPeriod: 10 * time.Second, +} + // SqlStore represents an account storage backed by a Sql DB persisted to disk type SqlStore struct { - db *gorm.DB - globalAccountLock sync.Mutex - metrics telemetry.AppMetrics - installationPK int - storeEngine types.Engine - pool *pgxpool.Pool - fieldEncrypt *crypt.FieldEncrypt - transactionTimeout time.Duration + conn *db.Conn + db *gorm.DB + tx *db.Tx + globalAccountLock sync.Mutex + metrics telemetry.AppMetrics + installationPK int + fieldEncrypt *crypt.FieldEncrypt } type migrationFunc func(*gorm.DB) error -// NewSqlStore creates a new SqlStore instance. -func NewSqlStore(ctx context.Context, db *gorm.DB, storeEngine types.Engine, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) { - sql, err := db.DB() - if err != nil { - return nil, err +// NewSqlStore creates a new SqlStore instance on top of an open connection. +func NewSqlStore(ctx context.Context, conn *db.Conn, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) { + if metrics != nil { + conn.SetTxMetrics(metrics.StoreMetrics()) } - - conns, err := strconv.Atoi(os.Getenv("NB_SQL_MAX_OPEN_CONNS")) - if err != nil { - conns = runtime.NumCPU() - } - - transactionTimeout := 5 * time.Minute - if v := os.Getenv("NB_STORE_TRANSACTION_TIMEOUT"); v != "" { - if parsed, err := time.ParseDuration(v); err == nil { - transactionTimeout = parsed - } - } - log.WithContext(ctx).Infof("Setting transaction timeout to %v", transactionTimeout) - - if storeEngine == types.SqliteStoreEngine { - if err == nil { - log.WithContext(ctx).Warnf("setting NB_SQL_MAX_OPEN_CONNS is not supported for sqlite, using default value 1") - } - conns = 1 - } - - sql.SetMaxOpenConns(conns) - sql.SetMaxIdleConns(conns) - sql.SetConnMaxLifetime(time.Hour) - sql.SetConnMaxIdleTime(3 * time.Minute) - - log.WithContext(ctx).Infof("Set max open db connections to %d, max idle to %d, max lifetime to %v, max idle time to %v", - conns, conns, time.Hour, 3*time.Minute) + store := &SqlStore{conn: conn, db: conn.DB(nil), metrics: metrics, installationPK: 1} if skipMigration { log.WithContext(ctx).Infof("skipping migration") - return &SqlStore{db: db, storeEngine: storeEngine, metrics: metrics, installationPK: 1, transactionTimeout: transactionTimeout}, nil + return store, nil } - if err := migratePreAuto(ctx, db); err != nil { + if err := migratePreAuto(ctx, store.db); err != nil { return nil, fmt.Errorf("migratePreAuto: %w", err) } - err = db.AutoMigrate( + err := conn.AutoMigrate( &types.SetupKey{}, &nbpeer.Peer{}, &types.User{}, &types.PersonalAccessToken{}, &types.ProxyAccessToken{}, &types.Group{}, &types.GroupPeer{}, &types.Account{}, &types.Policy{}, &types.PolicyRule{}, &route.Route{}, &nbdns.NameServerGroup{}, @@ -132,15 +93,34 @@ func NewSqlStore(ctx context.Context, db *gorm.DB, storeEngine types.Engine, met if err != nil { return nil, fmt.Errorf("auto migratePreAuto: %w", err) } - if err := migratePostAuto(ctx, db); err != nil { + if err := migratePostAuto(ctx, store.db); err != nil { return nil, fmt.Errorf("migratePostAuto: %w", err) } - return &SqlStore{db: db, storeEngine: storeEngine, metrics: metrics, installationPK: 1, transactionTimeout: transactionTimeout}, nil + return store, nil +} + +// newStore runs the migrations on conn and releases it when they fail. +func newStore(ctx context.Context, conn *db.Conn, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) { + store, err := NewSqlStore(ctx, conn, metrics, skipMigration) + if err != nil { + _ = conn.Close() + return nil, err + } + return store, nil +} + +// Conn returns the shared connection so domain repositories can run alongside this store. +func (s *SqlStore) Conn() *db.Conn { + return s.conn +} + +func (s *SqlStore) pgxPool() *pgxpool.Pool { + return s.conn.Pool(s.tx) } func GetKeyQueryCondition(s *SqlStore) string { - if s.storeEngine == types.MysqlStoreEngine { + if s.conn.Engine() == db.MysqlStoreEngine { return mysqlKeyQueryCondition } return keyQueryCondition @@ -168,145 +148,39 @@ func (s *SqlStore) AcquireGlobalLock(ctx context.Context) (unlock func()) { // Close closes the underlying DB connection func (s *SqlStore) Close(_ context.Context) error { - sql, err := s.db.DB() - if err != nil { - return fmt.Errorf("get db: %w", err) - } - return sql.Close() + return s.conn.Close() } // GetStoreEngine returns underlying store engine func (s *SqlStore) GetStoreEngine() types.Engine { - return s.storeEngine + return s.conn.Engine() } // NewSqliteStore creates a new SQLite store. func NewSqliteStore(ctx context.Context, dataDir string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) { - storeFile := storeSqliteFileName - if envFile, ok := os.LookupEnv("NB_STORE_ENGINE_SQLITE_FILE"); ok && envFile != "" { - storeFile = envFile - } - - // Separate file path from any SQLite URI query parameters (e.g., "store.db?mode=rwc") - filePath, query, hasQuery := strings.Cut(storeFile, "?") - - connStr := filePath - if !filepath.IsAbs(filePath) { - connStr = filepath.Join(dataDir, filePath) - } - - // Compose query parameters. User-provided ?_busy_timeout (or its mattn alias - // ?_timeout) overrides our default; otherwise inject 30s so SQLite waits at - // most that long on a lock instead of blocking the only Go-side connection. - // mattn/go-sqlite3 applies PRAGMA from the DSN on every fresh connection, so - // the value survives ConnMaxIdleTime/ConnMaxLifetime recycling. cache=shared - // stays the default on non-Windows for the same reason as before. - parsed, _ := url.ParseQuery(query) - var defaults []string - if parsed.Get("_busy_timeout") == "" && parsed.Get("_timeout") == "" { - defaults = append(defaults, "_busy_timeout=30000") - } - if !hasQuery && runtime.GOOS != "windows" { - // To avoid `The process cannot access the file because it is being used by another process` on Windows - defaults = append(defaults, "cache=shared") - } - parts := defaults - if hasQuery { - parts = append(parts, query) - } - if len(parts) > 0 { - connStr += "?" + strings.Join(parts, "&") - } - - db, err := gorm.Open(sqlite.Open(connStr), getGormConfig()) + conn, err := db.OpenSqlite(ctx, dataDir) if err != nil { return nil, err } - - return NewSqlStore(ctx, db, types.SqliteStoreEngine, metrics, skipMigration) + return newStore(ctx, conn, metrics, skipMigration) } // NewPostgresqlStore creates a new Postgres store. func NewPostgresqlStore(ctx context.Context, dsn string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) { - db, err := gorm.Open(postgres.Open(dsn), getGormConfig()) + conn, err := db.OpenPostgres(ctx, dsn, db.DefaultPoolConfig) if err != nil { return nil, err } - pool, err := connectToPgDb(context.Background(), dsn) - if err != nil { - return nil, err - } - store, err := NewSqlStore(ctx, db, types.PostgresStoreEngine, metrics, skipMigration) - if err != nil { - pool.Close() - return nil, err - } - store.pool = pool - return store, 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 + return newStore(ctx, conn, metrics, skipMigration) } // NewMysqlStore creates a new MySQL store. func NewMysqlStore(ctx context.Context, dsn string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) { - db, err := gorm.Open(mysql.Open(dsn+"?charset=utf8&parseTime=True&loc=Local"), getGormConfig()) + conn, err := db.OpenMysql(ctx, dsn) if err != nil { return nil, err } - - store, err := NewSqlStore(ctx, db, types.MysqlStoreEngine, metrics, skipMigration) - if err != nil { - closeGormDB(db) - return nil, err - } - return store, nil -} - -func getGormConfig() *gorm.Config { - return &gorm.Config{ - Logger: logger.Default.LogMode(logger.Silent), - CreateBatchSize: 400, - } -} - -// newPostgresStore initializes a new Postgres store. -func newPostgresStore(ctx context.Context, metrics telemetry.AppMetrics, skipMigration bool) (Store, error) { - dsn, ok := lookupDSNEnv(PostgresDsnEnv, PostgresDsnEnvLegacy) - if !ok { - return nil, fmt.Errorf("%s is not set", PostgresDsnEnv) - } - return NewPostgresqlStore(ctx, dsn, metrics, skipMigration) -} - -// newMysqlStore initializes a new MySQL store. -func newMysqlStore(ctx context.Context, metrics telemetry.AppMetrics, skipMigration bool) (Store, error) { - dsn, ok := lookupDSNEnv(mysqlDsnEnv, mysqlDsnEnvLegacy) - if !ok { - return nil, fmt.Errorf("%s is not set", mysqlDsnEnv) - } - return NewMysqlStore(ctx, dsn, metrics, skipMigration) + return newStore(ctx, conn, metrics, skipMigration) } // NewSqliteStoreFromFileStore restores a store from FileStore and stores SQLite DB in the file located in datadir. @@ -350,7 +224,7 @@ func newPostgresqlStoreFromSqlStore(ctx context.Context, sqliteStore *SqlStore, } if err := seedFromSqliteStore(ctx, store, sqliteStore); err != nil { - closeStore(ctx, store) + _ = store.Close(ctx) return nil, err } @@ -359,49 +233,11 @@ func newPostgresqlStoreFromSqlStore(ctx context.Context, sqliteStore *SqlStore, // used for tests only func NewPostgresqlStoreForTests(ctx context.Context, dsn string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) { - db, err := gorm.Open(postgres.Open(dsn), getGormConfig()) + conn, err := db.OpenPostgres(ctx, dsn, testPoolConfig) if err != nil { return nil, err } - pool, err := connectToPgDbForTests(context.Background(), dsn) - if err != nil { - closeGormDB(db) - return nil, err - } - store, err := NewSqlStore(ctx, db, types.PostgresStoreEngine, metrics, skipMigration) - if err != nil { - // Release the sessions, or the caller cannot drop the database. - pool.Close() - closeGormDB(db) - return nil, err - } - store.pool = pool - return store, nil -} - -// used for tests only -func connectToPgDbForTests(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 = 5 - config.MinConns = 1 - config.MaxConnLifetime = 30 * time.Second - config.HealthCheckPeriod = 10 * time.Second - - 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 + return newStore(ctx, conn, metrics, skipMigration) } // NewMysqlStoreFromSqlStore restores a store from SqlStore and stores MySQL DB. @@ -423,15 +259,6 @@ func seedFromSqliteStore(ctx context.Context, store, sqliteStore *SqlStore) erro return nil } -// closeStore releases a store that is not handed to the caller, so a failed -// seed does not leak its connection and pool. -func closeStore(ctx context.Context, store *SqlStore) { - store.Close(ctx) - if store.pool != nil { - store.pool.Close() - } -} - func newMysqlStoreFromSqlStore(ctx context.Context, sqliteStore *SqlStore, dsn string, metrics telemetry.AppMetrics, skipMigration bool) (*SqlStore, error) { store, err := NewMysqlStore(ctx, dsn, metrics, skipMigration) if err != nil { @@ -439,113 +266,41 @@ func newMysqlStoreFromSqlStore(ctx context.Context, sqliteStore *SqlStore, dsn s } if err := seedFromSqliteStore(ctx, store, sqliteStore); err != nil { - closeStore(ctx, store) + _ = store.Close(ctx) return nil, err } return store, nil } +// ExecuteInTransaction runs operation in a transaction. A store that is already +// bound to one joins it instead of opening a second, independent transaction. func (s *SqlStore) ExecuteInTransaction(ctx context.Context, operation func(store Store) error) error { - timeoutCtx, cancel := context.WithTimeout(ctx, s.transactionTimeout) - defer cancel() - - startTime := time.Now() - tx := s.db.WithContext(timeoutCtx).Begin() - if tx.Error != nil { - return tx.Error + if s.tx != nil { + return operation(s) } - defer func() { - if r := recover(); r != nil { - tx.Rollback() - panic(r) - } - }() - - if s.storeEngine == types.PostgresStoreEngine { - if err := tx.Exec("SET LOCAL statement_timeout = '1min'").Error; err != nil { - tx.Rollback() - return fmt.Errorf("failed to set statement timeout: %w", err) - } - if err := tx.Exec("SET LOCAL lock_timeout = '1min'").Error; err != nil { - tx.Rollback() - return fmt.Errorf("failed to set lock timeout: %w", err) - } - } - - // For MySQL, disable FK checks within this transaction to avoid deadlocks - // This is session-scoped and doesn't require SUPER privileges - if s.storeEngine == types.MysqlStoreEngine { - if err := tx.Exec("SET FOREIGN_KEY_CHECKS = 0").Error; err != nil { - tx.Rollback() - return fmt.Errorf("failed to disable FK checks: %w", err) - } - } - - repo := s.withTx(tx) - err := operation(repo) - if err != nil { - tx.Rollback() - if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) { - log.WithContext(ctx).Warnf("transaction exceeded %s timeout after %v, stack: %s", s.transactionTimeout, time.Since(startTime), debug.Stack()) - } - return err - } - - // Re-enable FK checks before commit (optional, as transaction end resets it) - if s.storeEngine == types.MysqlStoreEngine { - if err := tx.Exec("SET FOREIGN_KEY_CHECKS = 1").Error; err != nil { - tx.Rollback() - return fmt.Errorf("failed to re-enable FK checks: %w", err) - } - } - - err = tx.Commit().Error - if err != nil { - if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) { - log.WithContext(ctx).Warnf("transaction commit exceeded %s timeout after %v, stack: %s", s.transactionTimeout, time.Since(startTime), debug.Stack()) - } - return err - } - - log.WithContext(ctx).Tracef("transaction took %v", time.Since(startTime)) - if s.metrics != nil { - s.metrics.StoreMetrics().CountTransactionDuration(time.Since(startTime)) - } - - return nil + return s.conn.RunInTx(ctx, func(tx *db.Tx) error { + return operation(s.withTx(tx)) + }) } -func (s *SqlStore) withTx(tx *gorm.DB) Store { +func (s *SqlStore) withTx(tx *db.Tx) Store { return &SqlStore{ - db: tx, - storeEngine: s.storeEngine, + conn: s.conn, + db: s.conn.DB(tx), + tx: tx, fieldEncrypt: s.fieldEncrypt, } } -// transaction wraps a GORM transaction with MySQL-specific FK checks handling -// Use this instead of db.Transaction() directly to avoid deadlocks on MySQL/Aurora -func (s *SqlStore) transaction(fn func(*gorm.DB) error) error { - return s.db.Transaction(func(tx *gorm.DB) error { - // For MySQL, disable FK checks within this transaction to avoid deadlocks - // This is session-scoped and doesn't require SUPER privileges - if s.storeEngine == types.MysqlStoreEngine { - if err := tx.Exec("SET FOREIGN_KEY_CHECKS = 0").Error; err != nil { - return fmt.Errorf("failed to disable FK checks: %w", err) - } - } - - err := fn(tx) - - // Re-enable FK checks before commit (optional, as transaction end resets it) - if s.storeEngine == types.MysqlStoreEngine && err == nil { - if fkErr := tx.Exec("SET FOREIGN_KEY_CHECKS = 1").Error; fkErr != nil { - return fmt.Errorf("failed to re-enable FK checks: %w", fkErr) - } - } - - return err +// transaction runs fn as a savepoint of the bound transaction, or in a new +// transaction when the store is not bound to one. +func (s *SqlStore) transaction(ctx context.Context, fn func(tx *gorm.DB) error) error { + if s.tx != nil { + return s.db.Transaction(fn) + } + return s.conn.RunInTx(ctx, func(tx *db.Tx) error { + return fn(s.conn.DB(tx)) }) } diff --git a/management/server/store/sql_store_account.go b/management/server/store/sql_store_account.go index f4cb15a3c..8729c1f5a 100644 --- a/management/server/store/sql_store_account.go +++ b/management/server/store/sql_store_account.go @@ -51,7 +51,7 @@ func (s *SqlStore) SaveAccount(ctx context.Context, account *types.Account) erro group.StoreGroupPeers() } - err := s.transaction(func(tx *gorm.DB) error { + err := s.transaction(ctx, func(tx *gorm.DB) error { result := tx.Select(clause.Associations).Delete(account.Policies, "account_id = ?", account.Id) if result.Error != nil { return result.Error @@ -146,7 +146,7 @@ func (s *SqlStore) checkAccountDomainBeforeSave(ctx context.Context, accountID, func (s *SqlStore) DeleteAccount(ctx context.Context, account *types.Account) error { start := time.Now() - err := s.transaction(func(tx *gorm.DB) error { + err := s.transaction(ctx, func(tx *gorm.DB) error { result := tx.Select(clause.Associations).Delete(account.Policies, "account_id = ?", account.Id) if result.Error != nil { return result.Error @@ -281,7 +281,7 @@ func (s *SqlStore) GetAccountMeta(ctx context.Context, lockStrength LockingStren } func (s *SqlStore) GetAccount(ctx context.Context, accountID string) (*types.Account, error) { - if s.pool != nil { + if s.pgxPool() != nil { return s.getAccountPgx(ctx, accountID) } return s.getAccountGorm(ctx, accountID) @@ -761,7 +761,7 @@ func (s *SqlStore) getAccount(ctx context.Context, accountID string) (*types.Acc networkSerial sql.NullInt64 createdAt sql.NullTime ) - err := s.pool.QueryRow(ctx, accountQuery, accountID).Scan( + err := s.pgxPool().QueryRow(ctx, accountQuery, accountID).Scan( &account.Id, &account.CreatedBy, &createdAt, &account.Domain, &account.DomainCategory, &account.IsDomainPrimaryAccount, &networkIdentifier, &networkNet, &networkNetV6, &networkDns, &networkSerial, &dnsSettingsDisabledGroups, diff --git a/management/server/store/sql_store_account_onboarding.go b/management/server/store/sql_store_account_onboarding.go index 5872ea63e..73c8f14d0 100644 --- a/management/server/store/sql_store_account_onboarding.go +++ b/management/server/store/sql_store_account_onboarding.go @@ -44,7 +44,7 @@ func (s *SqlStore) getAccountOnboarding(ctx context.Context, accountID string, a const query = `SELECT account_id, onboarding_flow_pending, signup_form_pending, created_at, updated_at FROM account_onboardings WHERE account_id = $1` var onboardingFlowPending, signupFormPending sql.NullBool var createdAt, updatedAt sql.NullTime - err := s.pool.QueryRow(ctx, query, accountID).Scan( + err := s.pgxPool().QueryRow(ctx, query, accountID).Scan( &account.Onboarding.AccountID, &onboardingFlowPending, &signupFormPending, diff --git a/management/server/store/sql_store_agent_network_access_log.go b/management/server/store/sql_store_agent_network_access_log.go index fdaea1bf7..a1ae0150d 100644 --- a/management/server/store/sql_store_agent_network_access_log.go +++ b/management/server/store/sql_store_agent_network_access_log.go @@ -16,7 +16,7 @@ import ( // entry together with its authorising-group child rows in a single // transaction. func (s *SqlStore) CreateAgentNetworkAccessLog(ctx context.Context, entry *agentNetworkTypes.AgentNetworkAccessLog, groups []agentNetworkTypes.AgentNetworkAccessLogGroup) error { - err := s.db.Transaction(func(tx *gorm.DB) error { + err := s.transaction(ctx, func(tx *gorm.DB) error { // Idempotent on the log id / (log_id, group_id) so a proxy resend of the // same entry can't fail the request. if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(entry).Error; err != nil { @@ -46,7 +46,7 @@ func (s *SqlStore) CreateAgentNetworkAccessLog(ctx context.Context, entry *agent // deleted. func (s *SqlStore) DeleteOldAgentNetworkAccessLogs(ctx context.Context, accountID string, olderThan time.Time) (int64, error) { var deleted int64 - err := s.db.Transaction(func(tx *gorm.DB) error { + err := s.transaction(ctx, func(tx *gorm.DB) error { // Remove group child rows for the soon-to-be-deleted logs first. if err := tx.Exec( "DELETE FROM agent_network_access_log_group WHERE account_id = ? AND log_id IN (SELECT id FROM agent_network_access_log WHERE account_id = ? AND timestamp < ?)", diff --git a/management/server/store/sql_store_agent_network_usage.go b/management/server/store/sql_store_agent_network_usage.go index c34b31e0e..dc557d2f4 100644 --- a/management/server/store/sql_store_agent_network_usage.go +++ b/management/server/store/sql_store_agent_network_usage.go @@ -14,7 +14,7 @@ import ( // CreateAgentNetworkUsage persists a stripped agent-network usage record // together with its authorising-group child rows in a single transaction. func (s *SqlStore) CreateAgentNetworkUsage(ctx context.Context, usage *agentNetworkTypes.AgentNetworkUsage, groups []agentNetworkTypes.AgentNetworkUsageGroup) error { - err := s.db.Transaction(func(tx *gorm.DB) error { + err := s.transaction(ctx, func(tx *gorm.DB) error { // Idempotent on the usage id / (usage_id, group_id) so a proxy resend of // the same entry can't fail the request. if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(usage).Error; err != nil { diff --git a/management/server/store/sql_store_agentnetwork.go b/management/server/store/sql_store_agentnetwork.go index 8a92f7147..4fe77d994 100644 --- a/management/server/store/sql_store_agentnetwork.go +++ b/management/server/store/sql_store_agentnetwork.go @@ -619,7 +619,7 @@ func (s *SqlStore) IncrementAgentNetworkConsumptionBatch( } const tbl = "agent_network_consumption" - err := s.db.Transaction(func(tx *gorm.DB) error { + err := s.transaction(ctx, func(tx *gorm.DB) error { for _, k := range keys { if k.DimID == "" || k.WindowSeconds <= 0 { return status.Errorf(status.InvalidArgument, "dim_id and window_seconds must be set") diff --git a/management/server/store/sql_store_group.go b/management/server/store/sql_store_group.go index 16e812048..323a45734 100644 --- a/management/server/store/sql_store_group.go +++ b/management/server/store/sql_store_group.go @@ -37,7 +37,7 @@ func (s *SqlStore) CreateGroups(ctx context.Context, accountID string, groups [] return nil } - return s.db.Transaction(func(tx *gorm.DB) error { + return s.transaction(ctx, func(tx *gorm.DB) error { result := tx. Clauses( clause.OnConflict{ @@ -63,7 +63,7 @@ func (s *SqlStore) UpdateGroups(ctx context.Context, accountID string, groups [] return nil } - return s.db.Transaction(func(tx *gorm.DB) error { + return s.transaction(ctx, func(tx *gorm.DB) error { result := tx. Clauses( clause.OnConflict{ @@ -137,7 +137,7 @@ func (s *SqlStore) GetResourceGroups(ctx context.Context, lockStrength LockingSt func (s *SqlStore) getGroups(ctx context.Context, accountID string) ([]*types.Group, error) { const query = `SELECT id, account_id, public_id, name, issued, resources, integration_ref_id, integration_ref_integration_type FROM groups WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) + rows, err := s.pgxPool().Query(ctx, query, accountID) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_group_peer.go b/management/server/store/sql_store_group_peer.go index 6b0339816..cc4c44ad1 100644 --- a/management/server/store/sql_store_group_peer.go +++ b/management/server/store/sql_store_group_peer.go @@ -19,7 +19,7 @@ func (s *SqlStore) getGroupPeers(ctx context.Context, groupIDs []string) ([]type return nil, nil } const query = `SELECT account_id, group_id, peer_id FROM group_peers WHERE group_id = ANY($1)` - rows, err := s.pool.Query(ctx, query, groupIDs) + rows, err := s.pgxPool().Query(ctx, query, groupIDs) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_idp_migration.go b/management/server/store/sql_store_idp_migration.go index 64962845b..760d2967c 100644 --- a/management/server/store/sql_store_idp_migration.go +++ b/management/server/store/sql_store_idp_migration.go @@ -57,11 +57,11 @@ func (s *SqlStore) ListUsers(ctx context.Context) ([]*types.User, error) { // txDeferFKConstraints defers foreign key constraint checks for the duration of the transaction. // MySQL is already handled by s.transaction (SET FOREIGN_KEY_CHECKS = 0). func (s *SqlStore) txDeferFKConstraints(tx *gorm.DB) error { - if s.storeEngine == types.SqliteStoreEngine { + if s.conn.Engine() == types.SqliteStoreEngine { return tx.Exec("PRAGMA defer_foreign_keys = ON").Error } - if s.storeEngine != types.PostgresStoreEngine { + if s.conn.Engine() != types.PostgresStoreEngine { return nil } @@ -86,7 +86,7 @@ func (s *SqlStore) txDeferFKConstraints(tx *gorm.DB) error { // txRestoreFKConstraints reverts FK constraints back to NOT DEFERRABLE after the // deferred updates are done but before the transaction commits. func (s *SqlStore) txRestoreFKConstraints(tx *gorm.DB) error { - if s.storeEngine != types.PostgresStoreEngine { + if s.conn.Engine() != types.PostgresStoreEngine { return nil } @@ -138,7 +138,7 @@ func (s *SqlStore) UpdateUserID(ctx context.Context, accountID, oldUserID, newUs } log.Info("Updating user ID in the store") - err := s.transaction(func(tx *gorm.DB) error { + err := s.transaction(ctx, func(tx *gorm.DB) error { if err := s.txDeferFKConstraints(tx); err != nil { return err } @@ -161,7 +161,7 @@ func (s *SqlStore) UpdateUserID(ctx context.Context, accountID, oldUserID, newUs } log.Info("Restoring FK constraints") - err = s.transaction(func(tx *gorm.DB) error { + err = s.transaction(ctx, func(tx *gorm.DB) error { if err := s.txRestoreFKConstraints(tx); err != nil { return fmt.Errorf("restore FK constraints: %w", err) } diff --git a/management/server/store/sql_store_name_server_group.go b/management/server/store/sql_store_name_server_group.go index 4af831148..595921913 100644 --- a/management/server/store/sql_store_name_server_group.go +++ b/management/server/store/sql_store_name_server_group.go @@ -17,7 +17,7 @@ import ( func (s *SqlStore) getNameServerGroups(ctx context.Context, accountID string) ([]nbdns.NameServerGroup, error) { const query = `SELECT id, account_id, public_id, name, description, name_servers, groups, "primary", domains, enabled, search_domains_enabled FROM name_server_groups WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) + rows, err := s.pgxPool().Query(ctx, query, accountID) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_network.go b/management/server/store/sql_store_network.go index 963d67e8f..66333a4fb 100644 --- a/management/server/store/sql_store_network.go +++ b/management/server/store/sql_store_network.go @@ -15,7 +15,7 @@ import ( func (s *SqlStore) getNetworks(ctx context.Context, accountID string) ([]*networkTypes.Network, error) { const query = `SELECT id, account_id, public_id, name, description FROM networks WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) + rows, err := s.pgxPool().Query(ctx, query, accountID) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_network_resource.go b/management/server/store/sql_store_network_resource.go index 659382c61..dd4352ea0 100644 --- a/management/server/store/sql_store_network_resource.go +++ b/management/server/store/sql_store_network_resource.go @@ -17,7 +17,7 @@ import ( func (s *SqlStore) getNetworkResources(ctx context.Context, accountID string) ([]*resourceTypes.NetworkResource, error) { const query = `SELECT id, network_id, account_id, public_id, name, description, type, domain, prefix, enabled FROM network_resources WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) + rows, err := s.pgxPool().Query(ctx, query, accountID) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_network_router.go b/management/server/store/sql_store_network_router.go index b2594d483..bb5cb6621 100644 --- a/management/server/store/sql_store_network_router.go +++ b/management/server/store/sql_store_network_router.go @@ -19,7 +19,7 @@ import ( func (s *SqlStore) getNetworkRouters(ctx context.Context, accountID string) ([]*routerTypes.NetworkRouter, error) { const query = `SELECT id, network_id, account_id, public_id, peer, peer_groups, masquerade, metric, enabled FROM network_routers WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) + rows, err := s.pgxPool().Query(ctx, query, accountID) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_peer.go b/management/server/store/sql_store_peer.go index 90c742a72..e5086b6db 100644 --- a/management/server/store/sql_store_peer.go +++ b/management/server/store/sql_store_peer.go @@ -25,7 +25,7 @@ func (s *SqlStore) SavePeer(ctx context.Context, accountID string, peer *nbpeer. peerCopy := peer.Copy() peerCopy.AccountID = accountID - err := s.transaction(func(tx *gorm.DB) error { + err := s.transaction(ctx, func(tx *gorm.DB) error { // check if peer exists before saving var peerID string result := tx.Model(&nbpeer.Peer{}).Select("id").Take(&peerID, accountAndIDQueryCondition, accountID, peer.ID) @@ -190,7 +190,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee peer_status_connected, peer_status_login_expired, peer_status_requires_approval, location_connection_ip, location_country_code, location_city_name, location_geo_name_id, proxy_meta_embedded, proxy_meta_cluster, ipv6, meta_sync_message_version FROM peers WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) + rows, err := s.pgxPool().Query(ctx, query, accountID) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_personal_access_token.go b/management/server/store/sql_store_personal_access_token.go index 351d122fd..d5be3e327 100644 --- a/management/server/store/sql_store_personal_access_token.go +++ b/management/server/store/sql_store_personal_access_token.go @@ -45,7 +45,7 @@ func (s *SqlStore) getPersonalAccessTokens(ctx context.Context, userIDs []string return nil, nil } const query = `SELECT id, user_id, name, hashed_token, expiration_date, created_by, created_at, last_used FROM personal_access_tokens WHERE user_id = ANY($1)` - rows, err := s.pool.Query(ctx, query, userIDs) + rows, err := s.pgxPool().Query(ctx, query, userIDs) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_policy.go b/management/server/store/sql_store_policy.go index 7725dede5..95e80e712 100644 --- a/management/server/store/sql_store_policy.go +++ b/management/server/store/sql_store_policy.go @@ -18,7 +18,7 @@ import ( func (s *SqlStore) getPolicies(ctx context.Context, accountID string) ([]*types.Policy, error) { const query = `SELECT id, account_id, public_id, name, description, enabled, source_posture_checks FROM policies WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) + rows, err := s.pgxPool().Query(ctx, query, accountID) if err != nil { return nil, err } @@ -128,7 +128,7 @@ func (s *SqlStore) SavePolicy(ctx context.Context, policy *types.Policy) error { } func (s *SqlStore) DeletePolicy(ctx context.Context, accountID, policyID string) error { - return s.transaction(func(tx *gorm.DB) error { + return s.transaction(ctx, func(tx *gorm.DB) error { if err := tx.Where("policy_id = ?", policyID).Delete(&types.PolicyRule{}).Error; err != nil { return fmt.Errorf("delete policy rules: %w", err) } diff --git a/management/server/store/sql_store_policy_rule.go b/management/server/store/sql_store_policy_rule.go index d1d6ef3d3..f822788ac 100644 --- a/management/server/store/sql_store_policy_rule.go +++ b/management/server/store/sql_store_policy_rule.go @@ -18,7 +18,7 @@ func (s *SqlStore) getPolicyRules(ctx context.Context, policyIDs []string) ([]*t return nil, nil } const query = `SELECT id, policy_id, name, description, enabled, action, destinations, destination_resource, sources, source_resource, bidirectional, protocol, ports, port_ranges, authorized_groups, authorized_user FROM policy_rules WHERE policy_id = ANY($1)` - rows, err := s.pool.Query(ctx, query, policyIDs) + rows, err := s.pgxPool().Query(ctx, query, policyIDs) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_posture_checks.go b/management/server/store/sql_store_posture_checks.go index 71997ec73..d951086d4 100644 --- a/management/server/store/sql_store_posture_checks.go +++ b/management/server/store/sql_store_posture_checks.go @@ -16,7 +16,7 @@ import ( func (s *SqlStore) getPostureChecks(ctx context.Context, accountID string) ([]*posture.Checks, error) { const query = `SELECT id, account_id, public_id, name, description, checks FROM posture_checks WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) + rows, err := s.pgxPool().Query(ctx, query, accountID) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_route.go b/management/server/store/sql_store_route.go index 0aa0b399a..ec9e130b1 100644 --- a/management/server/store/sql_store_route.go +++ b/management/server/store/sql_store_route.go @@ -17,7 +17,7 @@ import ( func (s *SqlStore) getRoutes(ctx context.Context, accountID string) ([]route.Route, error) { const query = `SELECT id, account_id, public_id, network, domains, keep_route, net_id, description, peer, peer_groups, network_type, masquerade, metric, enabled, groups, access_control_groups, skip_auto_apply FROM routes WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) + rows, err := s.pgxPool().Query(ctx, query, accountID) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_service.go b/management/server/store/sql_store_service.go index 5eedbcb37..4f4f3546d 100644 --- a/management/server/store/sql_store_service.go +++ b/management/server/store/sql_store_service.go @@ -30,7 +30,7 @@ const serviceSelectColumns = `id, account_id, name, domain, enabled, auth, restr func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpservice.Service, error) { const serviceQuery = `SELECT ` + serviceSelectColumns + ` FROM services WHERE account_id = $1` - serviceRows, err := s.pool.Query(ctx, serviceQuery, accountID) + serviceRows, err := s.pgxPool().Query(ctx, serviceQuery, accountID) if err != nil { return nil, err } @@ -205,7 +205,7 @@ func (s *SqlStore) UpdateService(ctx context.Context, service *rpservice.Service targetType := &rpservice.Target{} // Use a transaction to ensure atomic updates of the service and its targets - err := s.db.Transaction(func(tx *gorm.DB) error { + err := s.transaction(ctx, func(tx *gorm.DB) error { // Delete existing targets if err := tx.Where("service_id = ?", serviceCopy.ID).Delete(targetType).Error; err != nil { return err diff --git a/management/server/store/sql_store_service_target.go b/management/server/store/sql_store_service_target.go index c4531a158..5c2b99510 100644 --- a/management/server/store/sql_store_service_target.go +++ b/management/server/store/sql_store_service_target.go @@ -26,7 +26,7 @@ const targetSelectColumns = `id, account_id, service_id, path, host, port, proto func (s *SqlStore) getServiceTargets(ctx context.Context, serviceIDs []string) ([]*rpservice.Target, error) { const targetsQuery = `SELECT ` + targetSelectColumns + ` FROM targets WHERE service_id = ANY($1)` - rows, err := s.pool.Query(ctx, targetsQuery, serviceIDs) + rows, err := s.pgxPool().Query(ctx, targetsQuery, serviceIDs) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_setup_key.go b/management/server/store/sql_store_setup_key.go index 79fb406ad..3857f6e8c 100644 --- a/management/server/store/sql_store_setup_key.go +++ b/management/server/store/sql_store_setup_key.go @@ -37,7 +37,7 @@ func (s *SqlStore) GetAccountBySetupKey(ctx context.Context, setupKey string) (* func (s *SqlStore) getSetupKeys(ctx context.Context, accountID string) ([]types.SetupKey, error) { const query = `SELECT id, account_id, key, key_secret, name, type, created_at, expires_at, updated_at, revoked, used_times, last_used, auto_groups, usage_limit, ephemeral, allow_extra_dns_labels FROM setup_keys WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) + rows, err := s.pgxPool().Query(ctx, query, accountID) if err != nil { return nil, err } diff --git a/management/server/store/sql_store_test.go b/management/server/store/sql_store_test.go index 731b90ce9..f695a150d 100644 --- a/management/server/store/sql_store_test.go +++ b/management/server/store/sql_store_test.go @@ -15,6 +15,8 @@ import ( log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gorm.io/gorm" + "gorm.io/gorm/clause" nbdns "github.com/netbirdio/netbird/dns" nbpeer "github.com/netbirdio/netbird/management/server/peer" @@ -346,7 +348,6 @@ func TestSqlStore_ExecuteInTransaction_Timeout(t *testing.T) { sqlStore, ok := store.(*SqlStore) require.True(t, ok) - assert.Equal(t, 1*time.Second, sqlStore.transactionTimeout) ctx := context.Background() err = sqlStore.ExecuteInTransaction(ctx, func(transaction Store) error { @@ -411,3 +412,88 @@ func TestNewSqliteStore_BusyTimeoutRespectsUserOverride(t *testing.T) { }) } } + +func TestSqlStore_ExecuteInTransaction_RestoresForeignKeyChecksOnMysql(t *testing.T) { + runTestForAllEngines(t, "", func(t *testing.T, store Store) { + sqlStore := store.(*SqlStore) + if sqlStore.conn.Engine() != types.MysqlStoreEngine { + t.Skip("FOREIGN_KEY_CHECKS is MySQL specific") + } + sqlDB, err := sqlStore.GetDB().DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + ctx := context.Background() + + foreignKeyChecks := func() int { + var enabled int + require.NoError(t, sqlStore.GetDB().Raw("SELECT @@SESSION.foreign_key_checks").Scan(&enabled).Error) + return enabled + } + + err = store.ExecuteInTransaction(ctx, func(Store) error { return assert.AnError }) + require.ErrorIs(t, err, assert.AnError) + assert.Equal(t, 1, foreignKeyChecks()) + + require.Panics(t, func() { + _ = store.ExecuteInTransaction(ctx, func(Store) error { panic("boom") }) + }) + assert.Equal(t, 1, foreignKeyChecks()) + + err = sqlStore.transaction(ctx, func(*gorm.DB) error { return assert.AnError }) + require.ErrorIs(t, err, assert.AnError) + assert.Equal(t, 1, foreignKeyChecks()) + + err = store.ExecuteInTransaction(ctx, func(transaction Store) error { + bound := transaction.(*SqlStore) + require.NoError(t, bound.transaction(ctx, func(*gorm.DB) error { return nil })) + var enabled int + require.NoError(t, bound.GetDB().Raw("SELECT @@SESSION.foreign_key_checks").Scan(&enabled).Error) + assert.Equal(t, 0, enabled, "a savepoint must not re-enable FK checks for the rest of the transaction") + return nil + }) + require.NoError(t, err) + assert.Equal(t, 1, foreignKeyChecks()) + }) +} + +func TestSqlStore_Transaction_RollsBackOnError(t *testing.T) { + runTestForAllEngines(t, "../testdata/extended-store.sql", func(t *testing.T, store Store) { + ctx := context.Background() + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + group := &types.Group{ID: "rolled-back-group", AccountID: accountID, Name: "rolled back", Issued: "api"} + + err := store.(*SqlStore).transaction(ctx, func(tx *gorm.DB) error { + require.NoError(t, tx.Omit(clause.Associations).Create(group).Error) + return assert.AnError + }) + require.ErrorIs(t, err, assert.AnError) + + _, err = store.GetGroupByID(ctx, LockingStrengthNone, accountID, group.ID) + require.Error(t, err) + }) +} + +func TestSqlStore_Transaction_NestedIsSavepoint(t *testing.T) { + runTestForAllEngines(t, "../testdata/extended-store.sql", func(t *testing.T, store Store) { + ctx := context.Background() + accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b" + outer := &types.Group{ID: "outer-group", AccountID: accountID, Name: "outer", Issued: "api"} + inner := &types.Group{ID: "inner-group", AccountID: accountID, Name: "inner", Issued: "api"} + + err := store.ExecuteInTransaction(ctx, func(transaction Store) error { + require.NoError(t, transaction.CreateGroup(ctx, outer)) + err := transaction.(*SqlStore).transaction(ctx, func(tx *gorm.DB) error { + require.NoError(t, tx.Omit(clause.Associations).Create(inner).Error) + return assert.AnError + }) + require.ErrorIs(t, err, assert.AnError) + return nil + }) + require.NoError(t, err) + + _, err = store.GetGroupByID(ctx, LockingStrengthNone, accountID, outer.ID) + require.NoError(t, err) + _, err = store.GetGroupByID(ctx, LockingStrengthNone, accountID, inner.ID) + require.Error(t, err) + }) +} diff --git a/management/server/store/sql_store_user.go b/management/server/store/sql_store_user.go index 2ead2156a..4db03fb71 100644 --- a/management/server/store/sql_store_user.go +++ b/management/server/store/sql_store_user.go @@ -108,7 +108,7 @@ func (s *SqlStore) GetUserByUserID(ctx context.Context, lockStrength LockingStre } func (s *SqlStore) DeleteUser(ctx context.Context, accountID, userID string) error { - err := s.transaction(func(tx *gorm.DB) error { + err := s.transaction(ctx, func(tx *gorm.DB) error { result := tx.Delete(&types.PersonalAccessToken{}, "user_id = ?", userID) if result.Error != nil { return result.Error @@ -173,7 +173,7 @@ func (s *SqlStore) GetAccountOwner(ctx context.Context, lockStrength LockingStre func (s *SqlStore) getUsers(ctx context.Context, accountID string) ([]types.User, error) { const query = `SELECT id, account_id, role, is_service_user, non_deletable, service_user_name, auto_groups, blocked, pending_approval, last_login, created_at, issued, integration_ref_id, integration_ref_integration_type, email, name FROM users WHERE account_id = $1` - rows, err := s.pool.Query(ctx, query, accountID) + rows, err := s.pgxPool().Query(ctx, query, accountID) if err != nil { return nil, err } diff --git a/management/server/store/sqlstore_bench_test.go b/management/server/store/sqlstore_bench_test.go index a38b4a8c1..b1933b0d3 100644 --- a/management/server/store/sqlstore_bench_test.go +++ b/management/server/store/sqlstore_bench_test.go @@ -22,6 +22,7 @@ import ( nbdns "github.com/netbirdio/netbird/dns" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + nbdb "github.com/netbirdio/netbird/management/internals/shared/db" 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" @@ -281,10 +282,11 @@ func setupBenchmarkDB(b testing.TB) (*SqlStore, func(), string) { b.Fatalf("failed to migrate database: %v", err) } - store := &SqlStore{ - db: db, - pool: pool, + conn, err := nbdb.NewConn(context.Background(), db, nbdb.PostgresStoreEngine, pool) + if err != nil { + b.Fatalf("failed to create connection: %v", err) } + store := &SqlStore{conn: conn, db: conn.DB(nil)} const ( accountID = "benchmark-account-id" @@ -527,7 +529,7 @@ func BenchmarkGetAccount(b *testing.B) { } } }) - store.pool.Close() + _ = store.Close(ctx) } func TestAccountEquivalence(t *testing.T) { diff --git a/management/server/store/store.go b/management/server/store/store.go index ed53690a5..e935d2d88 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -27,12 +27,12 @@ import ( "gorm.io/gorm" "github.com/netbirdio/netbird/dns" - "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" rpservice "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" + "github.com/netbirdio/netbird/management/internals/shared/db" "github.com/netbirdio/netbird/management/server/telemetry" "github.com/netbirdio/netbird/management/server/testutil" "github.com/netbirdio/netbird/management/server/types" @@ -50,14 +50,14 @@ import ( "github.com/netbirdio/netbird/route" ) -type LockingStrength string +type LockingStrength = db.LockingStrength const ( - LockingStrengthUpdate LockingStrength = "UPDATE" // Strongest lock, preventing any changes by other transactions until your transaction completes. - LockingStrengthShare LockingStrength = "SHARE" // Allows reading but prevents changes by other transactions. - LockingStrengthNoKeyUpdate LockingStrength = "NO KEY UPDATE" // Similar to UPDATE but allows changes to related rows. - LockingStrengthKeyShare LockingStrength = "KEY SHARE" // Protects against changes to primary/unique keys but allows other updates. - LockingStrengthNone LockingStrength = "NONE" // No locking, allowing all transactions to proceed without restrictions. + LockingStrengthUpdate = db.LockingStrengthUpdate + LockingStrengthShare = db.LockingStrengthShare + LockingStrengthNoKeyUpdate = db.LockingStrengthNoKeyUpdate + LockingStrengthKeyShare = db.LockingStrengthKeyShare + LockingStrengthNone = db.LockingStrengthNone ) type Store interface { @@ -316,9 +316,6 @@ type Store interface { DeleteExpiredCustomDomain(ctx context.Context, d *domain.Domain, now time.Time) (bool, error) DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error - CreateAccessLog(ctx context.Context, log *accesslogs.AccessLogEntry) error - GetAccountAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) - DeleteOldAccessLogs(ctx context.Context, olderThan time.Time) (int64, error) CreateAgentNetworkAccessLog(ctx context.Context, entry *agentNetworkTypes.AgentNetworkAccessLog, groups []agentNetworkTypes.AgentNetworkAccessLogGroup) error CreateAgentNetworkUsage(ctx context.Context, usage *agentNetworkTypes.AgentNetworkUsage, groups []agentNetworkTypes.AgentNetworkUsageGroup) error GetAgentNetworkAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLog, int64, error) @@ -492,7 +489,7 @@ func getStoreEngine(ctx context.Context, dataDir string, kind types.Engine) type // Migrate if it is the first run with a JSON file existing and no SQLite file present jsonStoreFile := filepath.Join(dataDir, storeFileName) - sqliteStoreFile := filepath.Join(dataDir, storeSqliteFileName) + sqliteStoreFile := filepath.Join(dataDir, db.SqliteFileName) if util.FileExists(jsonStoreFile) && !util.FileExists(sqliteStoreFile) { log.WithContext(ctx).Warnf("unsupported store engine specified, but found %s. Automatically migrating to SQLite.", jsonStoreFile) @@ -511,6 +508,16 @@ func getStoreEngine(ctx context.Context, dataDir string, kind types.Engine) type // NewStore creates a new store based on the provided engine type, data directory, and telemetry metrics func NewStore(ctx context.Context, kind types.Engine, dataDir string, metrics telemetry.AppMetrics, skipMigration bool) (Store, error) { + conn, err := OpenConn(ctx, kind, dataDir) + if err != nil { + return nil, err + } + return newStore(ctx, conn, metrics, skipMigration) +} + +// OpenConn resolves the configured engine and opens the connection that the +// store and the domain repositories share. +func OpenConn(ctx context.Context, kind types.Engine, dataDir string) (*db.Conn, error) { kind = getStoreEngine(ctx, dataDir, kind) if err := checkFileStoreEngine(kind, dataDir); err != nil { @@ -520,13 +527,21 @@ func NewStore(ctx context.Context, kind types.Engine, dataDir string, metrics te switch kind { case types.SqliteStoreEngine: log.WithContext(ctx).Info("using SQLite store engine") - return NewSqliteStore(ctx, dataDir, metrics, skipMigration) + return db.OpenSqlite(ctx, dataDir) case types.PostgresStoreEngine: log.WithContext(ctx).Info("using Postgres store engine") - return newPostgresStore(ctx, metrics, skipMigration) + dsn, ok := lookupDSNEnv(PostgresDsnEnv, PostgresDsnEnvLegacy) + if !ok { + return nil, fmt.Errorf("%s is not set", PostgresDsnEnv) + } + return db.OpenPostgres(ctx, dsn, db.DefaultPoolConfig) case types.MysqlStoreEngine: log.WithContext(ctx).Info("using MySQL store engine") - return newMysqlStore(ctx, metrics, skipMigration) + dsn, ok := lookupDSNEnv(mysqlDsnEnv, mysqlDsnEnvLegacy) + if !ok { + return nil, fmt.Errorf("%s is not set", mysqlDsnEnv) + } + return db.OpenMysql(ctx, dsn) default: return nil, fmt.Errorf("unsupported kind of store: %s", kind) } @@ -700,29 +715,34 @@ func NewTestStoreFromSQL(ctx context.Context, filename string, dataDir string) ( kind = types.SqliteStoreEngine } - storeStr := fmt.Sprintf("%s?cache=shared", storeSqliteFileName) + storeStr := fmt.Sprintf("%s?cache=shared", db.SqliteFileName) if runtime.GOOS == "windows" { // Vo avoid `The process cannot access the file because it is being used by another process` on Windows - storeStr = storeSqliteFileName + storeStr = db.SqliteFileName } file := filepath.Join(dataDir, storeStr) - db, err := gorm.Open(sqlite.Open(file), getGormConfig()) + gormDB, err := gorm.Open(sqlite.Open(file), db.GormConfig()) if err != nil { return nil, nil, err } if filename != "" { - err = LoadSQL(db, filename) + err = LoadSQL(gormDB, filename) if err != nil { return nil, nil, fmt.Errorf("failed to load SQL file: %v", err) } } - store, err := NewSqlStore(ctx, db, types.SqliteStoreEngine, nil, false) + conn, err := db.NewConn(ctx, gormDB, db.SqliteStoreEngine, nil) if err != nil { return nil, nil, fmt.Errorf("failed to create test store: %v", err) } + store, err := NewSqlStore(ctx, conn, nil, false) + if err != nil { + _ = conn.Close() + return nil, nil, fmt.Errorf("failed to create test store: %v", err) + } err = addAllGroupToAccount(ctx, store) if err != nil { @@ -790,9 +810,6 @@ func getSqlStoreEngine(ctx context.Context, sqliteStore *SqlStore, kind types.En closeConnection := func() { cleanup() store.Close(ctx) - if store.pool != nil { - store.pool.Close() - } if store != sqliteStore { // The sqlite store only seeded the engine under test; without this // every test leaks its connection and the opener goroutines. @@ -949,9 +966,6 @@ func postgresSchemaTemplate(ctx context.Context, baseDSN string, admin *gorm.DB) // TEMPLATE refuses a source that still has sessions, so release both handles // before the first clone. tplStore.Close(ctx) - if tplStore.pool != nil { - tplStore.pool.Close() - } schemaTemplates[key] = &schemaTemplate{dbName: name} return name, nil @@ -1036,11 +1050,11 @@ func mysqlTableNames(ctx context.Context, sqlDB *sql.DB, dbName string) ([]strin // cloneMysqlSchema replays the template's CREATE TABLE statements into the // database the DSN points at. func cloneMysqlSchema(ctx context.Context, dsn string, tableDDL []string) error { - db, err := gorm.Open(mysql.Open(dsn+"?charset=utf8&parseTime=True&loc=Local"), getGormConfig()) + gormDB, err := gorm.Open(mysql.Open(db.MysqlDSN(dsn)), db.GormConfig()) if err != nil { return fmt.Errorf("connect to test database: %w", err) } - sqlDB, err := db.DB() + sqlDB, err := gormDB.DB() if err != nil { return err } @@ -1080,19 +1094,19 @@ func closeGormDB(db *gorm.DB) { } func openDBWithRetry(dsn string, engine types.Engine, maxRetries int) (*gorm.DB, error) { - var db *gorm.DB + var gormDB *gorm.DB var err error for i := range maxRetries { switch engine { case types.PostgresStoreEngine: - db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) + gormDB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{}) case types.MysqlStoreEngine: - db, err = gorm.Open(mysql.Open(dsn+"?charset=utf8&parseTime=True&loc=Local"), &gorm.Config{}) + gormDB, err = gorm.Open(mysql.Open(db.MysqlDSN(dsn)), &gorm.Config{}) } if err == nil { - return db, nil + return gormDB, nil } if i < maxRetries-1 { @@ -1106,14 +1120,14 @@ func openDBWithRetry(dsn string, engine types.Engine, maxRetries int) (*gorm.DB, // createRandomDB creates a uniquely named database for one test. On postgres a // non-empty template is copied server-side with CREATE DATABASE ... TEMPLATE. -func createRandomDB(dsn string, db *gorm.DB, engine types.Engine, template string) (string, func(), error) { +func createRandomDB(dsn string, admin *gorm.DB, engine types.Engine, template string) (string, func(), error) { dbName := newTestDBName("test_db") createStmt := fmt.Sprintf("CREATE DATABASE %s", dbName) if template != "" && engine == types.PostgresStoreEngine { createStmt = fmt.Sprintf("CREATE DATABASE %s TEMPLATE %s", dbName, template) } - if err := execWithTemplateRetry(db, createStmt); err != nil { + if err := execWithTemplateRetry(admin, createStmt); err != nil { return "", nil, fmt.Errorf("failed to create database: %v", err) } @@ -1148,7 +1162,7 @@ func createRandomDB(dsn string, db *gorm.DB, engine types.Engine, template strin err = dropDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s WITH (FORCE)", dbName)).Error case types.MysqlStoreEngine: - dropDB, err = gorm.Open(mysql.Open(originalDSN+"?charset=utf8&parseTime=True&loc=Local"), &gorm.Config{ + dropDB, err = gorm.Open(mysql.Open(db.MysqlDSN(originalDSN)), &gorm.Config{ SkipDefaultTransaction: true, PrepareStmt: false, }) @@ -1226,7 +1240,7 @@ func MigrateFileStoreToSqlite(ctx context.Context, dataDir string) error { return fmt.Errorf("%s doesn't exist, couldn't continue the operation", fileStorePath) } - sqlStorePath := path.Join(dataDir, storeSqliteFileName) + sqlStorePath := path.Join(dataDir, db.SqliteFileName) if _, err := os.Stat(sqlStorePath); err == nil { return fmt.Errorf("%s already exists, couldn't continue the operation", sqlStorePath) } diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index 2db74fb86..068ac6ff2 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -18,7 +18,6 @@ import ( dns "github.com/netbirdio/netbird/dns" types "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" - accesslogs "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" domain "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" proxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" service "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" @@ -247,20 +246,6 @@ func (mr *MockStoreMockRecorder) CountProxiesByAccountID(ctx, accountID any) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountProxiesByAccountID", reflect.TypeOf((*MockStore)(nil).CountProxiesByAccountID), ctx, accountID) } -// CreateAccessLog mocks base method. -func (m *MockStore) CreateAccessLog(ctx context.Context, log *accesslogs.AccessLogEntry) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateAccessLog", ctx, log) - ret0, _ := ret[0].(error) - return ret0 -} - -// CreateAccessLog indicates an expected call of CreateAccessLog. -func (mr *MockStoreMockRecorder) CreateAccessLog(ctx, log any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAccessLog", reflect.TypeOf((*MockStore)(nil).CreateAccessLog), ctx, log) -} - // CreateAgentNetworkAccessLog mocks base method. func (m *MockStore) CreateAgentNetworkAccessLog(ctx context.Context, entry *types.AgentNetworkAccessLog, groups []types.AgentNetworkAccessLogGroup) error { m.ctrl.T.Helper() @@ -669,21 +654,6 @@ func (mr *MockStoreMockRecorder) DeleteNetworkRouter(ctx, accountID, routerID an return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNetworkRouter", reflect.TypeOf((*MockStore)(nil).DeleteNetworkRouter), ctx, accountID, routerID) } -// DeleteOldAccessLogs mocks base method. -func (m *MockStore) DeleteOldAccessLogs(ctx context.Context, olderThan time.Time) (int64, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteOldAccessLogs", ctx, olderThan) - ret0, _ := ret[0].(int64) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// DeleteOldAccessLogs indicates an expected call of DeleteOldAccessLogs. -func (mr *MockStoreMockRecorder) DeleteOldAccessLogs(ctx, olderThan any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOldAccessLogs", reflect.TypeOf((*MockStore)(nil).DeleteOldAccessLogs), ctx, olderThan) -} - // DeleteOldAgentNetworkAccessLogs mocks base method. func (m *MockStore) DeleteOldAgentNetworkAccessLogs(ctx context.Context, accountID string, olderThan time.Time) (int64, error) { m.ctrl.T.Helper() @@ -968,22 +938,6 @@ func (mr *MockStoreMockRecorder) GetAccount(ctx, accountID any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccount", reflect.TypeOf((*MockStore)(nil).GetAccount), ctx, accountID) } -// GetAccountAccessLogs mocks base method. -func (m *MockStore) GetAccountAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAccountAccessLogs", ctx, lockStrength, accountID, filter) - ret0, _ := ret[0].([]*accesslogs.AccessLogEntry) - ret1, _ := ret[1].(int64) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// GetAccountAccessLogs indicates an expected call of GetAccountAccessLogs. -func (mr *MockStoreMockRecorder) GetAccountAccessLogs(ctx, lockStrength, accountID, filter any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountAccessLogs", reflect.TypeOf((*MockStore)(nil).GetAccountAccessLogs), ctx, lockStrength, accountID, filter) -} - // GetAccountAgentNetworkBudgetRules mocks base method. func (m *MockStore) GetAccountAgentNetworkBudgetRules(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*types.AccountBudgetRule, error) { m.ctrl.T.Helper() @@ -2177,7 +2131,7 @@ func (m *MockStore) GetNetworkResourceByIDOrPublicID(ctx context.Context, lockSt } // GetNetworkResourceByIDOrPublicID indicates an expected call of GetNetworkResourceByIDOrPublicID. -func (mr *MockStoreMockRecorder) GetNetworkResourceByIDOrPublicID(ctx, lockStrength, accountID, resourceID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetNetworkResourceByIDOrPublicID(ctx, lockStrength, accountID, resourceID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNetworkResourceByIDOrPublicID", reflect.TypeOf((*MockStore)(nil).GetNetworkResourceByIDOrPublicID), ctx, lockStrength, accountID, resourceID) } @@ -2522,7 +2476,7 @@ func (m *MockStore) GetPolicyByIDOrPublicID(ctx context.Context, lockStrength Lo } // GetPolicyByIDOrPublicID indicates an expected call of GetPolicyByIDOrPublicID. -func (mr *MockStoreMockRecorder) GetPolicyByIDOrPublicID(ctx, lockStrength, accountID, policyID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetPolicyByIDOrPublicID(ctx, lockStrength, accountID, policyID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPolicyByIDOrPublicID", reflect.TypeOf((*MockStore)(nil).GetPolicyByIDOrPublicID), ctx, lockStrength, accountID, policyID) } @@ -2717,7 +2671,7 @@ func (m *MockStore) GetRouteByIDOrPublicID(ctx context.Context, lockStrength Loc } // GetRouteByIDOrPublicID indicates an expected call of GetRouteByIDOrPublicID. -func (mr *MockStoreMockRecorder) GetRouteByIDOrPublicID(ctx, lockStrength, accountID, routeID interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) GetRouteByIDOrPublicID(ctx, lockStrength, accountID, routeID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRouteByIDOrPublicID", reflect.TypeOf((*MockStore)(nil).GetRouteByIDOrPublicID), ctx, lockStrength, accountID, routeID) } diff --git a/management/server/types/store.go b/management/server/types/store.go index 2ca4383b2..a13d52f91 100644 --- a/management/server/types/store.go +++ b/management/server/types/store.go @@ -1,10 +1,12 @@ package types -type Engine string +import "github.com/netbirdio/netbird/management/internals/shared/db" + +type Engine = db.Engine const ( - PostgresStoreEngine Engine = "postgres" + PostgresStoreEngine = db.PostgresStoreEngine FileStoreEngine Engine = "jsonfile" - SqliteStoreEngine Engine = "sqlite" - MysqlStoreEngine Engine = "mysql" + SqliteStoreEngine = db.SqliteStoreEngine + MysqlStoreEngine = db.MysqlStoreEngine ) From e830903a94da4662da2e9d4b9291651677bc4042 Mon Sep 17 00:00:00 2001 From: Brad Ison Date: Tue, 29 Sep 2026 09:28:30 +0200 Subject: [PATCH 27/33] [management] Add a revocation guard hook to the proxy token API (#7732) Let an embedding binary refuse DELETE /api/reverse-proxies/proxy-tokens/{id} through an optional proxytoken.RevocationGuard passed to NewAPIHandler. It is checked after the ownership check, so another account's token still returns 404 without reaching the guard, and before the token is revoked. A status error from the guard is written with util.WriteError; any other error becomes a generic 500. Nothing installs a guard here, so OSS behavior is unchanged. --- .../reverseproxy/proxytoken/handler.go | 28 +++- .../reverseproxy/proxytoken/handler_test.go | 151 ++++++++++++++++++ management/internals/server/boot.go | 2 +- management/server/http/handler.go | 4 +- .../testing/testing_tools/channel/channel.go | 4 +- 5 files changed, 182 insertions(+), 7 deletions(-) diff --git a/management/internals/modules/reverseproxy/proxytoken/handler.go b/management/internals/modules/reverseproxy/proxytoken/handler.go index ed098a6dd..d8578db4c 100644 --- a/management/internals/modules/reverseproxy/proxytoken/handler.go +++ b/management/internals/modules/reverseproxy/proxytoken/handler.go @@ -1,6 +1,7 @@ package proxytoken import ( + "context" "encoding/json" "net/http" "time" @@ -18,13 +19,29 @@ import ( "github.com/netbirdio/netbird/shared/management/status" ) +// RevocationGuard vetoes the tenant-facing revocation of a proxy access +// token. Implementations are supplied by integrations; none is installed by +// default, so every token the caller's account owns may be revoked. It is +// consulted after the ownership check and before the token is revoked. A +// returned status error is written with util.WriteError: its type selects the +// HTTP status and its message is shown to the caller, so it must not carry +// internal detail. Any other error is reported as a generic internal error. +type RevocationGuard interface { + CheckProxyAccessTokenRevocation(ctx context.Context, token *types.ProxyAccessToken) error +} + type handler struct { store store.Store permissionsManager permissions.Manager + // revocationGuard vetoes revocations. Optional — when nil every owned + // token may be revoked. + revocationGuard RevocationGuard } -func RegisterEndpoints(s store.Store, permissionsManager permissions.Manager, router *mux.Router) { - h := &handler{store: s, permissionsManager: permissionsManager} +// RegisterEndpoints registers the proxy token endpoints. revocationGuard is +// optional; pass nil for no revocation policy. +func RegisterEndpoints(s store.Store, permissionsManager permissions.Manager, revocationGuard RevocationGuard, router *mux.Router) { + h := &handler{store: s, permissionsManager: permissionsManager, revocationGuard: revocationGuard} router.HandleFunc("/reverse-proxies/proxy-tokens", h.listTokens).Methods("GET", "OPTIONS") router.HandleFunc("/reverse-proxies/proxy-tokens", h.createToken).Methods("POST", "OPTIONS") router.HandleFunc("/reverse-proxies/proxy-tokens/{tokenId}", h.revokeToken).Methods("DELETE", "OPTIONS") @@ -154,6 +171,13 @@ func (h *handler) revokeToken(w http.ResponseWriter, r *http.Request) { return } + if h.revocationGuard != nil { + if err := h.revocationGuard.CheckProxyAccessTokenRevocation(ctx, token); err != nil { + util.WriteError(ctx, err, w) + return + } + } + if err := h.store.RevokeProxyAccessToken(ctx, tokenID); err != nil { util.WriteErrorResponse("failed to revoke token", http.StatusInternalServerError, w) return diff --git a/management/internals/modules/reverseproxy/proxytoken/handler_test.go b/management/internals/modules/reverseproxy/proxytoken/handler_test.go index c71fe59f6..da37261c4 100644 --- a/management/internals/modules/reverseproxy/proxytoken/handler_test.go +++ b/management/internals/modules/reverseproxy/proxytoken/handler_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -22,6 +23,7 @@ import ( "github.com/netbirdio/netbird/management/server/types" "github.com/netbirdio/netbird/shared/auth" "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/status" ) func authContext(accountID, userID string) context.Context { @@ -273,3 +275,152 @@ func TestRevokeToken_ManagementWideToken(t *testing.T) { h.revokeToken(w, req) assert.Equal(t, http.StatusNotFound, w.Code) } + +type revocationGuardFunc func(ctx context.Context, token *types.ProxyAccessToken) error + +func (f revocationGuardFunc) CheckProxyAccessTokenRevocation(ctx context.Context, token *types.ProxyAccessToken) error { + return f(ctx, token) +} + +func TestRevokeToken_GuardRefuses(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + accountID := "acc-123" + + // No RevokeProxyAccessToken expectation: a refused revocation must not + // reach the store. + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT().GetProxyAccessTokenByID(gomock.Any(), store.LockingStrengthNone, "tok-1").Return(&types.ProxyAccessToken{ + ID: "tok-1", + AccountID: &accountID, + }, nil) + + permsMgr := permissions.NewMockManager(ctrl) + permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Delete).Return(true, context.Background(), nil) + + var checked *types.ProxyAccessToken + h := &handler{ + store: mockStore, + permissionsManager: permsMgr, + revocationGuard: revocationGuardFunc(func(_ context.Context, token *types.ProxyAccessToken) error { + checked = token + return status.Errorf(status.PreconditionFailed, "token is in use") + }), + } + + req := httptest.NewRequest("DELETE", "/reverse-proxies/proxy-tokens/tok-1", nil) + req = req.WithContext(authContext(accountID, "user-1")) + req = mux.SetURLVars(req, map[string]string{"tokenId": "tok-1"}) + w := httptest.NewRecorder() + + h.revokeToken(w, req) + assert.Equal(t, http.StatusPreconditionFailed, w.Code) + assert.Contains(t, w.Body.String(), "token is in use") + require.NotNil(t, checked) + assert.Equal(t, "tok-1", checked.ID) +} + +func TestRevokeToken_GuardAllows(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + accountID := "acc-123" + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT().GetProxyAccessTokenByID(gomock.Any(), store.LockingStrengthNone, "tok-1").Return(&types.ProxyAccessToken{ + ID: "tok-1", + AccountID: &accountID, + }, nil) + mockStore.EXPECT().RevokeProxyAccessToken(gomock.Any(), "tok-1").Return(nil) + + permsMgr := permissions.NewMockManager(ctrl) + permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Delete).Return(true, context.Background(), nil) + + h := &handler{ + store: mockStore, + permissionsManager: permsMgr, + revocationGuard: revocationGuardFunc(func(context.Context, *types.ProxyAccessToken) error { + return nil + }), + } + + req := httptest.NewRequest("DELETE", "/reverse-proxies/proxy-tokens/tok-1", nil) + req = req.WithContext(authContext(accountID, "user-1")) + req = mux.SetURLVars(req, map[string]string{"tokenId": "tok-1"}) + w := httptest.NewRecorder() + + h.revokeToken(w, req) + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestRevokeToken_GuardFailure(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + accountID := "acc-123" + + // No RevokeProxyAccessToken expectation: a guard that cannot decide must + // not let the revocation through. + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT().GetProxyAccessTokenByID(gomock.Any(), store.LockingStrengthNone, "tok-1").Return(&types.ProxyAccessToken{ + ID: "tok-1", + AccountID: &accountID, + }, nil) + + permsMgr := permissions.NewMockManager(ctrl) + permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), accountID, "user-1", modules.Services, operations.Delete).Return(true, context.Background(), nil) + + h := &handler{ + store: mockStore, + permissionsManager: permsMgr, + revocationGuard: revocationGuardFunc(func(context.Context, *types.ProxyAccessToken) error { + return errors.New("connection refused") + }), + } + + req := httptest.NewRequest("DELETE", "/reverse-proxies/proxy-tokens/tok-1", nil) + req = req.WithContext(authContext(accountID, "user-1")) + req = mux.SetURLVars(req, map[string]string{"tokenId": "tok-1"}) + w := httptest.NewRecorder() + + h.revokeToken(w, req) + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "internal server error") + assert.NotContains(t, w.Body.String(), "connection refused") +} + +func TestRevokeToken_GuardNotConsultedForForeignToken(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + otherAccount := "acc-other" + + mockStore := store.NewMockStore(ctrl) + mockStore.EXPECT().GetProxyAccessTokenByID(gomock.Any(), store.LockingStrengthNone, "tok-1").Return(&types.ProxyAccessToken{ + ID: "tok-1", + AccountID: &otherAccount, + }, nil) + + permsMgr := permissions.NewMockManager(ctrl) + permsMgr.EXPECT().ValidateUserPermissions(gomock.Any(), "acc-123", "user-1", modules.Services, operations.Delete).Return(true, context.Background(), nil) + + // A foreign token must read as not found, not reveal through the guard's + // answer that it belongs to some account's managed proxy. + h := &handler{ + store: mockStore, + permissionsManager: permsMgr, + revocationGuard: revocationGuardFunc(func(context.Context, *types.ProxyAccessToken) error { + t.Fatal("guard consulted for a token the caller does not own") + return nil + }), + } + + req := httptest.NewRequest("DELETE", "/reverse-proxies/proxy-tokens/tok-1", nil) + req = req.WithContext(authContext("acc-123", "user-1")) + req = mux.SetURLVars(req, map[string]string{"tokenId": "tok-1"}) + w := httptest.NewRecorder() + + h.revokeToken(w, req) + assert.Equal(t, http.StatusNotFound, w.Code) +} diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index 9eb8b7748..0f136ce82 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -159,7 +159,7 @@ func (s *BaseServer) EventStore() activity.Store { func (s *BaseServer) APIHandler() http.Handler { return Create(s, func() http.Handler { - httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.Router(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.AuthManager(), s.Metrics(), s.PermissionsManager(), s.SettingsManager(), s.ZonesManager(), s.RecordsManager(), s.NetworkMapController(), s.IdpManager(), s.ServiceManager(), s.ReverseProxyDomainManager(), s.AccessLogsManager(), s.ReverseProxyGRPCServer(), s.Config.ReverseProxy.TrustedHTTPProxies, s.RateLimiter(), s.IsValidChildAccount, s.AgentNetworkManager()) + httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.Router(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.AuthManager(), s.Metrics(), s.PermissionsManager(), s.SettingsManager(), s.ZonesManager(), s.RecordsManager(), s.NetworkMapController(), s.IdpManager(), s.ServiceManager(), s.ReverseProxyDomainManager(), s.AccessLogsManager(), s.ReverseProxyGRPCServer(), s.Config.ReverseProxy.TrustedHTTPProxies, s.RateLimiter(), s.IsValidChildAccount, s.AgentNetworkManager(), nil) if err != nil { log.Fatalf("failed to create API handler: %v", err) } diff --git a/management/server/http/handler.go b/management/server/http/handler.go index cd365ec13..8ebfd6c64 100644 --- a/management/server/http/handler.go +++ b/management/server/http/handler.go @@ -62,7 +62,7 @@ import ( ) // NewAPIHandler creates the Management service HTTP API handler registering all the available endpoints. -func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, permissionsManager permissions.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, serviceManager service.Manager, reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, trustedHTTPProxies []netip.Prefix, rateLimiter *ratelimit.APIRateLimiter, isValidChildAccount middleware.IsValidChildAccountFunc, agentNetworkManager agentnetwork.Manager) (http.Handler, error) { +func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, permissionsManager permissions.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, serviceManager service.Manager, reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, trustedHTTPProxies []netip.Prefix, rateLimiter *ratelimit.APIRateLimiter, isValidChildAccount middleware.IsValidChildAccountFunc, agentNetworkManager agentnetwork.Manager, proxyTokenRevocationGuard proxytoken.RevocationGuard) (http.Handler, error) { // Register bypass paths for unauthenticated endpoints if err := bypass.AddBypassPath("/api/instance"); err != nil { @@ -136,7 +136,7 @@ func NewAPIHandler(ctx context.Context, router *mux.Router, accountManager accou reverseproxymanager.RegisterEndpoints(serviceManager, *reverseProxyDomainManager, reverseProxyAccessLogsManager, permissionsManager, router) } - proxytoken.RegisterEndpoints(accountManager.GetStore(), permissionsManager, router) + proxytoken.RegisterEndpoints(accountManager.GetStore(), permissionsManager, proxyTokenRevocationGuard, router) // Register OAuth callback handler for proxy authentication if proxyGRPCServer != nil { diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go index 6ea26e634..598adafcc 100644 --- a/management/server/http/testing/testing_tools/channel/channel.go +++ b/management/server/http/testing/testing_tools/channel/channel.go @@ -147,7 +147,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager) apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter() - apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil) + apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil, nil) if err != nil { t.Fatalf("Failed to create API handler: %v", err) } @@ -287,7 +287,7 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager) apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter() - apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil) + apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil, nil) if err != nil { t.Fatalf("Failed to create API handler: %v", err) } From a2919e26dd750b42fadbf62d72b4e24a530077ee Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Tue, 29 Sep 2026 11:46:25 +0300 Subject: [PATCH 28/33] [management, proxy] Enforce strict base64url decoding for JWT validation (#7554) --- management/server/auth/session.go | 20 +++++++++- management/server/auth/session_test.go | 51 ++++++++++++++++++++++++++ proxy/auth/auth.go | 2 +- shared/auth/jwt/validator.go | 3 +- 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/management/server/auth/session.go b/management/server/auth/session.go index 778146589..2f1b97975 100644 --- a/management/server/auth/session.go +++ b/management/server/auth/session.go @@ -3,9 +3,11 @@ package auth import ( "context" "crypto/sha256" + "encoding/base64" "encoding/hex" "errors" "fmt" + "strings" "time" ) @@ -52,6 +54,22 @@ func (s *SessionStore) RegisterToken(ctx context.Context, token string, expiresA } func hashToken(token string) string { - sum := sha256.Sum256([]byte(token)) + sum := sha256.Sum256([]byte(canonicalizeToken(token))) return hex.EncodeToString(sum[:]) } + +// canonicalizeToken re-encodes the JWT signature segment so noncanonical +// spellings of the same signature map to one stable cache key. +func canonicalizeToken(token string) string { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return token + } + + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return token + } + + return parts[0] + "." + parts[1] + "." + base64.RawURLEncoding.EncodeToString(sig) +} diff --git a/management/server/auth/session_test.go b/management/server/auth/session_test.go index 7c82dfc43..425e3143d 100644 --- a/management/server/auth/session_test.go +++ b/management/server/auth/session_test.go @@ -2,10 +2,15 @@ package auth import ( "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" "errors" + "strings" "testing" "time" + "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -131,3 +136,49 @@ func TestHashToken_StableAndDoesNotLeak(t *testing.T) { assert.Len(t, a, 64, "sha256 hex must be 64 chars") assert.NotContains(t, a, "tokenA", "raw token must not appear in hash") } + +func TestSessionStore_NoncanonicalSpellingIsRejectedAsReplay(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "sub": "user", + "exp": time.Now().Add(time.Hour).Unix(), + }) + canonical, err := token.SignedString(privateKey) + require.NoError(t, err) + + parts := strings.Split(canonical, ".") + require.Len(t, parts, 3) + + // A 256-byte RSA signature (256 mod 3 == 1) leaves unused bits in the final + // base64url character; flip one without changing the decoded signature. + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + last := strings.IndexByte(alphabet, parts[2][len(parts[2])-1]) + require.GreaterOrEqual(t, last, 0) + require.Equal(t, 0, last&3, "unexpected canonical RSA signature encoding") + + equivalentSig := parts[2][:len(parts[2])-1] + string(alphabet[last|1]) + equivalent := parts[0] + "." + parts[1] + "." + equivalentSig + require.NotEqual(t, canonical, equivalent, "spellings must differ as strings") + + // Same decoded signature bytes, so they verify as the same JWT. + canonicalSig, err := base64.RawURLEncoding.DecodeString(parts[2]) + require.NoError(t, err) + altSig, err := base64.RawURLEncoding.DecodeString(equivalentSig) + require.NoError(t, err) + require.Equal(t, canonicalSig, altSig, "spellings must decode to identical signature bytes") + + // The replay-cache key must be identical for both spellings. + assert.Equal(t, hashToken(canonical), hashToken(equivalent), + "noncanonical spelling must map to the same replay-cache key") + + s := newTestSessionStore(t) + ctx := context.Background() + exp := time.Now().Add(time.Hour) + + require.NoError(t, s.RegisterToken(ctx, canonical, exp), "first claim should succeed") + err = s.RegisterToken(ctx, equivalent, exp) + require.Error(t, err, "alternate spelling must be treated as a replay") + assert.ErrorIs(t, err, ErrTokenAlreadyUsed) +} diff --git a/proxy/auth/auth.go b/proxy/auth/auth.go index 5512bf003..084046c49 100644 --- a/proxy/auth/auth.go +++ b/proxy/auth/auth.go @@ -66,7 +66,7 @@ func ValidateSessionJWT(tokenString, domain string, publicKey ed25519.PublicKey) return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } return publicKey, nil - }, jwt.WithAudience(domain), jwt.WithIssuer(SessionJWTIssuer)) + }, jwt.WithAudience(domain), jwt.WithIssuer(SessionJWTIssuer), jwt.WithStrictDecoding()) if err != nil { return "", "", "", nil, nil, fmt.Errorf("parse token: %w", err) } diff --git a/shared/auth/jwt/validator.go b/shared/auth/jwt/validator.go index 62e127751..240d64bb3 100644 --- a/shared/auth/jwt/validator.go +++ b/shared/auth/jwt/validator.go @@ -18,7 +18,6 @@ import ( "time" "github.com/golang-jwt/jwt/v5" - log "github.com/sirupsen/logrus" ) @@ -217,8 +216,8 @@ func (v *Validator) ValidateAndParse(ctx context.Context, token string) (*jwt.To jwt.WithAudience(v.audienceList...), jwt.WithIssuer(v.issuer), jwt.WithIssuedAt(), + jwt.WithStrictDecoding(), ) - // Check if there was an error in parsing... if err != nil { err = fmt.Errorf("%w: %s", errTokenParsing, err) From 082aca155615f17f0c0ac84e958a34b9681ff55f Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Tue, 29 Sep 2026 13:25:56 +0300 Subject: [PATCH 29/33] [management] Refactor PKCE verifier store into a reusable single-use store (#7634) * Generalize PKCE verifier store into SingleUseStore * Use the single-use store in integration tests * Remove unrelated session code test stub --- .../service/manager/manager_test.go | 12 ++-- management/internals/server/boot.go | 8 +-- .../internals/shared/grpc/pkce_verifier.go | 55 ------------------- management/internals/shared/grpc/proxy.go | 10 ++-- .../internals/shared/grpc/proxy_test.go | 34 ++++++------ .../internals/shared/grpc/single_use.go | 47 ++++++++++++++++ ...ce_verifier_test.go => single_use_test.go} | 10 ++-- .../shared/grpc/validate_session_test.go | 4 +- .../proxy/auth_callback_integration_test.go | 4 +- .../testing/testing_tools/channel/channel.go | 8 +-- proxy/management_byop_integration_test.go | 4 +- proxy/management_integration_test.go | 4 +- 12 files changed, 96 insertions(+), 104 deletions(-) delete mode 100644 management/internals/shared/grpc/pkce_verifier.go create mode 100644 management/internals/shared/grpc/single_use.go rename management/internals/shared/grpc/{pkce_verifier_test.go => single_use_test.go} (85%) diff --git a/management/internals/modules/reverseproxy/service/manager/manager_test.go b/management/internals/modules/reverseproxy/service/manager/manager_test.go index 9704796be..2ec1af1a2 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/service/manager/manager_test.go @@ -433,8 +433,8 @@ func TestDeletePeerService_SourcePeerValidation(t *testing.T) { newProxyServer := func(t *testing.T) *nbgrpc.ProxyServiceServer { t.Helper() tokenStore := nbgrpc.NewOneTimeTokenStore(context.Background(), testCacheStore(t)) - pkceStore := nbgrpc.NewPKCEVerifierStore(context.Background(), testCacheStore(t)) - srv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil) + singleUseStore := nbgrpc.NewSingleUseStore(context.Background(), testCacheStore(t)) + srv := nbgrpc.NewProxyServiceServer(nil, tokenStore, singleUseStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil) return srv } @@ -726,8 +726,8 @@ func setupIntegrationTest(t *testing.T) (*Manager, store.Store) { } tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, testCacheStore(t)) - pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, testCacheStore(t)) - proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil) + singleUseStore := nbgrpc.NewSingleUseStore(ctx, testCacheStore(t)) + proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, singleUseStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil) proxyController, err := proxymanager.NewGRPCController(proxySrv, noop.NewMeterProvider().Meter("")) require.NoError(t, err) @@ -1150,8 +1150,8 @@ func TestDeleteService_DeletesTargets(t *testing.T) { mockAcct := account.NewMockManager(ctrl) tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, testCacheStore(t)) - pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, testCacheStore(t)) - proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, pkceStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil) + singleUseStore := nbgrpc.NewSingleUseStore(ctx, testCacheStore(t)) + proxySrv := nbgrpc.NewProxyServiceServer(nil, tokenStore, singleUseStore, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil) proxyController, err := proxymanager.NewGRPCController(proxySrv, noop.NewMeterProvider().Meter("")) require.NoError(t, err) diff --git a/management/internals/server/boot.go b/management/internals/server/boot.go index 0f136ce82..34c8363f1 100644 --- a/management/internals/server/boot.go +++ b/management/internals/server/boot.go @@ -248,7 +248,7 @@ func (s *BaseServer) GRPCServer() *grpc.Server { func (s *BaseServer) ReverseProxyGRPCServer() *nbgrpc.ProxyServiceServer { return Create(s, func() *nbgrpc.ProxyServiceServer { - proxyService := nbgrpc.NewProxyServiceServer(s.AccessLogsManager(), s.ProxyTokenStore(), s.PKCEVerifierStore(), s.proxyOIDCConfig(), s.PeersManager(), s.UsersManager(), s.IdpManager(), s.ProxyManager(), s.Store()) + proxyService := nbgrpc.NewProxyServiceServer(s.AccessLogsManager(), s.ProxyTokenStore(), s.SingleUseStore(), s.proxyOIDCConfig(), s.PeersManager(), s.UsersManager(), s.IdpManager(), s.ProxyManager(), s.Store()) s.AfterInit(func(s *BaseServer) { proxyService.SetServiceManager(s.ServiceManager()) proxyService.SetActivityManager(s.ProxyActivityManager()) @@ -305,9 +305,9 @@ func (s *BaseServer) ProxyTokenStore() *nbgrpc.OneTimeTokenStore { }) } -func (s *BaseServer) PKCEVerifierStore() *nbgrpc.PKCEVerifierStore { - return Create(s, func() *nbgrpc.PKCEVerifierStore { - return nbgrpc.NewPKCEVerifierStore(context.Background(), s.CacheStore()) +func (s *BaseServer) SingleUseStore() *nbgrpc.SingleUseStore { + return Create(s, func() *nbgrpc.SingleUseStore { + return nbgrpc.NewSingleUseStore(context.Background(), s.CacheStore()) }) } diff --git a/management/internals/shared/grpc/pkce_verifier.go b/management/internals/shared/grpc/pkce_verifier.go deleted file mode 100644 index 18155dc1d..000000000 --- a/management/internals/shared/grpc/pkce_verifier.go +++ /dev/null @@ -1,55 +0,0 @@ -package grpc - -import ( - "context" - "fmt" - "time" - - "github.com/eko/gocache/lib/v4/store" - log "github.com/sirupsen/logrus" - - nbcache "github.com/netbirdio/netbird/management/server/cache" -) - -// PKCEVerifierStore manages PKCE verifiers for OAuth flows. -// Supports both in-memory and Redis storage via NB_IDP_CACHE_REDIS_ADDRESS env var. -type PKCEVerifierStore struct { - cache nbcache.Store - ctx context.Context -} - -// NewPKCEVerifierStore creates a PKCE verifier store using the provided shared cache store. -func NewPKCEVerifierStore(ctx context.Context, cacheStore nbcache.Store) *PKCEVerifierStore { - return &PKCEVerifierStore{ - cache: cacheStore, - ctx: ctx, - } -} - -// Store saves a PKCE verifier associated with an OAuth state parameter. -// The verifier is stored with the specified TTL and will be automatically deleted after expiration. -func (s *PKCEVerifierStore) Store(state, verifier string, ttl time.Duration) error { - if err := s.cache.Set(s.ctx, state, verifier, store.WithExpiration(ttl)); err != nil { - return fmt.Errorf("failed to store PKCE verifier: %w", err) - } - - log.Debugf("Stored PKCE verifier for state (expires in %s)", ttl) - return nil -} - -// LoadAndDelete retrieves and removes a PKCE verifier for the given state. -// Returns the verifier and true if found, or empty string and false if not found. -// This enforces single-use semantics for PKCE verifiers. -func (s *PKCEVerifierStore) LoadAndDelete(state string) (string, bool) { - verifier, found, err := s.cache.GetDel(s.ctx, state) - if err != nil { - log.Warnf("Failed to consume PKCE verifier: %v", err) - return "", false - } - if !found { - log.Debug("PKCE verifier not found for state") - return "", false - } - - return verifier, true -} diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go index 28df7ed6f..e5d89ab3d 100644 --- a/management/internals/shared/grpc/proxy.go +++ b/management/internals/shared/grpc/proxy.go @@ -143,7 +143,7 @@ type ProxyServiceServer struct { oidcConfig ProxyOIDCConfig // Store for PKCE verifiers - pkceVerifierStore *PKCEVerifierStore + singleUseStore *SingleUseStore // tokenTTL is the lifetime of one-time tokens generated for proxy // authentication. Defaults to defaultProxyTokenTTL when zero. @@ -208,13 +208,13 @@ func enforceAccountScope(ctx context.Context, requestAccountID string) error { } // NewProxyServiceServer creates a new proxy service server. -func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeTokenStore, pkceStore *PKCEVerifierStore, oidcConfig ProxyOIDCConfig, peersManager peers.Manager, usersManager users.Manager, idpManager idp.Manager, proxyMgr proxy.Manager, tokenChecker ProxyTokenChecker) *ProxyServiceServer { +func NewProxyServiceServer(accessLogMgr accesslogs.Manager, tokenStore *OneTimeTokenStore, singleUseStore *SingleUseStore, oidcConfig ProxyOIDCConfig, peersManager peers.Manager, usersManager users.Manager, idpManager idp.Manager, proxyMgr proxy.Manager, tokenChecker ProxyTokenChecker) *ProxyServiceServer { ctx, cancel := context.WithCancel(context.Background()) s := &ProxyServiceServer{ accessLogManager: accessLogMgr, oidcConfig: oidcConfig, tokenStore: tokenStore, - pkceVerifierStore: pkceStore, + singleUseStore: singleUseStore, peersManager: peersManager, usersManager: usersManager, idpManager: idpManager, @@ -1575,7 +1575,7 @@ func (s *ProxyServiceServer) GetOIDCURL(ctx context.Context, req *proto.GetOIDCU state := fmt.Sprintf("%s|%s|%s", base64.URLEncoding.EncodeToString([]byte(redirectURL.String())), nonceB64, hmacSum) codeVerifier := oauth2.GenerateVerifier() - if err := s.pkceVerifierStore.Store(state, codeVerifier, pkceVerifierTTL); err != nil { + if err := s.singleUseStore.Store(state, codeVerifier, pkceVerifierTTL); err != nil { log.WithContext(ctx).Errorf("failed to store PKCE verifier: %v", err) return nil, status.Errorf(codes.Internal, "store PKCE verifier: %v", err) } @@ -1641,7 +1641,7 @@ func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL } // Consume the PKCE verifier only after HMAC validation passes. - verifier, ok := s.pkceVerifierStore.LoadAndDelete(state) + verifier, ok := s.singleUseStore.LoadAndDelete(state) if !ok { return "", "", errors.New("no verifier for state") } diff --git a/management/internals/shared/grpc/proxy_test.go b/management/internals/shared/grpc/proxy_test.go index 29b7c9523..060689da1 100644 --- a/management/internals/shared/grpc/proxy_test.go +++ b/management/internals/shared/grpc/proxy_test.go @@ -129,11 +129,11 @@ func drainEmpty(ch chan *proto.GetMappingUpdateResponse) bool { func TestSendServiceUpdateToCluster_UniqueTokensPerProxy(t *testing.T) { ctx := context.Background() tokenStore := NewOneTimeTokenStore(ctx, testCacheStore(t)) - pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t)) + singleUseStore := NewSingleUseStore(ctx, testCacheStore(t)) s := &ProxyServiceServer{ - tokenStore: tokenStore, - pkceVerifierStore: pkceStore, + tokenStore: tokenStore, + singleUseStore: singleUseStore, } s.SetProxyController(newTestProxyController()) @@ -186,11 +186,11 @@ func TestSendServiceUpdateToCluster_UniqueTokensPerProxy(t *testing.T) { func TestSendServiceUpdateToCluster_DeleteNoToken(t *testing.T) { ctx := context.Background() tokenStore := NewOneTimeTokenStore(ctx, testCacheStore(t)) - pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t)) + singleUseStore := NewSingleUseStore(ctx, testCacheStore(t)) s := &ProxyServiceServer{ - tokenStore: tokenStore, - pkceVerifierStore: pkceStore, + tokenStore: tokenStore, + singleUseStore: singleUseStore, } s.SetProxyController(newTestProxyController()) @@ -220,11 +220,11 @@ func TestSendServiceUpdateToCluster_DeleteNoToken(t *testing.T) { func TestSendServiceUpdate_UniqueTokensPerProxy(t *testing.T) { ctx := context.Background() tokenStore := NewOneTimeTokenStore(ctx, testCacheStore(t)) - pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t)) + singleUseStore := NewSingleUseStore(ctx, testCacheStore(t)) s := &ProxyServiceServer{ - tokenStore: tokenStore, - pkceVerifierStore: pkceStore, + tokenStore: tokenStore, + singleUseStore: singleUseStore, } s.SetProxyController(newTestProxyController()) @@ -272,13 +272,13 @@ func generateState(s *ProxyServiceServer, redirectURL string) string { func TestOAuthState_NeverTheSame(t *testing.T) { ctx := context.Background() - pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t)) + singleUseStore := NewSingleUseStore(ctx, testCacheStore(t)) s := &ProxyServiceServer{ oidcConfig: ProxyOIDCConfig{ HMACKey: []byte("test-hmac-key"), }, - pkceVerifierStore: pkceStore, + singleUseStore: singleUseStore, } redirectURL := "https://app.example.com/callback" @@ -300,17 +300,17 @@ func TestOAuthState_NeverTheSame(t *testing.T) { func TestValidateState_RejectsOldTwoPartFormat(t *testing.T) { ctx := context.Background() - pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t)) + singleUseStore := NewSingleUseStore(ctx, testCacheStore(t)) s := &ProxyServiceServer{ oidcConfig: ProxyOIDCConfig{ HMACKey: []byte("test-hmac-key"), }, - pkceVerifierStore: pkceStore, + singleUseStore: singleUseStore, } // Old format had only 2 parts: base64(url)|hmac - err := s.pkceVerifierStore.Store("base64url|hmac", "test", 10*time.Minute) + err := s.singleUseStore.Store("base64url|hmac", "test", 10*time.Minute) require.NoError(t, err) _, _, err = s.ValidateState("base64url|hmac") @@ -372,17 +372,17 @@ func TestEnforceAccountScope_AllowsNoTokenInContext(t *testing.T) { func TestValidateState_RejectsInvalidHMAC(t *testing.T) { ctx := context.Background() - pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t)) + singleUseStore := NewSingleUseStore(ctx, testCacheStore(t)) s := &ProxyServiceServer{ oidcConfig: ProxyOIDCConfig{ HMACKey: []byte("test-hmac-key"), }, - pkceVerifierStore: pkceStore, + singleUseStore: singleUseStore, } // Store with tampered HMAC - err := s.pkceVerifierStore.Store("dGVzdA==|nonce|wrong-hmac", "test", 10*time.Minute) + err := s.singleUseStore.Store("dGVzdA==|nonce|wrong-hmac", "test", 10*time.Minute) require.NoError(t, err) _, _, err = s.ValidateState("dGVzdA==|nonce|wrong-hmac") diff --git a/management/internals/shared/grpc/single_use.go b/management/internals/shared/grpc/single_use.go new file mode 100644 index 000000000..1698707d7 --- /dev/null +++ b/management/internals/shared/grpc/single_use.go @@ -0,0 +1,47 @@ +package grpc + +import ( + "context" + "fmt" + "time" + + "github.com/eko/gocache/lib/v4/store" + log "github.com/sirupsen/logrus" + + nbcache "github.com/netbirdio/netbird/management/server/cache" +) + +// SingleUseStore stores short-lived values that can be retrieved only once. +type SingleUseStore struct { + cache nbcache.Store + ctx context.Context +} + +// NewSingleUseStore creates a single-use value store over the shared cache. +func NewSingleUseStore(ctx context.Context, cacheStore nbcache.Store) *SingleUseStore { + return &SingleUseStore{ + cache: cacheStore, + ctx: ctx, + } +} + +// Store saves value under key with the given TTL, after which it is evicted. +func (s *SingleUseStore) Store(key, value string, ttl time.Duration) error { + if err := s.cache.Set(s.ctx, key, value, store.WithExpiration(ttl)); err != nil { + return fmt.Errorf("store single-use value: %w", err) + } + return nil +} + +// LoadAndDelete retrieves and removes the value for a key. +func (s *SingleUseStore) LoadAndDelete(key string) (string, bool) { + value, found, err := s.cache.GetDel(s.ctx, key) + if err != nil { + log.Warnf("failed to consume single-use value: %v", err) + return "", false + } + if !found { + return "", false + } + return value, true +} diff --git a/management/internals/shared/grpc/pkce_verifier_test.go b/management/internals/shared/grpc/single_use_test.go similarity index 85% rename from management/internals/shared/grpc/pkce_verifier_test.go rename to management/internals/shared/grpc/single_use_test.go index e7175b6c5..357046720 100644 --- a/management/internals/shared/grpc/pkce_verifier_test.go +++ b/management/internals/shared/grpc/single_use_test.go @@ -6,7 +6,7 @@ import ( "time" ) -func TestPKCEVerifierStoreLoadAndDelete(t *testing.T) { +func TestSingleUseStoreLoadAndDelete(t *testing.T) { const ( state = "state" verifier = "verifier" @@ -14,7 +14,7 @@ func TestPKCEVerifierStoreLoadAndDelete(t *testing.T) { ) t.Run("exactly one concurrent caller consumes the verifier", func(t *testing.T) { - store := NewPKCEVerifierStore(context.Background(), testCacheStore(t)) + store := NewSingleUseStore(context.Background(), testCacheStore(t)) if err := store.Store(state, verifier, time.Minute); err != nil { t.Fatalf("couldn't store PKCE verifier: %s", err) } @@ -50,7 +50,7 @@ func TestPKCEVerifierStoreLoadAndDelete(t *testing.T) { }) t.Run("replayed state is rejected", func(t *testing.T) { - store := NewPKCEVerifierStore(context.Background(), testCacheStore(t)) + store := NewSingleUseStore(context.Background(), testCacheStore(t)) if err := store.Store(state, verifier, time.Minute); err != nil { t.Fatalf("couldn't store PKCE verifier: %s", err) } @@ -64,7 +64,7 @@ func TestPKCEVerifierStoreLoadAndDelete(t *testing.T) { }) t.Run("unknown state is rejected", func(t *testing.T) { - store := NewPKCEVerifierStore(context.Background(), testCacheStore(t)) + store := NewSingleUseStore(context.Background(), testCacheStore(t)) if got, found := store.LoadAndDelete("never-stored"); found { t.Fatalf("unknown state should not resolve, got %q", got) @@ -72,7 +72,7 @@ func TestPKCEVerifierStoreLoadAndDelete(t *testing.T) { }) t.Run("expired verifier is rejected", func(t *testing.T) { - store := NewPKCEVerifierStore(context.Background(), testCacheStore(t)) + store := NewSingleUseStore(context.Background(), testCacheStore(t)) if err := store.Store(state, verifier, 50*time.Millisecond); err != nil { t.Fatalf("couldn't store PKCE verifier: %s", err) } diff --git a/management/internals/shared/grpc/validate_session_test.go b/management/internals/shared/grpc/validate_session_test.go index b300e8c1d..2af250a85 100644 --- a/management/internals/shared/grpc/validate_session_test.go +++ b/management/internals/shared/grpc/validate_session_test.go @@ -40,9 +40,9 @@ func setupValidateSessionTest(t *testing.T) *validateSessionTestSetup { proxyManager := &testValidateSessionProxyManager{} tokenStore := NewOneTimeTokenStore(ctx, testCacheStore(t)) - pkceStore := NewPKCEVerifierStore(ctx, testCacheStore(t)) + singleUseStore := NewSingleUseStore(ctx, testCacheStore(t)) - proxyService := NewProxyServiceServer(nil, tokenStore, pkceStore, ProxyOIDCConfig{}, nil, usersManager, nil, proxyManager, nil) + proxyService := NewProxyServiceServer(nil, tokenStore, singleUseStore, ProxyOIDCConfig{}, nil, usersManager, nil, proxyManager, nil) proxyService.SetServiceManager(serviceManager) createTestProxies(t, ctx, testStore) diff --git a/management/server/http/handlers/proxy/auth_callback_integration_test.go b/management/server/http/handlers/proxy/auth_callback_integration_test.go index 1dbfca4cd..41ecb5f23 100644 --- a/management/server/http/handlers/proxy/auth_callback_integration_test.go +++ b/management/server/http/handlers/proxy/auth_callback_integration_test.go @@ -197,7 +197,7 @@ func setupAuthCallbackTest(t *testing.T) *testSetup { require.NoError(t, err) tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, cacheStore) - pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, cacheStore) + singleUseStore := nbgrpc.NewSingleUseStore(ctx, cacheStore) usersManager := users.NewManager(testStore) @@ -212,7 +212,7 @@ func setupAuthCallbackTest(t *testing.T) *testSetup { proxyService := nbgrpc.NewProxyServiceServer( &testAccessLogManager{}, tokenStore, - pkceStore, + singleUseStore, oidcConfig, nil, usersManager, diff --git a/management/server/http/testing/testing_tools/channel/channel.go b/management/server/http/testing/testing_tools/channel/channel.go index 598adafcc..c3f6a06e0 100644 --- a/management/server/http/testing/testing_tools/channel/channel.go +++ b/management/server/http/testing/testing_tools/channel/channel.go @@ -110,13 +110,13 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee accessLogsManager := accesslogsmanager.NewManager(accesslogsmanager.NewRepository(store.(*nbstore.SqlStore).Conn()), store, permissionsManager, nil) proxyTokenStore := nbgrpc.NewOneTimeTokenStore(ctx, cacheStore) - pkceverifierStore := nbgrpc.NewPKCEVerifierStore(ctx, cacheStore) + singleUseStore := nbgrpc.NewSingleUseStore(ctx, cacheStore) noopMeter := noop.NewMeterProvider().Meter("") proxyMgr, err := proxymanager.NewManager(store, noopMeter) if err != nil { t.Fatalf("Failed to create proxy manager: %v", err) } - proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil) + proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, singleUseStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil) // NewProxyServiceServer starts cleanupStaleProxies on a context it derives // from context.Background(), independent of the cancellable ctx above; // Close() cancels it so the goroutine does not outlive the test. @@ -250,13 +250,13 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin accessLogsManager := accesslogsmanager.NewManager(accesslogsmanager.NewRepository(store.(*nbstore.SqlStore).Conn()), store, permissionsManager, nil) proxyTokenStore := nbgrpc.NewOneTimeTokenStore(ctx, cacheStore) - pkceverifierStore := nbgrpc.NewPKCEVerifierStore(ctx, cacheStore) + singleUseStore := nbgrpc.NewSingleUseStore(ctx, cacheStore) noopMeter := noop.NewMeterProvider().Meter("") proxyMgr, err := proxymanager.NewManager(store, noopMeter) if err != nil { t.Fatalf("Failed to create proxy manager: %v", err) } - proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil) + proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, singleUseStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil) // NewProxyServiceServer starts cleanupStaleProxies on a context it derives // from context.Background(), independent of the cancellable ctx above; // Close() cancels it so the goroutine does not outlive the test. diff --git a/proxy/management_byop_integration_test.go b/proxy/management_byop_integration_test.go index d075e47ec..42301254d 100644 --- a/proxy/management_byop_integration_test.go +++ b/proxy/management_byop_integration_test.go @@ -104,7 +104,7 @@ func setupBYOPIntegrationTest(t *testing.T) *byopTestSetup { require.NoError(t, err) tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, cacheStore) - pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, cacheStore) + singleUseStore := nbgrpc.NewSingleUseStore(ctx, cacheStore) meter := noop.NewMeterProvider().Meter("test") realProxyManager, err := proxymanager.NewManager(testStore, meter) @@ -121,7 +121,7 @@ func setupBYOPIntegrationTest(t *testing.T) *byopTestSetup { proxyService := nbgrpc.NewProxyServiceServer( &testAccessLogManager{}, tokenStore, - pkceStore, + singleUseStore, oidcConfig, nil, usersManager, diff --git a/proxy/management_integration_test.go b/proxy/management_integration_test.go index 0e148f858..befd1f1ee 100644 --- a/proxy/management_integration_test.go +++ b/proxy/management_integration_test.go @@ -119,7 +119,7 @@ func setupIntegrationTest(t *testing.T) *integrationTestSetup { require.NoError(t, err) tokenStore := nbgrpc.NewOneTimeTokenStore(ctx, cacheStore) - pkceStore := nbgrpc.NewPKCEVerifierStore(ctx, cacheStore) + singleUseStore := nbgrpc.NewSingleUseStore(ctx, cacheStore) // Create real users manager usersManager := users.NewManager(testStore) @@ -136,7 +136,7 @@ func setupIntegrationTest(t *testing.T) *integrationTestSetup { proxyService := nbgrpc.NewProxyServiceServer( &testAccessLogManager{}, tokenStore, - pkceStore, + singleUseStore, oidcConfig, nil, usersManager, From 93cb226a5e082507f29a2bf1bc4805a8195dbc7e Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:29:24 +0200 Subject: [PATCH 30/33] [management] fix login filter (#7739) --- .../internals/shared/grpc/loginfilter.go | 25 ++- .../internals/shared/grpc/loginfilter_test.go | 182 ++++++++++++++++++ 2 files changed, 203 insertions(+), 4 deletions(-) diff --git a/management/internals/shared/grpc/loginfilter.go b/management/internals/shared/grpc/loginfilter.go index cc69b7d6e..01ae67cc3 100644 --- a/management/internals/shared/grpc/loginfilter.go +++ b/management/internals/shared/grpc/loginfilter.go @@ -14,6 +14,7 @@ const ( baseBlockDuration = 10 * time.Minute // Duration for which a peer is banned after exceeding the reconnection limit reconnLimitForBan = 30 // Number of reconnections within the reconnTreshold that triggers a ban metaChangeLimit = 5 // Number of reconnections with different metadata that triggers a ban of one peer + maxBanLevel = 6 // Highest ban level; the ban duration doubles per level up to this one ) type lfConfig struct { @@ -21,6 +22,7 @@ type lfConfig struct { baseBlockDuration time.Duration reconnLimitForBan int metaChangeLimit int + maxBanLevel int } func initCfg() *lfConfig { @@ -29,6 +31,7 @@ func initCfg() *lfConfig { baseBlockDuration: baseBlockDuration, reconnLimitForBan: reconnLimitForBan, metaChangeLimit: metaChangeLimit, + maxBanLevel: maxBanLevel, } } @@ -102,11 +105,18 @@ func (l *loginFilter) addLogin(wgPubKey string, metaHash uint64) { return } - if state.isBanned && now.After(state.banExpiresAt) { + if state.isBanned { + if now.Before(state.banExpiresAt) { + return + } state.isBanned = false } - if state.banLevel > 0 && now.Sub(state.lastSeen) > (2*l.cfg.baseBlockDuration) { + quietSince := state.lastSeen + if state.banExpiresAt.After(quietSince) { + quietSince = state.banExpiresAt + } + if state.banLevel > 0 && now.Sub(quietSince) > (2*l.cfg.baseBlockDuration) { state.banLevel = 0 } @@ -124,10 +134,17 @@ func (l *loginFilter) addLogin(wgPubKey string, metaHash uint64) { return } + if now.Sub(state.sessionStart) >= l.cfg.reconnThreshold { + state.sessionStart = now + state.sessionCounter = 0 + } + state.sessionCounter++ - if state.sessionCounter > l.cfg.reconnLimitForBan && now.Sub(state.sessionStart) < l.cfg.reconnThreshold { + if state.sessionCounter > l.cfg.reconnLimitForBan { state.isBanned = true - state.banLevel++ + if state.banLevel < l.cfg.maxBanLevel { + state.banLevel++ + } backoffFactor := math.Pow(2, float64(state.banLevel-1)) duration := time.Duration(float64(l.cfg.baseBlockDuration) * backoffFactor) diff --git a/management/internals/shared/grpc/loginfilter_test.go b/management/internals/shared/grpc/loginfilter_test.go index d9df26420..edc256550 100644 --- a/management/internals/shared/grpc/loginfilter_test.go +++ b/management/internals/shared/grpc/loginfilter_test.go @@ -20,6 +20,7 @@ func testAdvancedCfg() *lfConfig { baseBlockDuration: 100 * time.Millisecond, reconnLimitForBan: 3, metaChangeLimit: 2, + maxBanLevel: 3, } } @@ -157,6 +158,187 @@ func (s *LoginFilterTestSuite) TestMetaChangeIsAllowedAfterWindowResets() { s.Equal(1, s.filter.logged[pubKey].metaChangeCounter, "meta change counter should reset") } +func (s *LoginFilterTestSuite) TestReconnectStormAfterQuietPeriodTriggersBan() { + pubKey := "PUB_KEY_A" + meta := uint64(1) + limit := s.filter.cfg.reconnLimitForBan + + s.filter.addLogin(pubKey, meta) + s.Require().Contains(s.filter.logged, pubKey) + s.filter.logged[pubKey].sessionStart = time.Now().Add(-(s.filter.cfg.reconnThreshold + time.Second)) + + s.filter.addLogin(pubKey, meta) + s.Equal(1, s.filter.logged[pubKey].sessionCounter, "expired window should restart the count") + + for i := 1; i < limit; i++ { + s.filter.addLogin(pubKey, meta) + } + s.True(s.filter.allowLogin(pubKey, meta)) + s.False(s.filter.logged[pubKey].isBanned) + + s.filter.addLogin(pubKey, meta) + + s.False(s.filter.allowLogin(pubKey, meta)) + s.True(s.filter.logged[pubKey].isBanned) +} + +func (s *LoginFilterTestSuite) TestReconnectStormAfterBanExpiresTriggersBanAgain() { + pubKey := "PUB_KEY_A" + meta := uint64(1) + limit := s.filter.cfg.reconnLimitForBan + + for i := 0; i <= limit; i++ { + s.filter.addLogin(pubKey, meta) + } + s.Require().Contains(s.filter.logged, pubKey) + s.Require().True(s.filter.logged[pubKey].isBanned) + + expired := time.Now().Add(-(s.filter.cfg.baseBlockDuration + time.Second)) + s.filter.logged[pubKey].banExpiresAt = expired + s.filter.logged[pubKey].sessionStart = expired + + for i := 0; i <= limit; i++ { + s.filter.addLogin(pubKey, meta) + } + + s.True(s.filter.logged[pubKey].isBanned) + s.Equal(2, s.filter.logged[pubKey].banLevel) +} + +func (s *LoginFilterTestSuite) TestSlowReconnectsAcrossWindowsDoNotBan() { + pubKey := "PUB_KEY_A" + meta := uint64(1) + limit := s.filter.cfg.reconnLimitForBan + + for i := 0; i < limit; i++ { + s.filter.addLogin(pubKey, meta) + } + s.Require().Contains(s.filter.logged, pubKey) + s.filter.logged[pubKey].sessionStart = time.Now().Add(-(s.filter.cfg.reconnThreshold + time.Second)) + + for i := 0; i < limit; i++ { + s.filter.addLogin(pubKey, meta) + } + + s.True(s.filter.allowLogin(pubKey, meta)) + s.False(s.filter.logged[pubKey].isBanned) +} + +func (s *LoginFilterTestSuite) TestBanLevelEscalatesWhenStormResumesRightAfterBan() { + pubKey := "PUB_KEY_A" + meta := uint64(1) + limit := s.filter.cfg.reconnLimitForBan + banTime := time.Now().Add(-3 * s.filter.cfg.baseBlockDuration) + + s.filter.logged[pubKey] = &peerState{ + currentHash: meta, + isBanned: true, + banLevel: 1, + banExpiresAt: time.Now().Add(-time.Millisecond), + sessionStart: banTime, + lastSeen: banTime, + } + + for i := 0; i <= limit; i++ { + s.filter.addLogin(pubKey, meta) + } + + s.True(s.filter.logged[pubKey].isBanned) + s.Equal(2, s.filter.logged[pubKey].banLevel) +} + +func (s *LoginFilterTestSuite) TestBanLevelResetsAfterQuietPeriodFollowingBan() { + pubKey := "PUB_KEY_A" + meta := uint64(1) + quiet := 2*s.filter.cfg.baseBlockDuration + time.Second + + s.filter.logged[pubKey] = &peerState{ + currentHash: meta, + banLevel: 2, + banExpiresAt: time.Now().Add(-s.filter.cfg.baseBlockDuration), + lastSeen: time.Now().Add(-2 * quiet), + } + + s.filter.addLogin(pubKey, meta) + s.Equal(2, s.filter.logged[pubKey].banLevel, "ban ended more recently than the quiet period") + + s.filter.logged[pubKey].banExpiresAt = time.Now().Add(-quiet) + s.filter.logged[pubKey].lastSeen = time.Now().Add(-2 * quiet) + + s.filter.addLogin(pubKey, meta) + s.Equal(0, s.filter.logged[pubKey].banLevel) +} + +func (s *LoginFilterTestSuite) TestBanDurationIsCappedAtMaxLevel() { + pubKey := "PUB_KEY_A" + meta := uint64(1) + limit := s.filter.cfg.reconnLimitForBan + maxLevel := s.filter.cfg.maxBanLevel + + s.filter.logged[pubKey] = &peerState{ + currentHash: meta, + banLevel: maxLevel, + sessionStart: time.Now(), + lastSeen: time.Now(), + } + + for i := 0; i <= limit; i++ { + s.filter.addLogin(pubKey, meta) + } + + s.True(s.filter.logged[pubKey].isBanned) + s.Equal(maxLevel, s.filter.logged[pubKey].banLevel) + expected := s.filter.cfg.baseBlockDuration << (maxLevel - 1) + s.InDelta(expected, s.filter.logged[pubKey].banExpiresAt.Sub(s.filter.logged[pubKey].lastSeen), float64(time.Millisecond)) +} + +func (s *LoginFilterTestSuite) TestEstablishedPeerReconnectingOnceIsAllowed() { + pubKey := "PUB_KEY_A" + meta := uint64(1) + longAgo := time.Now().Add(-time.Hour) + + s.filter.logged[pubKey] = &peerState{ + currentHash: meta, + sessionCounter: 1, + sessionStart: longAgo, + lastSeen: longAgo, + metaChangeWindowStart: longAgo, + metaChangeCounter: 1, + } + + s.True(s.filter.allowLogin(pubKey, meta)) + s.filter.addLogin(pubKey, meta) + + s.True(s.filter.allowLogin(pubKey, meta)) + s.False(s.filter.logged[pubKey].isBanned) + s.Equal(1, s.filter.logged[pubKey].sessionCounter) +} + +func (s *LoginFilterTestSuite) TestLoginsDuringActiveBanDoNotExtendIt() { + pubKey := "PUB_KEY_A" + meta := uint64(1) + limit := s.filter.cfg.reconnLimitForBan + + for i := 0; i <= limit; i++ { + s.filter.addLogin(pubKey, meta) + } + s.Require().Contains(s.filter.logged, pubKey) + s.Require().True(s.filter.logged[pubKey].isBanned) + expiresAt := time.Now().Add(time.Hour) + s.filter.logged[pubKey].banExpiresAt = expiresAt + lastSeen := s.filter.logged[pubKey].lastSeen + + for i := 0; i <= limit; i++ { + s.filter.addLogin(pubKey, meta) + } + + s.True(s.filter.logged[pubKey].isBanned) + s.Equal(1, s.filter.logged[pubKey].banLevel) + s.Equal(expiresAt, s.filter.logged[pubKey].banExpiresAt) + s.Equal(lastSeen, s.filter.logged[pubKey].lastSeen) + s.Equal(0, s.filter.logged[pubKey].sessionCounter) +} + func BenchmarkHashingMethods(b *testing.B) { meta := nbpeer.PeerSystemMeta{ WtVersion: "1.25.1", From 10a04fcccbd02ab734e3d229dce3b314e0002e48 Mon Sep 17 00:00:00 2001 From: Brad Ison Date: Tue, 29 Sep 2026 13:36:45 +0200 Subject: [PATCH 31/33] [management] Add a disabled state to the managed Agent Network proxy API (#7744) A managed Agent Network gateway can be turned off by the platform. The derived state had no value for that, so a disabled deployment reported whatever the operator last saw, usually provisioning. Add `disabled` to the AgentNetworkManagedProxy state enum and say in the POST and GET descriptions that a disabled deployment answers with it and that provisioning again does not turn it back on. --- shared/management/http/api/openapi.yml | 8 ++++---- shared/management/http/api/types.gen.go | 7 +++++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index c19df4fcf..3dd9f41f1 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -6453,8 +6453,8 @@ components: example: "d1m3kebd9pcs0c1pnu7g" state: type: string - description: Derived deployment state. `provisioning` until the gateway is rolled out and connected, `ready` while the gateway actively serves the endpoint, `failed` when the rollout reported a failure. - enum: [ "provisioning", "ready", "failed" ] + description: Derived deployment state. `provisioning` until the gateway is rolled out and connected, `ready` while the gateway actively serves the endpoint, `failed` when the rollout reported a failure, `disabled` while the gateway is turned off and the endpoint is not served. + enum: [ "provisioning", "ready", "failed", "disabled" ] example: "ready" endpoint: type: string @@ -13575,7 +13575,7 @@ paths: /api/integrations/agent-network/managed-proxy: post: summary: Provision a managed Agent Network gateway - description: Starts provisioning of a NetBird-managed Agent Network gateway for the account, allocating its endpoint under the managed zone on the first call. Idempotent — answers 202 when this call started (or, after a failure, restarted) provisioning and 200 when a deployment already exists, reporting current state either way. Returns 409 when the account already has an Agent Network endpoint that managed provisioning does not own, and 503 when endpoint allocation is temporarily exhausted. + description: Starts provisioning of a NetBird-managed Agent Network gateway for the account, allocating its endpoint under the managed zone on the first call. Idempotent — answers 202 when this call started (or, after a failure, restarted) provisioning and 200 when a deployment already exists, reporting current state either way. A disabled deployment answers 200 with state `disabled` and stays disabled. Returns 409 when the account already has an Agent Network endpoint that managed provisioning does not own, and 503 when endpoint allocation is temporarily exhausted. tags: [ Agent Network ] security: - BearerAuth: [ ] @@ -13613,7 +13613,7 @@ paths: $ref: '#/components/schemas/ErrorResponse' get: summary: Retrieve managed Agent Network gateway status - description: Reports the account's managed gateway deployment and its derived state. Returns 404 when the account has no managed deployment. + description: Reports the account's managed gateway deployment and its derived state, including `disabled` for a deployment that is turned off. Returns 404 when the account has no managed deployment. tags: [ Agent Network ] security: - BearerAuth: [ ] diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index b5a7a80ac..9a90a72d3 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -79,6 +79,7 @@ func (e AgentNetworkConsumptionDimensionKind) Valid() bool { // Defines values for AgentNetworkManagedProxyState. const ( + AgentNetworkManagedProxyStateDisabled AgentNetworkManagedProxyState = "disabled" AgentNetworkManagedProxyStateFailed AgentNetworkManagedProxyState = "failed" AgentNetworkManagedProxyStateProvisioning AgentNetworkManagedProxyState = "provisioning" AgentNetworkManagedProxyStateReady AgentNetworkManagedProxyState = "ready" @@ -87,6 +88,8 @@ const ( // Valid indicates whether the value is a known member of the AgentNetworkManagedProxyState enum. func (e AgentNetworkManagedProxyState) Valid() bool { switch e { + case AgentNetworkManagedProxyStateDisabled: + return true case AgentNetworkManagedProxyStateFailed: return true case AgentNetworkManagedProxyStateProvisioning: @@ -2259,11 +2262,11 @@ type AgentNetworkManagedProxy struct { // Region Region of the cluster hosting the deployment. Region *string `json:"region,omitempty"` - // State Derived deployment state. `provisioning` until the gateway is rolled out and connected, `ready` while the gateway actively serves the endpoint, `failed` when the rollout reported a failure. + // State Derived deployment state. `provisioning` until the gateway is rolled out and connected, `ready` while the gateway actively serves the endpoint, `failed` when the rollout reported a failure, `disabled` while the gateway is turned off and the endpoint is not served. State AgentNetworkManagedProxyState `json:"state"` } -// AgentNetworkManagedProxyState Derived deployment state. `provisioning` until the gateway is rolled out and connected, `ready` while the gateway actively serves the endpoint, `failed` when the rollout reported a failure. +// AgentNetworkManagedProxyState Derived deployment state. `provisioning` until the gateway is rolled out and connected, `ready` while the gateway actively serves the endpoint, `failed` when the rollout reported a failure, `disabled` while the gateway is turned off and the endpoint is not served. type AgentNetworkManagedProxyState string // AgentNetworkManagedProxyConflict Conflict body returned when the account already has an Agent Network endpoint that managed provisioning does not own, naming that endpoint. From 7ff709f565456f8a5fa5d8e25a1699667a5a76e2 Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Tue, 29 Sep 2026 17:08:33 +0200 Subject: [PATCH 32/33] [client] Keep the delete-profile dialog open until the delete finishes (#7752) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [client] Keep the delete-profile dialog open until the delete finishes Confirming a profile deletion closed the dialog straight away and left the daemon call running in the background. A slow delete then looked like nothing had happened: the dialog was gone, the profile was still listed, and the row only disappeared whenever the refresh landed. The confirm dialog now owns the action. It stays open with the confirm button spinning, closes once the call resolves, and surfaces a failure after it is gone rather than behind it. Nothing on the daemon path carries a deadline, so the wait is bounded in the dialog instead: Cancel comes back after five seconds and the wait is abandoned at thirty, which keeps a hung daemon from trapping the user in a modal that cannot be dismissed. A loading button keeps its own variant colours rather than the disabled skin, which dimmed the spinner to grey on the danger variant, and blocks input through aria-disabled and a click guard instead. * [client] Match the theme and anonymize pickers to the language switcher * [client] Settle a confirm dialog only from the run that opened it Cancelling at the stall point leaves the action running, and the provider is mounted once: take() read whichever settler the ref held when the stale run finally finished. Open another prompt in the meantime and that run answered it — a hung delete that later resolved confirmed a profile switch nobody accepted, and its timeout closed the new dialog with an unrelated error. Each run now remembers the settler it was dispatched for and settles only while the ref still points at it. A late arrival finds a stranger there and answers nothing. * [client] Add the shared Select the theme and anonymize pickers use The picker rework landed without the component both pickers import, so the branch did not compile. Add it, and name it for what it is: a select of a few options, with nothing settings-specific about it, so it sits with the other input controls rather than under a name that discourages reuse. * [client] Drop the Select header comment * [client] Fix cubic comments * [client] Name the Select trigger with the option it is showing * [client] Name the language trigger with the language it is showing * [client] Hold the confirm dialog for 15s before offering cancel --- .../frontend/src/components/DropdownMenu.tsx | 16 +- .../src/components/LanguagePicker.tsx | 6 +- .../frontend/src/components/ThemePicker.tsx | 82 ++------- .../src/components/buttons/Button.tsx | 8 +- .../src/components/dialog/ConfirmModal.tsx | 9 +- .../frontend/src/components/inputs/Select.tsx | 99 ++++++++++ .../frontend/src/contexts/DialogContext.tsx | 66 ++++++- .../src/modules/profiles/ProfilesTab.tsx | 19 +- .../src/modules/settings/SettingsAccent.tsx | 171 ++++++++++++++++-- .../settings/SettingsTroubleshooting.tsx | 74 +++----- 10 files changed, 397 insertions(+), 153 deletions(-) create mode 100644 client/ui/frontend/src/components/inputs/Select.tsx diff --git a/client/ui/frontend/src/components/DropdownMenu.tsx b/client/ui/frontend/src/components/DropdownMenu.tsx index 8cedcea03..aa3ced05f 100644 --- a/client/ui/frontend/src/components/DropdownMenu.tsx +++ b/client/ui/frontend/src/components/DropdownMenu.tsx @@ -1,6 +1,6 @@ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import { cva } from "class-variance-authority"; -import { Check, ChevronRight, Circle } from "lucide-react"; +import { Check, ChevronRight } from "lucide-react"; import * as React from "react"; import { cn } from "@/lib/cn"; @@ -159,19 +159,23 @@ const DropdownMenuRadioItem = React.forwardRef< - + {children} + - + - {children} )); DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; diff --git a/client/ui/frontend/src/components/LanguagePicker.tsx b/client/ui/frontend/src/components/LanguagePicker.tsx index 35ef7d5b5..d0a95906f 100644 --- a/client/ui/frontend/src/components/LanguagePicker.tsx +++ b/client/ui/frontend/src/components/LanguagePicker.tsx @@ -89,7 +89,11 @@ export function LanguagePicker() { tabIndex={0} disabled={busy || languages.length === 0} onKeyDown={handleTriggerKeyDown} - aria-label={t("settings.general.language.label")} + aria-label={ + current + ? `${t("settings.general.language.label")}: ${labelFor(current)}` + : t("settings.general.language.label") + } aria-haspopup={"listbox"} aria-expanded={open} className={cn( diff --git a/client/ui/frontend/src/components/ThemePicker.tsx b/client/ui/frontend/src/components/ThemePicker.tsx index c3dc70d1e..3acb11a99 100644 --- a/client/ui/frontend/src/components/ThemePicker.tsx +++ b/client/ui/frontend/src/components/ThemePicker.tsx @@ -1,18 +1,10 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { ChevronDown, MonitorIcon, MoonIcon, SunMediumIcon, type LucideIcon } from "lucide-react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from "@/components/DropdownMenu"; +import { MonitorIcon, MoonIcon, SunMediumIcon, type LucideIcon } from "lucide-react"; +import { Select } from "@/components/inputs/Select"; import { HelpText } from "@/components/typography/HelpText"; import { Label } from "@/components/typography/Label"; import { useTheme, type ThemePreference } from "@/contexts/ThemeContext"; -import { useFocusVisible } from "@/hooks/useFocusVisible"; -import { cn } from "@/lib/cn"; import { errorDialog, formatErrorMessage } from "@/lib/errors"; const OPTIONS: { value: ThemePreference; icon: LucideIcon; labelKey: string }[] = [ @@ -25,16 +17,12 @@ export function ThemePicker() { const { t } = useTranslation(); const { theme, setTheme } = useTheme(); const [busy, setBusy] = useState(false); - const isFocusVisible = useFocusVisible(); - const current = OPTIONS.find((o) => o.value === theme) ?? OPTIONS[0]; - const CurrentIcon = current.icon; - - const select = async (value: string) => { + const select = async (value: ThemePreference) => { if (busy || value === theme) return; setBusy(true); try { - await setTheme(value as ThemePreference); + await setTheme(value); } catch (e) { await errorDialog({ Title: t("settings.error.saveTitle"), @@ -52,57 +40,17 @@ export function ThemePicker() { {t("settings.general.theme.help")}
- - - - - - void select(v)}> - {OPTIONS.map(({ value, icon: Icon, labelKey }) => ( - - - {t(labelKey)} - - ))} - - - + ({ + value, + icon, + label: t(`settings.troubleshooting.anonymize.${value}`), + }))} + onChange={setAnonymizeLevel} + ariaLabel={t("settings.troubleshooting.anonymize.label")} + />
Date: Tue, 29 Sep 2026 18:29:55 +0300 Subject: [PATCH 33/33] [management,proxy] Use single-use codes for OIDC session handoff (#7635) * Generalize PKCE verifier store into SingleUseStore * Generalize PKCE verifier store into SingleUseStore * Extend single-use store to generate one-time retrieval codes * Hand off proxy OIDC session via one-time code instead of URL token * Use the single-use store in integration tests * Read active proxy versions by cluster * Detect proxy clusters that support session codes * Bind OIDC session handoff mode to signed state * Deprecate legacy OIDC session token handoff * Remove unrelated session code test stub * fix tests * fix merge * Fix session code compatibility detection * Isolate proxy session codes in shared cache * bump min session version --- .../modules/reverseproxy/proxy/manager.go | 1 + .../reverseproxy/proxy/manager/manager.go | 20 + .../proxy/manager/manager_test.go | 35 ++ .../reverseproxy/proxy/manager_mock.go | 14 + management/internals/shared/grpc/proxy.go | 71 ++- .../internals/shared/grpc/proxy_test.go | 28 +- .../internals/shared/grpc/single_use.go | 20 + .../internals/shared/grpc/single_use_test.go | 37 ++ .../shared/grpc/validate_session_test.go | 48 ++ management/server/http/handlers/proxy/auth.go | 15 +- .../proxy/auth_callback_integration_test.go | 87 +++- management/server/store/sql_store_proxy.go | 15 + management/server/store/store.go | 1 + management/server/store/store_mock.go | 15 + proxy/internal/auth/middleware.go | 49 +- proxy/internal/auth/middleware_test.go | 26 ++ proxy/internal/auth/oidc.go | 11 +- proxy/internal/proxy/reverseproxy.go | 7 +- proxy/management_integration_test.go | 10 +- proxy/web/web.go | 1 + shared/management/proto/proxy_service.pb.go | 439 +++++++++--------- shared/management/proto/proxy_service.proto | 7 +- 22 files changed, 676 insertions(+), 281 deletions(-) diff --git a/management/internals/modules/reverseproxy/proxy/manager.go b/management/internals/modules/reverseproxy/proxy/manager.go index a591b86ca..c0b8435ec 100644 --- a/management/internals/modules/reverseproxy/proxy/manager.go +++ b/management/internals/modules/reverseproxy/proxy/manager.go @@ -20,6 +20,7 @@ type Manager interface { ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool + ClusterSupportsSessionCode(ctx context.Context, clusterAddr string) bool CleanupStale(ctx context.Context, inactivityDuration time.Duration) error GetAccountProxy(ctx context.Context, accountID string) (*Proxy, error) CountAccountProxies(ctx context.Context, accountID string) (int64, error) diff --git a/management/internals/modules/reverseproxy/proxy/manager/manager.go b/management/internals/modules/reverseproxy/proxy/manager/manager.go index edfa32aa9..7ddb66eec 100644 --- a/management/internals/modules/reverseproxy/proxy/manager/manager.go +++ b/management/internals/modules/reverseproxy/proxy/manager/manager.go @@ -8,6 +8,7 @@ import ( "go.opentelemetry.io/otel/metric" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + nbversion "github.com/netbirdio/netbird/version" ) // store defines the interface for proxy persistence operations @@ -22,6 +23,7 @@ type store interface { GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool + GetActiveProxyVersions(ctx context.Context, clusterAddr string) ([]string, error) CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error) CountProxiesByAccountID(ctx context.Context, accountID string) (int64, error) @@ -29,6 +31,8 @@ type store interface { DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error } +const minSessionCodeVersion = "0.81.0" + // Manager handles all proxy operations type Manager struct { store store @@ -145,6 +149,22 @@ func (m Manager) ClusterSupportsPrivate(ctx context.Context, clusterAddr string) return m.store.GetClusterSupportsPrivate(ctx, clusterAddr) } +// ClusterSupportsSessionCode reports whether all active proxies support session codes. +func (m Manager) ClusterSupportsSessionCode(ctx context.Context, clusterAddr string) bool { + versions, err := m.store.GetActiveProxyVersions(ctx, clusterAddr) + if err != nil || len(versions) == 0 { + return false + } + + for _, version := range versions { + if supported, err := nbversion.MeetsMinVersion(minSessionCodeVersion, version); err != nil || !supported { + return false + } + } + + return true +} + // CleanupStale removes proxies that haven't sent heartbeat in the specified duration func (m *Manager) CleanupStale(ctx context.Context, inactivityDuration time.Duration) error { if err := m.store.CleanupStaleProxies(ctx, inactivityDuration); err != nil { diff --git a/management/internals/modules/reverseproxy/proxy/manager/manager_test.go b/management/internals/modules/reverseproxy/proxy/manager/manager_test.go index d5a3ce777..66ddb95bd 100644 --- a/management/internals/modules/reverseproxy/proxy/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/proxy/manager/manager_test.go @@ -22,6 +22,7 @@ type mockStore struct { updateProxyHeartbeatFunc func(ctx context.Context, p *proxy.Proxy) error getActiveProxyClusterAddressesFunc func(ctx context.Context) ([]string, error) getActiveProxyClusterAddressesForAccFunc func(ctx context.Context, accountID string) ([]string, error) + getActiveProxyVersionsFunc func(ctx context.Context, clusterAddress string) ([]string, error) cleanupStaleProxiesFunc func(ctx context.Context, d time.Duration) error getProxyByAccountIDFunc func(ctx context.Context, accountID string) (*proxy.Proxy, error) countProxiesByAccountIDFunc func(ctx context.Context, accountID string) (int64, error) @@ -104,6 +105,12 @@ func (m *mockStore) GetClusterSupportsCrowdSec(_ context.Context, _ string) *boo func (m *mockStore) GetClusterSupportsPrivate(_ context.Context, _ string) *bool { return nil } +func (m *mockStore) GetActiveProxyVersions(ctx context.Context, clusterAddress string) ([]string, error) { + if m.getActiveProxyVersionsFunc != nil { + return m.getActiveProxyVersionsFunc(ctx, clusterAddress) + } + return nil, nil +} func newTestManager(s store) *Manager { meter := noop.NewMeterProvider().Meter("test") @@ -114,6 +121,34 @@ func newTestManager(s store) *Manager { return m } +func TestClusterSupportsSessionCode(t *testing.T) { + tests := []struct { + name string + versions []string + storeErr error + want bool + }{ + {name: "all supported", versions: []string{"0.81.0", "0.81.2"}, want: true}, + {name: "one old proxy", versions: []string{"0.81.0", "0.80.0"}}, + {name: "missing version", versions: []string{"0.81.0", ""}}, + {name: "no active proxies"}, + {name: "store error", storeErr: errors.New("db error")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &mockStore{ + getActiveProxyVersionsFunc: func(_ context.Context, _ string) ([]string, error) { + return tt.versions, tt.storeErr + }, + } + + got := newTestManager(s).ClusterSupportsSessionCode(context.Background(), "cluster.example.com") + assert.Equal(t, tt.want, got) + }) + } +} + func TestConnect_WithAccountID(t *testing.T) { accountID := "acc-123" diff --git a/management/internals/modules/reverseproxy/proxy/manager_mock.go b/management/internals/modules/reverseproxy/proxy/manager_mock.go index ec6df8a4a..d6f7197d7 100644 --- a/management/internals/modules/reverseproxy/proxy/manager_mock.go +++ b/management/internals/modules/reverseproxy/proxy/manager_mock.go @@ -112,6 +112,20 @@ func (mr *MockManagerMockRecorder) ClusterSupportsPrivate(ctx, clusterAddr any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsPrivate", reflect.TypeOf((*MockManager)(nil).ClusterSupportsPrivate), ctx, clusterAddr) } +// ClusterSupportsSessionCode mocks base method. +func (m *MockManager) ClusterSupportsSessionCode(ctx context.Context, clusterAddr string) bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClusterSupportsSessionCode", ctx, clusterAddr) + ret0, _ := ret[0].(bool) + return ret0 +} + +// ClusterSupportsSessionCode indicates an expected call of ClusterSupportsSessionCode. +func (mr *MockManagerMockRecorder) ClusterSupportsSessionCode(ctx, clusterAddr any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsSessionCode", reflect.TypeOf((*MockManager)(nil).ClusterSupportsSessionCode), ctx, clusterAddr) +} + // Connect mocks base method. func (m *MockManager) Connect(ctx context.Context, proxyID, sessionID, clusterAddress, ipAddress, version string, accountID *string, capabilities *Capabilities) (*Proxy, error) { m.ctrl.T.Helper() diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go index e5d89ab3d..00355087d 100644 --- a/management/internals/shared/grpc/proxy.go +++ b/management/internals/shared/grpc/proxy.go @@ -27,8 +27,6 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - "github.com/netbirdio/netbird/shared/management/domain" - "github.com/netbirdio/netbird/management/internals/modules/agentnetwork" "github.com/netbirdio/netbird/management/internals/modules/peers" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" @@ -42,6 +40,7 @@ import ( "github.com/netbirdio/netbird/management/server/users" proxyauth "github.com/netbirdio/netbird/proxy/auth" "github.com/netbirdio/netbird/shared/hash/argon2id" + "github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/proto" nbstatus "github.com/netbirdio/netbird/shared/management/status" ) @@ -142,7 +141,7 @@ type ProxyServiceServer struct { // OIDC configuration for proxy authentication oidcConfig ProxyOIDCConfig - // Store for PKCE verifiers + // singleUseStore backs both PKCE verifiers and OIDC session exchange codes. singleUseStore *SingleUseStore // tokenTTL is the lifetime of one-time tokens generated for proxy @@ -158,6 +157,13 @@ type ProxyServiceServer struct { const pkceVerifierTTL = 10 * time.Minute +const sessionCodeTTL = 60 * time.Second + +const sessionCodeCacheNamespace = "proxy:session" + +// The signed nonce binds the handoff mode without changing the state format. +const sessionCodeNoncePrefix = "code." + const defaultProxyTokenTTL = 5 * time.Minute const defaultSnapshotBatchSize = 500 @@ -306,6 +312,16 @@ func (s *ProxyServiceServer) proxyConnectAuthorizer() ProxyConnectAuthorizer { return s.connectAuthorizer } +// GenerateSessionCode creates a single-use code for the given session token. +func (s *ProxyServiceServer) GenerateSessionCode(sessionToken string) (code string, ok bool) { + code, err := s.singleUseStore.Generate(sessionCodeCacheNamespace, sessionToken, sessionCodeTTL) + if err != nil { + log.WithError(err).Error("failed to generate proxy session code") + return "", false + } + return code, true +} + // CheckLLMPolicyLimits is the pre-flight policy gate the proxy calls before // forwarding an LLM request upstream. Delegates to the agent-network selector, // which scores applicable policies by remaining headroom and returns the @@ -1536,18 +1552,20 @@ func (s *ProxyServiceServer) GetOIDCURL(ctx context.Context, req *proto.GetOIDCU log.WithContext(ctx).Errorf("failed to get account services: %v", err) return nil, status.Errorf(codes.FailedPrecondition, "get account services: %v", err) } - var found bool + var matchedService *rpservice.Service for _, service := range services { if service.Domain == redirectURL.Hostname() { - found = true + matchedService = service break } } - if !found { + if matchedService == nil { log.WithContext(ctx).Debugf("OIDC redirect URL %q does not match any service domain", redirectURL.Hostname()) return nil, status.Errorf(codes.FailedPrecondition, "service not found in store") } + useSessionCode := s.proxyManager.ClusterSupportsSessionCode(ctx, matchedService.ProxyCluster) + provider, err := oidc.NewProvider(ctx, s.oidcConfig.Issuer) if err != nil { log.WithContext(ctx).Errorf("failed to create OIDC provider: %v", err) @@ -1567,9 +1585,12 @@ func (s *ProxyServiceServer) GetOIDCURL(ctx context.Context, req *proto.GetOIDCU return nil, status.Errorf(codes.Internal, "generate nonce: %v", err) } nonceB64 := base64.URLEncoding.EncodeToString(nonce) + if useSessionCode { + nonceB64 = sessionCodeNoncePrefix + nonceB64 + } // Using an HMAC here to avoid redirection state being modified. - // State format: base64(redirectURL)|nonce|hmac(redirectURL|nonce) + // State format: base64(redirectURL)|[code.]nonce|hmac(redirectURL|nonce) payload := redirectURL.String() + "|" + nonceB64 hmacSum := s.generateHMAC(payload) state := fmt.Sprintf("%s|%s|%s", base64.URLEncoding.EncodeToString([]byte(redirectURL.String())), nonceB64, hmacSum) @@ -1612,15 +1633,12 @@ func (s *ProxyServiceServer) generateHMAC(input string) string { return hex.EncodeToString(mac.Sum(nil)) } -// ValidateState validates the state parameter from an OAuth callback. -// Returns the original redirect URL if valid, or an error if invalid. -// The HMAC is verified before consuming the PKCE verifier to prevent -// an attacker from invalidating a legitimate user's auth flow. -func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL string, err error) { - // State format: base64(redirectURL)|nonce|hmac(redirectURL|nonce) +// ValidateState validates and consumes an OIDC state. +func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL string, useSessionCode bool, err error) { + // State format: base64(redirectURL)|[code.]nonce|hmac(redirectURL|nonce) parts := strings.Split(state, "|") if len(parts) != 3 { - return "", "", errors.New("invalid state format") + return "", "", false, errors.New("invalid state format") } encodedURL := parts[0] @@ -1629,7 +1647,7 @@ func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL redirectURLBytes, err := base64.URLEncoding.DecodeString(encodedURL) if err != nil { - return "", "", fmt.Errorf("invalid state encoding: %w", err) + return "", "", false, fmt.Errorf("invalid state encoding: %w", err) } redirectURL = string(redirectURLBytes) @@ -1637,16 +1655,17 @@ func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL expectedHMAC := s.generateHMAC(payload) if !hmac.Equal([]byte(providedHMAC), []byte(expectedHMAC)) { - return "", "", errors.New("invalid state signature") + return "", "", false, errors.New("invalid state signature") } + useSessionCode = strings.HasPrefix(nonce, sessionCodeNoncePrefix) // Consume the PKCE verifier only after HMAC validation passes. verifier, ok := s.singleUseStore.LoadAndDelete(state) if !ok { - return "", "", errors.New("no verifier for state") + return "", "", false, errors.New("no verifier for state") } - return verifier, redirectURL, nil + return verifier, redirectURL, useSessionCode, nil } // Denied reasons reported to the proxy when access is refused because of the @@ -1851,7 +1870,20 @@ func (s *ProxyServiceServer) getAccountServiceByDomain(ctx context.Context, acco // ValidateSession validates a session token and checks if the user has access to the domain. func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.ValidateSessionRequest) (*proto.ValidateSessionResponse, error) { domain := req.GetDomain() - sessionToken := req.GetSessionToken() + sessionToken := req.GetSessionToken() //nolint:staticcheck + + // A one-time code from the OIDC callback is redeemed here for the durable + // token, so the token never travels in a redirect URL. The redeemed token + // is returned to the proxy (mintedToken) to install as the session cookie. + mintedToken := "" + if code := req.GetSessionCode(); code != "" { + redeemed, found := s.singleUseStore.LoadAndDelete(singleUseCacheKey(sessionCodeCacheNamespace, code)) + if !found { + return deniedSessionResponse("invalid or expired session code"), nil + } + sessionToken = redeemed + mintedToken = redeemed + } if domain == "" || sessionToken == "" { return deniedSessionResponse("missing domain or session_token"), nil @@ -1924,6 +1956,7 @@ func (s *ProxyServiceServer) ValidateSession(ctx context.Context, req *proto.Val UserEmail: user.Email, PeerGroupIds: groupIDs, PeerGroupNames: groupNames, + SessionToken: mintedToken, }, nil } diff --git a/management/internals/shared/grpc/proxy_test.go b/management/internals/shared/grpc/proxy_test.go index 060689da1..1e718a2c4 100644 --- a/management/internals/shared/grpc/proxy_test.go +++ b/management/internals/shared/grpc/proxy_test.go @@ -313,7 +313,7 @@ func TestValidateState_RejectsOldTwoPartFormat(t *testing.T) { err := s.singleUseStore.Store("base64url|hmac", "test", 10*time.Minute) require.NoError(t, err) - _, _, err = s.ValidateState("base64url|hmac") + _, _, _, err = s.ValidateState("base64url|hmac") require.Error(t, err) assert.Contains(t, err.Error(), "invalid state format") } @@ -385,11 +385,35 @@ func TestValidateState_RejectsInvalidHMAC(t *testing.T) { err := s.singleUseStore.Store("dGVzdA==|nonce|wrong-hmac", "test", 10*time.Minute) require.NoError(t, err) - _, _, err = s.ValidateState("dGVzdA==|nonce|wrong-hmac") + _, _, _, err = s.ValidateState("dGVzdA==|nonce|wrong-hmac") require.Error(t, err) assert.Contains(t, err.Error(), "invalid state signature") } +func TestSessionCodeCannotConsumeOIDCState(t *testing.T) { + const verifier = "pkce-verifier" + + store := NewSingleUseStore(context.Background(), testCacheStore(t)) + server := &ProxyServiceServer{ + oidcConfig: ProxyOIDCConfig{ + HMACKey: []byte("test-hmac-key"), + }, + singleUseStore: store, + } + state := generateState(server, "https://service.example.com/callback") + require.NoError(t, store.Store(state, verifier, time.Minute)) + + response, err := server.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + SessionCode: state, + }) + require.NoError(t, err) + assert.False(t, response.GetValid()) + + gotVerifier, _, _, err := server.ValidateState(state) + require.NoError(t, err) + assert.Equal(t, verifier, gotVerifier) +} + func TestSendServiceUpdateToCluster_FiltersOnCapability(t *testing.T) { tokenStore := NewOneTimeTokenStore(context.Background(), testCacheStore(t)) diff --git a/management/internals/shared/grpc/single_use.go b/management/internals/shared/grpc/single_use.go index 1698707d7..467ee6020 100644 --- a/management/internals/shared/grpc/single_use.go +++ b/management/internals/shared/grpc/single_use.go @@ -2,6 +2,8 @@ package grpc import ( "context" + "crypto/rand" + "encoding/base64" "fmt" "time" @@ -33,6 +35,24 @@ func (s *SingleUseStore) Store(key, value string, ttl time.Duration) error { return nil } +// Generate stores a value under a namespaced random key and returns the random key. +func (s *SingleUseStore) Generate(namespace, value string, ttl time.Duration) (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("generate single-use key: %w", err) + } + + key := base64.RawURLEncoding.EncodeToString(buf) + if err := s.Store(singleUseCacheKey(namespace, key), value, ttl); err != nil { + return "", err + } + return key, nil +} + +func singleUseCacheKey(namespace, key string) string { + return namespace + ":" + key +} + // LoadAndDelete retrieves and removes the value for a key. func (s *SingleUseStore) LoadAndDelete(key string) (string, bool) { value, found, err := s.cache.GetDel(s.ctx, key) diff --git a/management/internals/shared/grpc/single_use_test.go b/management/internals/shared/grpc/single_use_test.go index 357046720..8243ca43b 100644 --- a/management/internals/shared/grpc/single_use_test.go +++ b/management/internals/shared/grpc/single_use_test.go @@ -83,3 +83,40 @@ func TestSingleUseStoreLoadAndDelete(t *testing.T) { } }) } + +func TestSingleUseStore_GenerateAndConsumeOnce(t *testing.T) { + const namespace = "test" + s := NewSingleUseStore(context.Background(), testCacheStore(t)) + + key, err := s.Generate(namespace, "the-value", time.Minute) + if err != nil { + t.Fatalf("generate: %v", err) + } + if key == "" || key == "the-value" { + t.Fatalf("unexpected key %q", key) + } + + value, found := s.LoadAndDelete(singleUseCacheKey(namespace, key)) + if !found || value != "the-value" { + t.Fatalf("expected to load the stored value, got %q found=%v", value, found) + } + + if _, found := s.LoadAndDelete(singleUseCacheKey(namespace, key)); found { + t.Fatal("value must be consumed on first LoadAndDelete") + } +} + +func TestSingleUseStore_GenerateUniqueKeys(t *testing.T) { + s := NewSingleUseStore(context.Background(), testCacheStore(t)) + a, err := s.Generate("test", "v", time.Minute) + if err != nil { + t.Fatalf("generate a: %v", err) + } + b, err := s.Generate("test", "v", time.Minute) + if err != nil { + t.Fatalf("generate b: %v", err) + } + if a == b { + t.Fatal("generated keys must be distinct") + } +} diff --git a/management/internals/shared/grpc/validate_session_test.go b/management/internals/shared/grpc/validate_session_test.go index 2af250a85..4b36e74cf 100644 --- a/management/internals/shared/grpc/validate_session_test.go +++ b/management/internals/shared/grpc/validate_session_test.go @@ -634,6 +634,10 @@ func (m *testValidateSessionProxyManager) ClusterSupportsPrivate(_ context.Conte return nil } +func (m *testValidateSessionProxyManager) ClusterSupportsSessionCode(_ context.Context, _ string) bool { + return false +} + type testValidateSessionUsersManager struct { store store.Store } @@ -662,3 +666,47 @@ func (m *testValidateSessionUsersManager) GetUserWithGroups(ctx context.Context, } return user, groups, nil } + +func TestValidateSession_RedeemsSessionCode(t *testing.T) { + setup := setupValidateSessionTest(t) + defer setup.cleanup() + + proxy, err := setup.store.GetServiceByID(context.Background(), store.LockingStrengthNone, "testAccountId", "testProxyId") + require.NoError(t, err) + + token := createSessionToken(t, proxy.SessionPrivateKey, "allowedUserId", "test-proxy.example.com") + code, ok := setup.proxyService.GenerateSessionCode(token) + require.True(t, ok) + require.NotEqual(t, token, code, "code must not be the token itself") + + resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: "test-proxy.example.com", + SessionCode: code, + }) + require.NoError(t, err) + assert.True(t, resp.Valid, "redeemed code should authorize the user") + assert.Equal(t, "allowedUserId", resp.UserId) + assert.Equal(t, token, resp.GetSessionToken(), "response must carry the durable token for the cookie") + + // Single-use: the same code must not redeem again. + resp2, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: "test-proxy.example.com", + SessionCode: code, + }) + require.NoError(t, err) + assert.False(t, resp2.Valid, "a consumed code must be rejected") + assert.Empty(t, resp2.GetSessionToken()) +} + +func TestValidateSession_InvalidSessionCode(t *testing.T) { + setup := setupValidateSessionTest(t) + defer setup.cleanup() + + resp, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: "test-proxy.example.com", + SessionCode: "does-not-exist", + }) + require.NoError(t, err) + assert.False(t, resp.Valid) + assert.Empty(t, resp.GetSessionToken()) +} diff --git a/management/server/http/handlers/proxy/auth.go b/management/server/http/handlers/proxy/auth.go index 15327fd30..298fb503e 100644 --- a/management/server/http/handlers/proxy/auth.go +++ b/management/server/http/handlers/proxy/auth.go @@ -59,7 +59,7 @@ func (h *AuthCallbackHandler) handleCallback(w http.ResponseWriter, r *http.Requ state := r.URL.Query().Get("state") - codeVerifier, originalURL, err := h.proxyService.ValidateState(state) + codeVerifier, originalURL, useSessionCode, err := h.proxyService.ValidateState(state) if err != nil { log.WithError(err).Error("OAuth callback state validation failed") http.Error(w, "Invalid state parameter", http.StatusBadRequest) @@ -119,10 +119,19 @@ func (h *AuthCallbackHandler) handleCallback(w http.ResponseWriter, r *http.Requ redirectURL.Scheme = "https" query := redirectURL.Query() - query.Set("session_token", sessionToken) + if useSessionCode { + code, ok := h.proxyService.GenerateSessionCode(sessionToken) + if !ok { + http.Error(w, "Failed to create session", http.StatusInternalServerError) + return + } + query.Set("session_code", code) + } else { + query.Set("session_token", sessionToken) + } redirectURL.RawQuery = query.Encode() - log.WithField("redirect", redirectURL.Host).Debug("OAuth callback: redirecting user with session token") + log.WithField("redirect", redirectURL.Host).Debug("OAuth callback: redirecting user to proxy") http.Redirect(w, r, redirectURL.String(), http.StatusFound) } diff --git a/management/server/http/handlers/proxy/auth_callback_integration_test.go b/management/server/http/handlers/proxy/auth_callback_integration_test.go index 41ecb5f23..964841a63 100644 --- a/management/server/http/handlers/proxy/auth_callback_integration_test.go +++ b/management/server/http/handlers/proxy/auth_callback_integration_test.go @@ -181,6 +181,10 @@ func (m *testAccessLogManager) GetAllAccessLogs(_ context.Context, _, _ string, } func setupAuthCallbackTest(t *testing.T) *testSetup { + return setupAuthCallbackTestWithProxyManager(t, testSessionCodeManager{}) +} + +func setupAuthCallbackTestWithProxyManager(t *testing.T, proxyManager nbproxy.Manager) *testSetup { t.Helper() ctx := context.Background() @@ -217,7 +221,7 @@ func setupAuthCallbackTest(t *testing.T) *testSetup { nil, usersManager, nil, - nil, + proxyManager, nil, ) @@ -242,6 +246,15 @@ func setupAuthCallbackTest(t *testing.T) *testSetup { } } +type testSessionCodeManager struct { + nbproxy.Manager + supported bool +} + +func (m testSessionCodeManager) ClusterSupportsSessionCode(_ context.Context, _ string) bool { + return m.supported +} + func createTestReverseProxies(t *testing.T, ctx context.Context, testStore store.Store) { t.Helper() @@ -252,10 +265,11 @@ func createTestReverseProxies(t *testing.T, ctx context.Context, testStore store privKey := base64.StdEncoding.EncodeToString(priv) testProxy := &service.Service{ - ID: "testProxyId", - AccountID: "testAccountId", - Name: "Test Proxy", - Domain: "test-proxy.example.com", + ID: "testProxyId", + AccountID: "testAccountId", + Name: "Test Proxy", + Domain: "test-proxy.example.com", + ProxyCluster: "cluster.example.com", Targets: []*service.Target{{ Path: strPtr("/"), Host: "localhost", @@ -512,29 +526,56 @@ func createTestState(t *testing.T, ps *nbgrpc.ProxyServiceServer, redirectURL st } func TestAuthCallback_UserAllowedToLogin(t *testing.T) { - setup := setupAuthCallbackTest(t) - defer setup.cleanup() + tests := []struct { + name string + manager nbproxy.Manager + wantParam string + absentParam string + }{ + {name: "legacy proxy", manager: testSessionCodeManager{}, wantParam: "session_token", absentParam: "session_code"}, + {name: "compatible proxy", manager: testSessionCodeManager{supported: true}, wantParam: "session_code", absentParam: "session_token"}, + } - setup.oidcServer.tokenSubject = "allowedUserId" + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setup := setupAuthCallbackTestWithProxyManager(t, tt.manager) + defer setup.cleanup() - state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/dashboard") + setup.oidcServer.tokenSubject = "allowedUserId" + state := createTestState(t, setup.proxyService, "https://test-proxy.example.com/dashboard") + req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state="+url.QueryEscape(state), nil) + rec := httptest.NewRecorder() + setup.router.ServeHTTP(rec, req) + require.Equal(t, http.StatusFound, rec.Code) - req := httptest.NewRequest(http.MethodGet, "/reverse-proxy/callback?code=test-auth-code&state="+url.QueryEscape(state), nil) - rec := httptest.NewRecorder() + location, err := url.Parse(rec.Header().Get("Location")) + require.NoError(t, err) + require.Equal(t, "test-proxy.example.com", location.Host) + require.NotEmpty(t, location.Query().Get(tt.wantParam)) + require.Empty(t, location.Query().Get(tt.absentParam)) + require.Empty(t, location.Query().Get("error")) - setup.router.ServeHTTP(rec, req) + if tt.wantParam == "session_code" { + code := location.Query().Get("session_code") + response, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: location.Hostname(), + SessionCode: code, + }) + require.NoError(t, err) + require.True(t, response.GetValid()) + require.NotEmpty(t, response.GetSessionToken()) + require.NotEqual(t, code, response.GetSessionToken()) - require.Equal(t, http.StatusFound, rec.Code) - - location := rec.Header().Get("Location") - require.NotEmpty(t, location) - - parsedLocation, err := url.Parse(location) - require.NoError(t, err) - - require.Equal(t, "test-proxy.example.com", parsedLocation.Host) - require.NotEmpty(t, parsedLocation.Query().Get("session_token"), "Should include session token") - require.Empty(t, parsedLocation.Query().Get("error"), "Should not have error parameter") + replayed, err := setup.proxyService.ValidateSession(context.Background(), &proto.ValidateSessionRequest{ + Domain: location.Hostname(), + SessionCode: code, + }) + require.NoError(t, err) + require.False(t, replayed.GetValid()) + require.Empty(t, replayed.GetSessionToken()) + } + }) + } } // TestAuthCallback_UserDeniedByAccountStatus asserts that a user whose account diff --git a/management/server/store/sql_store_proxy.go b/management/server/store/sql_store_proxy.go index 5d1a9afba..58fa86468 100644 --- a/management/server/store/sql_store_proxy.go +++ b/management/server/store/sql_store_proxy.go @@ -367,6 +367,21 @@ func (s *SqlStore) GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr s return s.getClusterUnanimousCapability(ctx, clusterAddr, "supports_crowdsec") } +// GetActiveProxyVersions returns every active proxy version in a cluster. +func (s *SqlStore) GetActiveProxyVersions(ctx context.Context, clusterAddr string) ([]string, error) { + var versions []string + err := s.db.WithContext(ctx). + Model(&proxy.Proxy{}). + Where("LOWER(cluster_address) = LOWER(?) AND status = ? AND last_seen > ?", + clusterAddr, proxy.StatusConnected, time.Now().Add(-proxyActiveThreshold)). + Pluck("version", &versions).Error + if err != nil { + log.WithContext(ctx).Errorf("failed to get active proxy versions for %s: %v", clusterAddr, err) + return nil, status.Errorf(status.Internal, "get active proxy versions") + } + return versions, nil +} + // getClusterUnanimousCapability returns an aggregated boolean capability // requiring all active proxies in the cluster to report true. func (s *SqlStore) getClusterUnanimousCapability(ctx context.Context, clusterAddr, column string) *bool { diff --git a/management/server/store/store.go b/management/server/store/store.go index e935d2d88..bd55e3176 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -337,6 +337,7 @@ type Store interface { GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool + GetActiveProxyVersions(ctx context.Context, clusterAddr string) ([]string, error) CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error GetAllProxies(ctx context.Context) ([]*proxy.Proxy, error) DisconnectAllProxies(ctx context.Context) (int64, error) diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index 068ac6ff2..4dd7f9286 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -1539,6 +1539,21 @@ func (mr *MockStoreMockRecorder) GetActiveProxyClusterAddressesForAccount(ctx, a return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveProxyClusterAddressesForAccount", reflect.TypeOf((*MockStore)(nil).GetActiveProxyClusterAddressesForAccount), ctx, accountID) } +// GetActiveProxyVersions mocks base method. +func (m *MockStore) GetActiveProxyVersions(ctx context.Context, clusterAddr string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetActiveProxyVersions", ctx, clusterAddr) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetActiveProxyVersions indicates an expected call of GetActiveProxyVersions. +func (mr *MockStoreMockRecorder) GetActiveProxyVersions(ctx, clusterAddr any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveProxyVersions", reflect.TypeOf((*MockStore)(nil).GetActiveProxyVersions), ctx, clusterAddr) +} + // GetAgentNetworkAccessLogSessions mocks base method. func (m *MockStore) GetAgentNetworkAccessLogSessions(ctx context.Context, lockStrength LockingStrength, accountID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) { m.ctrl.T.Helper() diff --git a/proxy/internal/auth/middleware.go b/proxy/internal/auth/middleware.go index 311dd2cbb..672286748 100644 --- a/proxy/internal/auth/middleware.go +++ b/proxy/internal/auth/middleware.go @@ -46,7 +46,7 @@ type Scheme interface { // an authenticated user. An empty token indicates an unauthenticated // request; optionally, promptData may be returned for the login UI. // An error indicates an infrastructure failure (e.g. gRPC unavailable). - Authenticate(*http.Request) (token string, promptData string, err error) + Authenticate(*http.Request) (token, promptData string, err error) } // DomainConfig holds the authentication and restriction settings for a protected domain. @@ -77,6 +77,8 @@ type validationResult struct { // Groups for tokens minted before names were embedded; the consumer // falls back to ids for missing positions. GroupNames []string + // MintedToken is the session token issued when a one-time code is redeemed. + MintedToken string } // Middleware applies per-domain authentication and IP restriction checks. @@ -581,7 +583,8 @@ func (mw *Middleware) authenticateWithSchemes(w http.ResponseWriter, r *http.Req // handleAuthenticatedToken validates the token, handles denied access, and on // success sets a session cookie and redirects to the original URL. func (mw *Middleware) handleAuthenticatedToken(w http.ResponseWriter, r *http.Request, host, token string, config DomainConfig, scheme Scheme) { - result, err := mw.validateSessionToken(r.Context(), host, token, config.SessionPublicKey, scheme.Type()) + isCode := scheme.Type() == auth.MethodOIDC && r.URL.Query().Get("session_code") != "" + result, err := mw.validateSessionToken(r.Context(), host, token, isCode, config.SessionPublicKey, scheme.Type()) if err != nil { if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { cd.SetOrigin(proxy.OriginAuth) @@ -612,7 +615,13 @@ func (mw *Middleware) handleAuthenticatedToken(w http.ResponseWriter, r *http.Re return } - setSessionCookie(w, token, config.SessionExpiration) + // When a code was redeemed, the cookie must hold the durable token the + // server returned, not the single-use code. + cookieValue := token + if result.MintedToken != "" { + cookieValue = result.MintedToken + } + setSessionCookie(w, cookieValue, config.SessionExpiration) // Redirect instead of forwarding the auth POST to the backend. // The browser will follow with a GET carrying the new session cookie. @@ -652,7 +661,7 @@ func wasCredentialSubmitted(r *http.Request, method auth.Method) bool { case auth.MethodPassword: return credentialFormValue(r, passwordFormId) != "" case auth.MethodOIDC: - return r.URL.Query().Get("session_token") != "" + return r.URL.Query().Get("session_token") != "" || r.URL.Query().Get("session_code") != "" } return false } @@ -706,12 +715,15 @@ func (mw *Middleware) RemoveDomain(domain string) { // validateSessionToken validates a session token. OIDC tokens with a configured // validator go through gRPC for group access checks; other methods validate locally. -func (mw *Middleware) validateSessionToken(ctx context.Context, host, token string, publicKey ed25519.PublicKey, method auth.Method) (*validationResult, error) { +func (mw *Middleware) validateSessionToken(ctx context.Context, host, token string, isCode bool, publicKey ed25519.PublicKey, method auth.Method) (*validationResult, error) { if method == auth.MethodOIDC && mw.sessionValidator != nil { - resp, err := mw.sessionValidator.ValidateSession(ctx, &proto.ValidateSessionRequest{ - Domain: host, - SessionToken: token, - }) + req := &proto.ValidateSessionRequest{Domain: host} + if isCode { + req.SessionCode = token + } else { + req.SessionToken = token //nolint:staticcheck + } + resp, err := mw.sessionValidator.ValidateSession(ctx, req) if err != nil { return nil, fmt.Errorf("%w: %w", errValidationUnavailable, err) } @@ -729,11 +741,12 @@ func (mw *Middleware) validateSessionToken(ctx context.Context, host, token stri }, nil } return &validationResult{ - UserID: resp.UserId, - UserEmail: resp.GetUserEmail(), - Valid: true, - Groups: resp.GetPeerGroupIds(), - GroupNames: resp.GetPeerGroupNames(), + UserID: resp.UserId, + UserEmail: resp.GetUserEmail(), + Valid: true, + Groups: resp.GetPeerGroupIds(), + GroupNames: resp.GetPeerGroupNames(), + MintedToken: resp.GetSessionToken(), }, nil } @@ -788,14 +801,16 @@ func sessionGroupsAllowed(allowed map[string]struct{}, method auth.Method, group } } -// stripSessionTokenParam returns the request URI with the session_token query -// parameter removed so it doesn't linger in the browser's address bar or history. +// stripSessionTokenParam returns the request URI with the session hand-off +// query parameters removed so they don't linger in the browser's address bar +// or history. func stripSessionTokenParam(u *url.URL) string { q := u.Query() - if !q.Has("session_token") { + if !q.Has("session_token") && !q.Has("session_code") { return u.RequestURI() } q.Del("session_token") + q.Del("session_code") clean := *u clean.RawQuery = q.Encode() return clean.RequestURI() diff --git a/proxy/internal/auth/middleware_test.go b/proxy/internal/auth/middleware_test.go index f1242f95e..88c900f97 100644 --- a/proxy/internal/auth/middleware_test.go +++ b/proxy/internal/auth/middleware_test.go @@ -783,6 +783,12 @@ func TestWasCredentialSubmitted(t *testing.T) { query: url.Values{"session_token": {"abc123"}}, expected: true, }, + { + name: "OIDC code in query", + method: auth.MethodOIDC, + query: url.Values{"session_code": {"abc123"}}, + expected: true, + }, { name: "OIDC token not in query", method: auth.MethodOIDC, @@ -1571,3 +1577,23 @@ func TestProtect_TunnelPeerFastPath_TakesPathWithInboundMarker(t *testing.T) { assert.Equal(t, http.StatusOK, rec.Code, "a successful tunnel-peer validation must forward to the next handler") } + +func TestStripSessionTokenParam(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"strips session_token", "https://ex.com/p?a=1&session_token=tok", "/p?a=1"}, + {"strips session_code", "https://ex.com/p?a=1&session_code=code", "/p?a=1"}, + {"strips both", "https://ex.com/p?session_token=tok&session_code=code&a=1", "/p?a=1"}, + {"no-op when absent", "https://ex.com/p?a=1", "/p?a=1"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + u, err := url.Parse(tc.in) + require.NoError(t, err) + assert.Equal(t, tc.want, stripSessionTokenParam(u)) + }) + } +} diff --git a/proxy/internal/auth/oidc.go b/proxy/internal/auth/oidc.go index a60e6437a..739777924 100644 --- a/proxy/internal/auth/oidc.go +++ b/proxy/internal/auth/oidc.go @@ -40,9 +40,14 @@ func (OIDC) Type() auth.Method { // Authenticate checks for an OIDC session token or obtains the OIDC redirect URL. func (o OIDC) Authenticate(r *http.Request) (string, string, error) { - // Check for the session_token query param (from OIDC redirects). - // The management server passes the token in the URL because it cannot set - // cookies for the proxy's domain (cookies are domain-scoped per RFC 6265). + // Check for the session credential returned by the OIDC callback. The management + // server passes it in the URL because it cannot set a cookie for the proxy's + // domain (cookies are domain-scoped per RFC 6265). The current flow uses a + // single-use session_code to keep the durable token out of the URL. + // session_token remains supported for backward compatibility. + if code := r.URL.Query().Get("session_code"); code != "" { + return code, "", nil + } if token := r.URL.Query().Get("session_token"); token != "" { return token, "", nil } diff --git a/proxy/internal/proxy/reverseproxy.go b/proxy/internal/proxy/reverseproxy.go index 7c9e21261..7b0acd1b3 100644 --- a/proxy/internal/proxy/reverseproxy.go +++ b/proxy/internal/proxy/reverseproxy.go @@ -721,12 +721,13 @@ func stripSessionCookie(r *httputil.ProxyRequest) { } } -// stripSessionTokenQuery removes the OIDC session_token query parameter from -// the outgoing URL to prevent credential leakage to backends. +// stripSessionTokenQuery removes the OIDC session hand-off query parameters +// from the outgoing URL to prevent credential leakage to backends. func stripSessionTokenQuery(r *httputil.ProxyRequest) { q := r.Out.URL.Query() - if q.Has("session_token") { + if q.Has("session_token") || q.Has("session_code") { q.Del("session_token") + q.Del("session_code") r.Out.URL.RawQuery = q.Encode() } } diff --git a/proxy/management_integration_test.go b/proxy/management_integration_test.go index befd1f1ee..000d8ce72 100644 --- a/proxy/management_integration_test.go +++ b/proxy/management_integration_test.go @@ -131,7 +131,7 @@ func setupIntegrationTest(t *testing.T) *integrationTestSetup { HMACKey: []byte("test-hmac-key"), } - proxyManager := &testProxyManager{} + proxyManager := &testProxyManager{supportsSessionCode: true} proxyService := nbgrpc.NewProxyServiceServer( &testAccessLogManager{}, @@ -202,7 +202,9 @@ func (m *testAccessLogManager) GetAllAccessLogs(_ context.Context, _, _ string, } // testProxyManager is a mock implementation of proxy.Manager for testing. -type testProxyManager struct{} +type testProxyManager struct { + supportsSessionCode bool +} func (m *testProxyManager) Connect(_ context.Context, proxyID, sessionID, _, _, _ string, _ *string, _ *nbproxy.Capabilities) (*nbproxy.Proxy, error) { return &nbproxy.Proxy{ID: proxyID, SessionID: sessionID, Status: nbproxy.StatusConnected}, nil @@ -244,6 +246,10 @@ func (m *testProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) * return nil } +func (m *testProxyManager) ClusterSupportsSessionCode(_ context.Context, _ string) bool { + return m.supportsSessionCode +} + func (m *testProxyManager) CleanupStale(_ context.Context, _ time.Duration) error { return nil } diff --git a/proxy/web/web.go b/proxy/web/web.go index 6773a9c1a..a45fc8730 100644 --- a/proxy/web/web.go +++ b/proxy/web/web.go @@ -181,6 +181,7 @@ func ServeAccessDeniedPage(w http.ResponseWriter, r *http.Request, code int, tit func stripAuthParams(u *url.URL) string { q := u.Query() q.Del("session_token") + q.Del("session_code") q.Del("error") q.Del("error_description") clean := *u diff --git a/shared/management/proto/proxy_service.pb.go b/shared/management/proto/proxy_service.pb.go index 496774a4b..09dff0d36 100644 --- a/shared/management/proto/proxy_service.pb.go +++ b/shared/management/proto/proxy_service.pb.go @@ -2265,8 +2265,11 @@ type ValidateSessionRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Domain string `protobuf:"bytes,1,opt,name=domain,proto3" json:"domain,omitempty"` + Domain string `protobuf:"bytes,1,opt,name=domain,proto3" json:"domain,omitempty"` + // Deprecated: Do not use. SessionToken string `protobuf:"bytes,2,opt,name=session_token,json=sessionToken,proto3" json:"session_token,omitempty"` + // session_code is a short-lived, single-use code exchanged for a session token. + SessionCode string `protobuf:"bytes,3,opt,name=session_code,json=sessionCode,proto3" json:"session_code,omitempty"` } func (x *ValidateSessionRequest) Reset() { @@ -2308,6 +2311,7 @@ func (x *ValidateSessionRequest) GetDomain() string { return "" } +// Deprecated: Do not use. func (x *ValidateSessionRequest) GetSessionToken() string { if x != nil { return x.SessionToken @@ -2315,6 +2319,13 @@ func (x *ValidateSessionRequest) GetSessionToken() string { return "" } +func (x *ValidateSessionRequest) GetSessionCode() string { + if x != nil { + return x.SessionCode + } + return "" +} + type ValidateSessionResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -2333,6 +2344,8 @@ type ValidateSessionResponse struct { // Stamped onto upstream requests as X-NetBird-Groups so downstream // services can read names rather than opaque ids. PeerGroupNames []string `protobuf:"bytes,6,rep,name=peer_group_names,json=peerGroupNames,proto3" json:"peer_group_names,omitempty"` + // session_token contains the durable token issued when session_code is redeemed. + SessionToken string `protobuf:"bytes,7,opt,name=session_token,json=sessionToken,proto3" json:"session_token,omitempty"` } func (x *ValidateSessionResponse) Reset() { @@ -2409,6 +2422,13 @@ func (x *ValidateSessionResponse) GetPeerGroupNames() []string { return nil } +func (x *ValidateSessionResponse) GetSessionToken() string { + if x != nil { + return x.SessionToken + } + return "" +} + // ValidateTunnelPeerRequest carries the inbound peer's tunnel IP and the // service domain whose group requirements should gate access. The calling // account is inferred from the proxy's gRPC metadata (ProxyToken). @@ -3519,223 +3539,228 @@ var file_proxy_service_proto_rawDesc = []byte{ 0x09, 0x52, 0x0b, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x72, 0x6c, 0x22, 0x26, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22, 0x55, 0x0a, 0x16, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, + 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22, 0x7c, 0x0a, 0x16, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xdc, 0x01, - 0x0a, 0x17, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, - 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, + 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x27, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, + 0x02, 0x18, 0x01, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x64, + 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x43, 0x6f, 0x64, 0x65, 0x22, 0x81, 0x02, 0x0a, 0x17, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, + 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, + 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, + 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, + 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, + 0x73, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, + 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, + 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x70, 0x65, 0x65, + 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x06, 0x20, + 0x03, 0x28, 0x09, 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x4e, 0x61, + 0x6d, 0x65, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, + 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x50, 0x0a, 0x19, 0x56, 0x61, 0x6c, 0x69, + 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, + 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, + 0x49, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x84, 0x02, 0x0a, 0x1a, 0x56, + 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, + 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, - 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0e, - 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x05, - 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, - 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, - 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x70, 0x65, - 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x50, 0x0a, 0x19, - 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, - 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x75, 0x6e, - 0x6e, 0x65, 0x6c, 0x5f, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x75, - 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x84, - 0x02, 0x0a, 0x1a, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, - 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, - 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, - 0x6c, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, - 0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, - 0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x0c, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, - 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, - 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, - 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, - 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x70, - 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, - 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, - 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x81, 0x01, 0x0a, 0x13, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, - 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, 0x0a, - 0x04, 0x69, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, - 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x48, 0x00, 0x52, 0x04, 0x69, 0x6e, 0x69, - 0x74, 0x12, 0x2f, 0x0a, 0x03, 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, - 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x48, 0x00, 0x52, 0x03, 0x61, - 0x63, 0x6b, 0x42, 0x05, 0x0a, 0x03, 0x6d, 0x73, 0x67, 0x22, 0xdf, 0x01, 0x0a, 0x10, 0x53, 0x79, - 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x12, 0x19, - 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x07, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, - 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, - 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x61, - 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, - 0x61, 0x6d, 0x70, 0x52, 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x18, - 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, - 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, - 0x79, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, 0x0c, 0x63, - 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0x11, 0x0a, 0x0f, 0x53, - 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x22, 0x7e, - 0x0a, 0x14, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, - 0x67, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, - 0x67, 0x52, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x32, 0x0a, 0x15, 0x69, 0x6e, - 0x69, 0x74, 0x69, 0x61, 0x6c, 0x5f, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, - 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, 0x6e, 0x69, 0x74, 0x69, - 0x61, 0x6c, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x22, 0xa9, - 0x01, 0x0a, 0x1b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, - 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, - 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x17, 0x0a, - 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, - 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, - 0x69, 0x64, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, - 0x49, 0x64, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x5f, - 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, - 0x65, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x22, 0xff, 0x01, 0x0a, 0x1c, 0x43, + 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x0d, + 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, + 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, + 0x69, 0x64, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, + 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x70, 0x65, 0x65, 0x72, 0x5f, + 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x4e, 0x61, 0x6d, 0x65, + 0x73, 0x22, 0x81, 0x01, 0x0a, 0x13, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, + 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, 0x0a, 0x04, 0x69, 0x6e, 0x69, + 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, + 0x73, 0x49, 0x6e, 0x69, 0x74, 0x48, 0x00, 0x52, 0x04, 0x69, 0x6e, 0x69, 0x74, 0x12, 0x2f, 0x0a, + 0x03, 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, + 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x48, 0x00, 0x52, 0x03, 0x61, 0x63, 0x6b, 0x42, 0x05, + 0x0a, 0x03, 0x6d, 0x73, 0x67, 0x22, 0xdf, 0x01, 0x0a, 0x10, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, + 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x70, 0x72, + 0x6f, 0x78, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x72, + 0x6f, 0x78, 0x79, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, + 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, + 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, + 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, + 0x72, 0x65, 0x73, 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, + 0x74, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x43, 0x61, 0x70, + 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, + 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0x11, 0x0a, 0x0f, 0x53, 0x79, 0x6e, 0x63, 0x4d, + 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x22, 0x7e, 0x0a, 0x14, 0x53, 0x79, + 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x32, 0x0a, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x52, 0x07, 0x6d, + 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x32, 0x0a, 0x15, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, + 0x6c, 0x5f, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x53, 0x79, + 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x22, 0xa9, 0x01, 0x0a, 0x1b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, - 0x69, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x64, - 0x65, 0x63, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, - 0x65, 0x63, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x73, 0x65, 0x6c, 0x65, 0x63, - 0x74, 0x65, 0x64, 0x5f, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x10, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x6c, - 0x69, 0x63, 0x79, 0x49, 0x64, 0x12, 0x30, 0x0a, 0x14, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, - 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x12, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x69, 0x6f, 0x6e, - 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x77, 0x69, 0x6e, 0x64, 0x6f, - 0x77, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, - 0x0d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x1b, - 0x0a, 0x09, 0x64, 0x65, 0x6e, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x08, 0x64, 0x65, 0x6e, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x64, - 0x65, 0x6e, 0x79, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0a, 0x64, 0x65, 0x6e, 0x79, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0x91, 0x02, 0x0a, - 0x15, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, - 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, - 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x19, - 0x0a, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x07, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x77, 0x69, 0x6e, - 0x64, 0x6f, 0x77, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, - 0x03, 0x52, 0x0d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, - 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, - 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x49, 0x6e, - 0x70, 0x75, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x6f, 0x75, - 0x74, 0x70, 0x75, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, - 0x6e, 0x73, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x63, 0x6f, 0x73, 0x74, - 0x5f, 0x75, 0x73, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x01, 0x52, 0x07, 0x63, 0x6f, 0x73, 0x74, - 0x55, 0x73, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, - 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, - 0x22, 0x18, 0x0a, 0x16, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, - 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2a, 0x64, 0x0a, 0x16, 0x50, 0x72, - 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, - 0x54, 0x79, 0x70, 0x65, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, - 0x59, 0x50, 0x45, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a, - 0x14, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4d, 0x4f, 0x44, - 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, - 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x4d, 0x4f, 0x56, 0x45, 0x44, 0x10, 0x02, - 0x2a, 0x46, 0x0a, 0x0f, 0x50, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x4d, - 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, - 0x49, 0x54, 0x45, 0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, 0x19, 0x0a, - 0x15, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, 0x50, 0x52, - 0x45, 0x53, 0x45, 0x52, 0x56, 0x45, 0x10, 0x01, 0x2a, 0x90, 0x01, 0x0a, 0x0e, 0x4d, 0x69, 0x64, - 0x64, 0x6c, 0x65, 0x77, 0x61, 0x72, 0x65, 0x53, 0x6c, 0x6f, 0x74, 0x12, 0x1f, 0x0a, 0x1b, 0x4d, - 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x55, - 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x1e, 0x0a, 0x1a, - 0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, - 0x4f, 0x4e, 0x5f, 0x52, 0x45, 0x51, 0x55, 0x45, 0x53, 0x54, 0x10, 0x01, 0x12, 0x1f, 0x0a, 0x1b, - 0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, - 0x4f, 0x4e, 0x5f, 0x52, 0x45, 0x53, 0x50, 0x4f, 0x4e, 0x53, 0x45, 0x10, 0x02, 0x12, 0x1c, 0x0a, - 0x18, 0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, - 0x5f, 0x54, 0x45, 0x52, 0x4d, 0x49, 0x4e, 0x41, 0x4c, 0x10, 0x03, 0x2a, 0xc8, 0x01, 0x0a, 0x0b, - 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x14, 0x50, - 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x50, 0x45, 0x4e, 0x44, - 0x49, 0x4e, 0x47, 0x10, 0x00, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, - 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, 0x23, - 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x54, - 0x55, 0x4e, 0x4e, 0x45, 0x4c, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, - 0x44, 0x10, 0x02, 0x12, 0x24, 0x0a, 0x20, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, - 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, - 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, - 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, - 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x16, - 0x0a, 0x12, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x45, - 0x52, 0x52, 0x4f, 0x52, 0x10, 0x05, 0x32, 0xfc, 0x07, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, - 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x5f, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4d, 0x61, - 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, - 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, - 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x55, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, - 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, - 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, - 0x6e, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x12, - 0x54, 0x0a, 0x0d, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, - 0x12, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, - 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x51, 0x0a, 0x0c, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, - 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5d, 0x0a, 0x10, 0x53, 0x65, 0x6e, 0x64, - 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, - 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, 0x74, - 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, + 0x69, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, + 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, + 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, + 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, + 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, + 0x1f, 0x0a, 0x0b, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x49, 0x64, + 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x22, 0xff, 0x01, 0x0a, 0x1c, 0x43, 0x68, 0x65, 0x63, 0x6b, + 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x64, 0x65, 0x63, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x65, 0x63, 0x69, 0x73, + 0x69, 0x6f, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, + 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x10, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, + 0x64, 0x12, 0x30, 0x0a, 0x14, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x69, 0x6f, 0x6e, + 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x12, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x47, 0x72, 0x6f, 0x75, + 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x5f, 0x73, 0x65, + 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x77, 0x69, 0x6e, + 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x65, + 0x6e, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, + 0x65, 0x6e, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x64, 0x65, 0x6e, 0x79, 0x5f, + 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x64, 0x65, + 0x6e, 0x79, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0x91, 0x02, 0x0a, 0x15, 0x52, 0x65, 0x63, + 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, + 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x67, 0x72, + 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x67, 0x72, + 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x5f, + 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x77, + 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x21, 0x0a, 0x0c, + 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, 0x05, 0x20, 0x01, + 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, + 0x23, 0x0a, 0x0d, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, + 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x4f, 0x75, + 0x74, 0x70, 0x75, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x63, 0x6f, 0x73, 0x74, 0x5f, 0x75, 0x73, 0x64, + 0x18, 0x07, 0x20, 0x01, 0x28, 0x01, 0x52, 0x07, 0x63, 0x6f, 0x73, 0x74, 0x55, 0x73, 0x64, 0x12, + 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x08, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x22, 0x18, 0x0a, 0x16, + 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2a, 0x64, 0x0a, 0x16, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, + 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, + 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, + 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a, 0x14, 0x55, 0x50, 0x44, + 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4d, 0x4f, 0x44, 0x49, 0x46, 0x49, 0x45, + 0x44, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, + 0x50, 0x45, 0x5f, 0x52, 0x45, 0x4d, 0x4f, 0x56, 0x45, 0x44, 0x10, 0x02, 0x2a, 0x46, 0x0a, 0x0f, + 0x50, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x4d, 0x6f, 0x64, 0x65, 0x12, + 0x18, 0x0a, 0x14, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, + 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x41, 0x54, + 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, 0x50, 0x52, 0x45, 0x53, 0x45, 0x52, + 0x56, 0x45, 0x10, 0x01, 0x2a, 0x90, 0x01, 0x0a, 0x0e, 0x4d, 0x69, 0x64, 0x64, 0x6c, 0x65, 0x77, + 0x61, 0x72, 0x65, 0x53, 0x6c, 0x6f, 0x74, 0x12, 0x1f, 0x0a, 0x1b, 0x4d, 0x49, 0x44, 0x44, 0x4c, + 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, + 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x1e, 0x0a, 0x1a, 0x4d, 0x49, 0x44, 0x44, + 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x4f, 0x4e, 0x5f, 0x52, + 0x45, 0x51, 0x55, 0x45, 0x53, 0x54, 0x10, 0x01, 0x12, 0x1f, 0x0a, 0x1b, 0x4d, 0x49, 0x44, 0x44, + 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x4f, 0x4e, 0x5f, 0x52, + 0x45, 0x53, 0x50, 0x4f, 0x4e, 0x53, 0x45, 0x10, 0x02, 0x12, 0x1c, 0x0a, 0x18, 0x4d, 0x49, 0x44, + 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x54, 0x45, 0x52, + 0x4d, 0x49, 0x4e, 0x41, 0x4c, 0x10, 0x03, 0x2a, 0xc8, 0x01, 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x78, + 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x52, 0x4f, 0x58, 0x59, + 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, + 0x00, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, + 0x53, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, + 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x54, 0x55, 0x4e, 0x4e, 0x45, + 0x4c, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, + 0x24, 0x0a, 0x20, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, + 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x50, 0x45, 0x4e, 0x44, + 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, + 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, + 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x16, 0x0a, 0x12, 0x50, 0x52, + 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x45, 0x52, 0x52, 0x4f, 0x52, + 0x10, 0x05, 0x32, 0xfc, 0x07, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x12, 0x5f, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, + 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, + 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x30, 0x01, 0x12, 0x55, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, + 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x12, 0x54, 0x0a, 0x0d, 0x53, + 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x20, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, + 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, + 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x51, 0x0a, 0x0c, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, + 0x65, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, + 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5d, 0x0a, 0x10, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, + 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, + 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, + 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, - 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, - 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, - 0x4c, 0x12, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, - 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, - 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x5a, 0x0a, 0x0f, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x4b, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x12, 0x1d, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, + 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, + 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, + 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, + 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, + 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x12, - 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, - 0x65, 0x72, 0x12, 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, - 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, - 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x69, 0x0a, 0x14, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, - 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x12, 0x27, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, - 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, - 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x57, 0x0a, 0x0e, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x21, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x63, 0x6f, - 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, - 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, - 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x12, 0x56, 0x61, 0x6c, 0x69, + 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x12, 0x25, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, + 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, + 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x69, 0x0a, + 0x14, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, + 0x69, 0x6d, 0x69, 0x74, 0x73, 0x12, 0x27, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, + 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, + 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x57, 0x0a, 0x0e, 0x52, 0x65, 0x63, 0x6f, + 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x21, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, + 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x33, } var ( diff --git a/shared/management/proto/proxy_service.proto b/shared/management/proto/proxy_service.proto index facadc4d5..7c5fb08eb 100644 --- a/shared/management/proto/proxy_service.proto +++ b/shared/management/proto/proxy_service.proto @@ -363,7 +363,9 @@ message GetOIDCURLResponse { message ValidateSessionRequest { string domain = 1; - string session_token = 2; + string session_token = 2 [deprecated = true]; + // session_code is a short-lived, single-use code exchanged for a session token. + string session_code = 3; } message ValidateSessionResponse { @@ -380,6 +382,8 @@ message ValidateSessionResponse { // Stamped onto upstream requests as X-NetBird-Groups so downstream // services can read names rather than opaque ids. repeated string peer_group_names = 6; + // session_token contains the durable token issued when session_code is redeemed. + string session_token = 7; } // ValidateTunnelPeerRequest carries the inbound peer's tunnel IP and the @@ -506,4 +510,3 @@ message RecordLLMUsageRequest { message RecordLLMUsageResponse { } -