mirror of
https://github.com/juanfont/headscale.git
synced 2026-11-04 04:34:07 +09:00
policy/matcher, types: compare a node's routes to matchers as ranges
This commit is contained in:
1 parent
926998d553
commit
05a5c11a20
5 files changed
+304
-41
No files matched your search
@@ -119,6 +119,26 @@ func (m *Match) DestsOverlapsPrefixes(prefixes ...netip.Prefix) bool {
|
||||
})
|
||||
}
|
||||
|
||||
// SrcsOverlapsRanges reports whether the sources overlap sorted, disjoint ranges.
|
||||
func (m *Match) SrcsOverlapsRanges(ranges []netipx.IPRange) bool {
|
||||
return rangesOverlap(m.srcRanges, ranges)
|
||||
}
|
||||
|
||||
// DestsOverlapsRanges reports whether the destinations overlap sorted, disjoint ranges.
|
||||
func (m *Match) DestsOverlapsRanges(ranges []netipx.IPRange) bool {
|
||||
return rangesOverlap(m.destRanges, ranges)
|
||||
}
|
||||
|
||||
func rangesOverlap(a, b []netipx.IPRange) bool {
|
||||
if len(a) > len(b) {
|
||||
a, b = b, a
|
||||
}
|
||||
|
||||
return slices.ContainsFunc(a, func(r netipx.IPRange) bool {
|
||||
return overlaps(b, r)
|
||||
})
|
||||
}
|
||||
|
||||
func overlaps(ranges []netipx.IPRange, r netipx.IPRange) bool {
|
||||
if !r.IsValid() {
|
||||
return false
|
||||
|
||||
@@ -1,50 +1,266 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/policy/matcher"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go4.org/netipx"
|
||||
"pgregory.net/rapid"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// TestCanAccessWithRoutesMatchesCanAccess guards the peer-map optimization:
|
||||
// CanAccessWithRoutes, fed the same route data CanAccess computes internally,
|
||||
// must produce identical results. BuildPeerMap relies on this to precompute
|
||||
// each node's routes once instead of per pair.
|
||||
func TestCanAccessWithRoutesMatchesCanAccess(t *testing.T) {
|
||||
user := types.User{Name: "u"}
|
||||
func TestRouteRanges(t *testing.T) {
|
||||
prefixes := []netip.Prefix{
|
||||
netip.MustParsePrefix("2001:db8::2/128"),
|
||||
netip.MustParsePrefix("10.0.0.1/32"),
|
||||
{},
|
||||
netip.MustParsePrefix("10.0.0.0/31"),
|
||||
netip.MustParsePrefix("2001:db8::1/128"),
|
||||
netip.MustParsePrefix("10.0.0.1/32"),
|
||||
}
|
||||
before := slices.Clone(prefixes)
|
||||
want := []netipx.IPRange{
|
||||
netipx.IPRangeFrom(
|
||||
netip.MustParseAddr("10.0.0.0"), netip.MustParseAddr("10.0.0.1")),
|
||||
netipx.IPRangeFrom(
|
||||
netip.MustParseAddr("2001:db8::1"), netip.MustParseAddr("2001:db8::2")),
|
||||
}
|
||||
|
||||
subnetRouter := node("subnet", "100.64.0.1", "fd7a:115c:a1e0::1", user)
|
||||
subnetRouter.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")}}
|
||||
subnetRouter.ApprovedRoutes = []netip.Prefix{netip.MustParsePrefix("10.0.0.0/24")}
|
||||
require.Equal(t, want, types.RouteRanges(prefixes))
|
||||
require.Equal(t, before, prefixes)
|
||||
require.Empty(t, types.RouteRanges(nil))
|
||||
require.Empty(t, types.RouteRanges([]netip.Prefix{{}}))
|
||||
}
|
||||
|
||||
exitNode := node("exit", "100.64.0.2", "fd7a:115c:a1e0::2", user)
|
||||
exitNode.Hostinfo = &tailcfg.Hostinfo{RoutableIPs: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}}
|
||||
exitNode.ApprovedRoutes = []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}
|
||||
func TestMatcherRouteRangesMatchPrefixes(t *testing.T) {
|
||||
prefixStrings := []string{
|
||||
"10.0.0.0/32", "10.0.0.1/32", "10.0.0.3/32", "10.0.0.0/24",
|
||||
"10.0.2.0/24", "10.0.0.7/24", "192.168.0.0/16", "100.64.0.1/32",
|
||||
"2001:db8::1/128", "2001:db8::2/128", "2001:db8::4/128",
|
||||
"2001:db8::/64", "2001:db8:1::/64", "::ffff:192.0.2.0/120",
|
||||
"0.0.0.0/0", "::/0",
|
||||
}
|
||||
|
||||
plain := node("plain", "100.64.0.3", "fd7a:115c:a1e0::3", user)
|
||||
prefixes := make([]netip.Prefix, len(prefixStrings)+1)
|
||||
for i, text := range prefixStrings {
|
||||
prefixes[i+1] = netip.MustParsePrefix(text)
|
||||
}
|
||||
|
||||
matchers := matcher.MatchesFromFilterRules([]tailcfg.FilterRule{
|
||||
{
|
||||
SrcIPs: []string{"*"},
|
||||
DstPorts: []tailcfg.NetPortRange{{IP: "*", Ports: tailcfg.PortRangeAny}},
|
||||
},
|
||||
rapid.Check(t, func(rt *rapid.T) {
|
||||
sources := rapid.SliceOfN(rapid.SampledFrom(prefixStrings), 0, 12).
|
||||
Draw(rt, "sources")
|
||||
destinations := rapid.SliceOfN(rapid.SampledFrom(prefixStrings), 0, 12).
|
||||
Draw(rt, "destinations")
|
||||
routes := rapid.SliceOfN(rapid.SampledFrom(prefixes), 0, 32).Draw(rt, "routes")
|
||||
before := slices.Clone(routes)
|
||||
ranges := types.RouteRanges(routes)
|
||||
rule := tailcfg.FilterRule{SrcIPs: sources}
|
||||
|
||||
for _, destination := range destinations {
|
||||
rule.DstPorts = append(rule.DstPorts, tailcfg.NetPortRange{
|
||||
IP: destination, Ports: tailcfg.PortRangeAny,
|
||||
})
|
||||
}
|
||||
|
||||
m := matcher.MatchFromFilterRule(rule)
|
||||
require.Equal(rt, m.SrcsOverlapsPrefixes(routes...),
|
||||
m.SrcsOverlapsRanges(ranges))
|
||||
require.Equal(rt, m.DestsOverlapsPrefixes(routes...),
|
||||
m.DestsOverlapsRanges(ranges))
|
||||
require.Equal(rt, before, routes)
|
||||
})
|
||||
}
|
||||
|
||||
views := []types.NodeView{subnetRouter.View(), exitNode.View(), plain.View()}
|
||||
func TestBuildPeerMapMatchesPrefixReference(t *testing.T) {
|
||||
users := []types.User{
|
||||
{ID: 1, Name: "u1"}, {ID: 2, Name: "u2"}, {ID: 3, Name: "u3"},
|
||||
}
|
||||
routePool := []netip.Prefix{
|
||||
netip.MustParsePrefix("10.1.0.0/24"),
|
||||
netip.MustParsePrefix("10.1.1.0/24"),
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
netip.MustParsePrefix("192.168.0.0/16"),
|
||||
netip.MustParsePrefix("2001:db8::/64"),
|
||||
netip.MustParsePrefix("2001:db8:1::/64"),
|
||||
netip.MustParsePrefix("2001:db8::/32"),
|
||||
netip.MustParsePrefix("0.0.0.0/0"),
|
||||
netip.MustParsePrefix("::/0"),
|
||||
}
|
||||
policies := []struct{ name, policy string }{
|
||||
{name: "global", policy: benchPolicies[0].policy},
|
||||
{name: "self", policy: benchPolicies[1].policy},
|
||||
{name: "via", policy: benchPolicies[2].policy},
|
||||
{name: "subnet source", policy: `{
|
||||
"acls": [{"action": "accept", "src": ["10.0.0.0/8", "2001:db8::/32"],
|
||||
"dst": ["u2@:*"]}]}`},
|
||||
{name: "allow all", policy: `{
|
||||
"acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}]}`},
|
||||
}
|
||||
|
||||
for _, a := range views {
|
||||
for _, b := range views {
|
||||
if a.ID() == b.ID() {
|
||||
continue
|
||||
rapid.Check(t, func(rt *rapid.T) {
|
||||
nodes := make(types.Nodes, rapid.IntRange(5, 12).Draw(rt, "nodes"))
|
||||
for i := range nodes {
|
||||
u := users[rapid.IntRange(0, len(users)-1).Draw(rt, "user")]
|
||||
|
||||
switch i {
|
||||
case 0, 4:
|
||||
u = users[0]
|
||||
case 2:
|
||||
u = users[1]
|
||||
}
|
||||
|
||||
want := a.CanAccess(matchers, b)
|
||||
got := a.CanAccessWithRoutes(matchers, b, a.SubnetRoutes(), b.SubnetRoutes(), b.IsExitNode())
|
||||
assert.Equalf(t, want, got, "%s -> %s", a.Hostname(), b.Hostname())
|
||||
n := node(fmt.Sprint("n", i), fmt.Sprint("100.64.0.", i+1),
|
||||
fmt.Sprintf("fd7a:115c:a1e0::%x", i+1), u)
|
||||
n.ID = types.NodeID(i + 1) //nolint:gosec
|
||||
|
||||
switch rapid.IntRange(0, 2).Draw(rt, "family") {
|
||||
case 1:
|
||||
n.IPv4 = nil
|
||||
case 2:
|
||||
n.IPv6 = nil
|
||||
}
|
||||
|
||||
switch i {
|
||||
case 1:
|
||||
n.Tags = []string{"tag:router"}
|
||||
case 3:
|
||||
n.Tags = []string{"tag:srv"}
|
||||
}
|
||||
|
||||
if n.IsTagged() {
|
||||
n.User, n.UserID = nil, nil
|
||||
}
|
||||
|
||||
n.Hostinfo = &tailcfg.Hostinfo{
|
||||
RoutableIPs: rapid.SliceOfN(rapid.SampledFrom(routePool), 0, 8).
|
||||
Draw(rt, "announced"),
|
||||
}
|
||||
n.ApprovedRoutes = rapid.SliceOfN(rapid.SampledFrom(routePool), 0, 8).
|
||||
Draw(rt, "approved")
|
||||
|
||||
switch i {
|
||||
case 1:
|
||||
n.Hostinfo.RoutableIPs = append(n.Hostinfo.RoutableIPs,
|
||||
routePool[0], routePool[5])
|
||||
n.ApprovedRoutes = append(n.ApprovedRoutes, routePool[0], routePool[5])
|
||||
case 2:
|
||||
n.Hostinfo.RoutableIPs = append(n.Hostinfo.RoutableIPs,
|
||||
routePool[7:]...)
|
||||
n.ApprovedRoutes = append(n.ApprovedRoutes, routePool[7:]...)
|
||||
}
|
||||
|
||||
nodes[i] = n
|
||||
}
|
||||
|
||||
for _, pol := range policies {
|
||||
pm, err := NewPolicyManager([]byte(pol.policy), users, nodes.ViewSlice())
|
||||
require.NoError(rt, err)
|
||||
require.Equal(rt, peerMapPrefixReference(pm, nodes),
|
||||
pm.BuildPeerMap(nodes.ViewSlice()), pol.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func peerMapPrefixReference(
|
||||
pm *PolicyManager,
|
||||
nodes types.Nodes,
|
||||
) map[types.NodeID][]types.NodeID {
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
|
||||
nodeMatchers := make(map[types.NodeID][]matcher.Match, len(nodes))
|
||||
for _, n := range nodes {
|
||||
if pm.needsPerNodeFilter {
|
||||
nodeMatchers[n.ID] = matcher.MatchesFromFilterRules(
|
||||
pm.filterRulesForNodeLocked(n.View()))
|
||||
} else {
|
||||
nodeMatchers[n.ID] = pm.matchers
|
||||
}
|
||||
}
|
||||
|
||||
peers := make(map[types.NodeID][]types.NodeID)
|
||||
|
||||
for i, a := range nodes {
|
||||
for _, b := range nodes[i+1:] {
|
||||
mi, mj := nodeMatchers[a.ID], nodeMatchers[b.ID]
|
||||
if canAccessPrefixReference(a, b, mi) ||
|
||||
canAccessPrefixReference(b, a, mi) ||
|
||||
canAccessPrefixReference(a, b, mj) ||
|
||||
canAccessPrefixReference(b, a, mj) {
|
||||
peers[a.ID] = append(peers[a.ID], b.ID)
|
||||
peers[b.ID] = append(peers[b.ID], a.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return peers
|
||||
}
|
||||
|
||||
func canAccessPrefixReference(a, b *types.Node, matchers []matcher.Match) bool {
|
||||
src, dst := a.IPs(), b.IPs()
|
||||
srcRoutes, dstRoutes := a.SubnetRoutes(), b.SubnetRoutes()
|
||||
|
||||
for _, m := range matchers {
|
||||
srcMatchesIP := m.SrcsContainsIPs(src...)
|
||||
srcMatchesRoutes := len(srcRoutes) > 0 && m.SrcsOverlapsPrefixes(srcRoutes...)
|
||||
|
||||
if !srcMatchesIP && !srcMatchesRoutes {
|
||||
continue
|
||||
}
|
||||
|
||||
if m.DestsContainsIP(dst...) {
|
||||
return true
|
||||
}
|
||||
|
||||
if len(dstRoutes) > 0 && m.DestsOverlapsPrefixes(dstRoutes...) {
|
||||
return true
|
||||
}
|
||||
|
||||
if b.IsExitNode() && m.DestsIsTheInternet() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func BenchmarkBuildPeerMapManyRoutes(b *testing.B) {
|
||||
for _, pol := range benchPolicies {
|
||||
for _, n := range []int{64, 128} {
|
||||
b.Run(fmt.Sprintf("%s/n=%d", pol.name, n), func(b *testing.B) {
|
||||
users, nodes := benchNodes(n)
|
||||
for _, node := range nodes {
|
||||
node.Hostinfo, node.ApprovedRoutes = nil, nil
|
||||
}
|
||||
|
||||
routes := make([]netip.Prefix, 10000)
|
||||
for i := range routes {
|
||||
ip := netip.AddrFrom4([4]byte{
|
||||
10, 100, byte((i >> 7) & 0xff), byte((i << 1) & 0xff),
|
||||
})
|
||||
routes[i] = netip.PrefixFrom(ip, 32)
|
||||
}
|
||||
|
||||
nodes[0].Hostinfo = &tailcfg.Hostinfo{RoutableIPs: routes}
|
||||
nodes[0].ApprovedRoutes = routes
|
||||
views := nodes.ViewSlice()
|
||||
pm, err := NewPolicyManager([]byte(pol.policy), users, views)
|
||||
require.NoError(b, err)
|
||||
|
||||
rules, err := pm.FilterForNode(nodes[0].View())
|
||||
require.NoError(b, err)
|
||||
require.Equal(b, pol.routerFiltered, len(rules) > 0)
|
||||
b.ReportAllocs()
|
||||
|
||||
for b.Loop() {
|
||||
pm.BuildPeerMap(views)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/juanfont/headscale/hscontrol/policy/matcher"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go4.org/netipx"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
@@ -22,13 +23,15 @@ func buildPeerMapPairScan(pm *PolicyManager, nodes views.Slice[types.NodeView])
|
||||
defer pm.mu.RUnlock()
|
||||
|
||||
type nodeRoutes struct {
|
||||
subnet []netip.Prefix
|
||||
subnet []netipx.IPRange
|
||||
isExit bool
|
||||
}
|
||||
|
||||
routeInfo := make(map[types.NodeID]nodeRoutes, nodes.Len())
|
||||
for _, n := range nodes.All() {
|
||||
routeInfo[n.ID()] = nodeRoutes{subnet: n.SubnetRoutes(), isExit: n.IsExitNode()}
|
||||
routeInfo[n.ID()] = nodeRoutes{
|
||||
subnet: types.RouteRanges(n.SubnetRoutes()), isExit: n.IsExitNode(),
|
||||
}
|
||||
}
|
||||
|
||||
ret := make(map[types.NodeID][]types.NodeID, nodes.Len())
|
||||
|
||||
@@ -781,16 +781,18 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
|
||||
// Precompute each node's subnet routes and exit-node status once; the
|
||||
// Precompute each node's route ranges and exit-node status once; the
|
||||
// O(n^2) pair scans below would otherwise recompute them for every pair.
|
||||
type nodeRoutes struct {
|
||||
subnet []netip.Prefix
|
||||
subnet []netipx.IPRange
|
||||
isExit bool
|
||||
}
|
||||
|
||||
routeInfo := make(map[types.NodeID]nodeRoutes, nodes.Len())
|
||||
for _, n := range nodes.All() {
|
||||
routeInfo[n.ID()] = nodeRoutes{subnet: n.SubnetRoutes(), isExit: n.IsExitNode()}
|
||||
routeInfo[n.ID()] = nodeRoutes{
|
||||
subnet: types.RouteRanges(n.SubnetRoutes()), isExit: n.IsExitNode(),
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a global filter, use it for all nodes (normal case).
|
||||
@@ -825,7 +827,7 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ
|
||||
for k := range pm.matchers {
|
||||
m := &pm.matchers[k]
|
||||
|
||||
if m.SrcsContainsIPs(ips...) || (routed && m.SrcsOverlapsPrefixes(ri.subnet...)) {
|
||||
if m.SrcsContainsIPs(ips...) || (routed && m.SrcsOverlapsRanges(ri.subnet)) {
|
||||
if srcOf[i] == nil {
|
||||
srcOf[i] = make([]uint64, words)
|
||||
}
|
||||
@@ -833,7 +835,7 @@ func (pm *PolicyManager) BuildPeerMap(nodes views.Slice[types.NodeView]) map[typ
|
||||
srcOf[i][k/64] |= 1 << (k % 64) //nolint:mnd
|
||||
}
|
||||
|
||||
if m.DestsContainsIP(ips...) || (routed && m.DestsOverlapsPrefixes(ri.subnet...)) ||
|
||||
if m.DestsContainsIP(ips...) || (routed && m.DestsOverlapsRanges(ri.subnet)) ||
|
||||
(ri.isExit && m.DestsIsTheInternet()) {
|
||||
if dstOf[i] == nil {
|
||||
dstOf[i] = make([]uint64, words)
|
||||
|
||||
+29
-7
@@ -390,6 +390,25 @@ func (node *Node) AppendToIPSet(build *netipx.IPSetBuilder) {
|
||||
}
|
||||
}
|
||||
|
||||
// RouteRanges returns sorted, disjoint ranges covering valid route prefixes.
|
||||
func RouteRanges(prefixes []netip.Prefix) []netipx.IPRange {
|
||||
if len(prefixes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var builder netipx.IPSetBuilder
|
||||
|
||||
for _, prefix := range prefixes {
|
||||
if prefix.IsValid() {
|
||||
builder.AddPrefix(prefix)
|
||||
}
|
||||
}
|
||||
|
||||
set, _ := builder.IPSet()
|
||||
|
||||
return set.Ranges()
|
||||
}
|
||||
|
||||
// CanAccess reports whether node may reach node2 under the given
|
||||
// matchers. A node owns two source identities for ACL purposes:
|
||||
// - its own IPs (regular peer membership)
|
||||
@@ -400,17 +419,19 @@ func (node *Node) AppendToIPSet(build *netipx.IPSetBuilder) {
|
||||
// matching node2's IPs, node2's approved subnet routes, or "the
|
||||
// internet" when node2 is an exit node — grants access.
|
||||
func (node *Node) CanAccess(matchers []matcher.Match, node2 *Node) bool {
|
||||
return node.canAccess(matchers, node2, node.SubnetRoutes(), node2.SubnetRoutes(), node2.IsExitNode())
|
||||
return node.canAccess(matchers, node2,
|
||||
RouteRanges(node.SubnetRoutes()), RouteRanges(node2.SubnetRoutes()),
|
||||
node2.IsExitNode())
|
||||
}
|
||||
|
||||
// canAccess is [Node.CanAccess] with the snapshot-stable route data supplied by
|
||||
// the caller. The peer-map build precomputes each node's SubnetRoutes and
|
||||
// the caller. The peer-map build precomputes each node's route ranges and
|
||||
// exit-node status once and passes them here, so the O(n^2) pair scan does not
|
||||
// recompute them for every pair.
|
||||
func (node *Node) canAccess(
|
||||
matchers []matcher.Match,
|
||||
node2 *Node,
|
||||
srcRoutes, dstRoutes []netip.Prefix,
|
||||
srcRoutes, dstRoutes []netipx.IPRange,
|
||||
dstIsExit bool,
|
||||
) bool {
|
||||
src := node.IPs()
|
||||
@@ -418,7 +439,7 @@ func (node *Node) canAccess(
|
||||
|
||||
for _, m := range matchers {
|
||||
srcMatchesIP := m.SrcsContainsIPs(src...)
|
||||
srcMatchesRoutes := len(srcRoutes) > 0 && m.SrcsOverlapsPrefixes(srcRoutes...)
|
||||
srcMatchesRoutes := len(srcRoutes) > 0 && m.SrcsOverlapsRanges(srcRoutes)
|
||||
|
||||
if !srcMatchesIP && !srcMatchesRoutes {
|
||||
continue
|
||||
@@ -428,7 +449,7 @@ func (node *Node) canAccess(
|
||||
return true
|
||||
}
|
||||
|
||||
if len(dstRoutes) > 0 && m.DestsOverlapsPrefixes(dstRoutes...) {
|
||||
if len(dstRoutes) > 0 && m.DestsOverlapsRanges(dstRoutes) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -962,12 +983,13 @@ func (nv NodeView) CanAccess(matchers []matcher.Match, node2 NodeView) bool {
|
||||
return nv.ж.CanAccess(matchers, node2.ж)
|
||||
}
|
||||
|
||||
// CanAccessWithRoutes is [NodeView.CanAccess] with precomputed route data, used
|
||||
// CanAccessWithRoutes is [NodeView.CanAccess] with precomputed route ranges, used
|
||||
// by the peer-map build to avoid recomputing each node's routes per pair.
|
||||
// Callers must pass sorted, disjoint ranges, as returned by [RouteRanges].
|
||||
func (nv NodeView) CanAccessWithRoutes(
|
||||
matchers []matcher.Match,
|
||||
node2 NodeView,
|
||||
srcRoutes, dstRoutes []netip.Prefix,
|
||||
srcRoutes, dstRoutes []netipx.IPRange,
|
||||
dstIsExit bool,
|
||||
) bool {
|
||||
if !nv.Valid() || !node2.Valid() {
|
||||
|
||||
Reference in new issue
Block a user