diff --git a/packages/system/kilo/Makefile b/packages/system/kilo/Makefile index c6f47322c..fb4c5f610 100644 --- a/packages/system/kilo/Makefile +++ b/packages/system/kilo/Makefile @@ -5,9 +5,18 @@ include ../../../hack/common-envs.mk include ../../../hack/package.mk update: - tag=$$(git ls-remote --tags --sort="v:refname" https://github.com/squat/kilo | awk -F'[/^]' 'END{print $$3}') && \ - digest=$$(skopeo inspect --override-os linux --override-arch amd64 --format '{{.Digest}}' docker://ghcr.io/squat/kilo:$${tag}) && \ - REPOSITORY="ghcr.io/squat/kilo" \ - yq -i '.kilo.image.repository = strenv(REPOSITORY)' values.yaml && \ - TAG="$${tag}@$${digest}" \ + sha=$$(git ls-remote https://github.com/squat/kilo refs/heads/main | awk '{print $$1}') && \ + sed -i "/ARG VERSION/ s|=.*|=$${sha}|g" images/kilo/Dockerfile + +image: + docker buildx build images/kilo \ + --tag $(REGISTRY)/kilo:$(call settag,$(TAG)) \ + --cache-from type=registry,ref=$(REGISTRY)/kilo:latest \ + --cache-to type=inline \ + --metadata-file images/kilo.json \ + $(BUILDX_ARGS) + REPOSITORY="$(REGISTRY)/kilo" \ + yq -i '.kilo.image.repository = strenv(REPOSITORY)' values.yaml + TAG=$(TAG)@$$(yq e '."containerimage.digest"' images/kilo.json -o json -r) \ yq -i '.kilo.image.tag = strenv(TAG)' values.yaml + rm -f images/kilo.json diff --git a/packages/system/kilo/images/kilo/Dockerfile b/packages/system/kilo/images/kilo/Dockerfile new file mode 100644 index 000000000..d7e9fce6e --- /dev/null +++ b/packages/system/kilo/images/kilo/Dockerfile @@ -0,0 +1,43 @@ +# Build the kg binary +FROM --platform=$BUILDPLATFORM golang:1.25 AS builder + +ARG VERSION=fbce60d8033da073748f5b0d6ce0f1e216f03e7f +ARG TARGETOS +ARG TARGETARCH + +WORKDIR /workspace + +RUN curl -sSL https://github.com/squat/kilo/archive/${VERSION}.tar.gz | tar -xzvf- --strip=1 + +COPY patches /patches +RUN git apply /patches/*.diff + +RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build \ + -trimpath \ + -ldflags "-X github.com/squat/kilo/pkg/version.Version=${VERSION}" \ + -a -o kg ./cmd/kg + +RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build \ + -trimpath \ + -ldflags "-X github.com/squat/kilo/pkg/version.Version=${VERSION}" \ + -a -o kgctl ./cmd/kgctl + +# Download CNI plugins +FROM alpine:3.23 AS cni +ARG TARGETARCH +ARG CNI_PLUGINS_VERSION=v1.9.0 +RUN apk add --no-cache curl && \ + curl -Lo cni.tar.gz https://github.com/containernetworking/plugins/releases/download/$CNI_PLUGINS_VERSION/cni-plugins-linux-$TARGETARCH-$CNI_PLUGINS_VERSION.tgz && \ + tar -xf cni.tar.gz + +FROM alpine:3.23 +ARG ALPINE_VERSION=v3.20 +RUN echo -e "https://alpine.global.ssl.fastly.net/alpine/$ALPINE_VERSION/main\nhttps://alpine.global.ssl.fastly.net/alpine/$ALPINE_VERSION/community" > /etc/apk/repositories && \ + apk add --no-cache ipset iptables ip6tables graphviz font-noto +COPY --from=cni bridge host-local loopback portmap /opt/cni/bin/ +ADD https://raw.githubusercontent.com/kubernetes-sigs/iptables-wrappers/e139a115350974aac8a82ec4b815d2845f86997e/iptables-wrapper-installer.sh / +RUN chmod 700 /iptables-wrapper-installer.sh && /iptables-wrapper-installer.sh --no-sanity-check +COPY --from=builder /workspace/kg /opt/bin/kg +COPY --from=builder /workspace/kgctl /opt/bin/kgctl + +ENTRYPOINT ["/opt/bin/kg"] diff --git a/packages/system/kilo/images/kilo/patches/feat-add-cross-mesh-granularity.diff b/packages/system/kilo/images/kilo/patches/feat-add-cross-mesh-granularity.diff new file mode 100644 index 000000000..80298e809 --- /dev/null +++ b/packages/system/kilo/images/kilo/patches/feat-add-cross-mesh-granularity.diff @@ -0,0 +1,990 @@ +diff --git a/Makefile b/Makefile +index 8392bc8a..7f5ad13f 100644 +--- a/Makefile ++++ b/Makefile +@@ -87,7 +87,7 @@ unit: + test: lint unit e2e + + e2e: +- KILO_IMAGE=squat/kilo:test bash_unit $(BASH_UNIT_FLAGS) ./e2e/setup.sh ./e2e/full-mesh.sh ./e2e/location-mesh.sh ./e2e/multi-cluster.sh ./e2e/handlers.sh ./e2e/kgctl.sh ./e2e/teardown.sh ++ KILO_IMAGE=squat/kilo:test bash_unit $(BASH_UNIT_FLAGS) ./e2e/setup.sh ./e2e/full-mesh.sh ./e2e/location-mesh.sh ./e2e/cross-mesh.sh ./e2e/multi-cluster.sh ./e2e/handlers.sh ./e2e/kgctl.sh ./e2e/teardown.sh + + docs/kg.md: + go run ./cmd/kg/... --help | head -n -2 > help.txt +diff --git a/cmd/kg/main.go b/cmd/kg/main.go +index 4472f7f7..0edd9842 100644 +--- a/cmd/kg/main.go ++++ b/cmd/kg/main.go +@@ -77,6 +77,7 @@ var ( + availableGranularities = strings.Join([]string{ + string(mesh.LogicalGranularity), + string(mesh.FullGranularity), ++ string(mesh.CrossGranularity), + }, ", ") + availableLogLevels = strings.Join([]string{ + logLevelAll, +@@ -237,6 +238,7 @@ func runRoot(_ *cobra.Command, _ []string) error { + switch gr { + case mesh.LogicalGranularity: + case mesh.FullGranularity: ++ case mesh.CrossGranularity: + default: + return fmt.Errorf("mesh granularity %v unknown; possible values are: %s", granularity, availableGranularities) + } +diff --git a/cmd/kgctl/main.go b/cmd/kgctl/main.go +index 7f92031f..b3169d83 100644 +--- a/cmd/kgctl/main.go ++++ b/cmd/kgctl/main.go +@@ -49,6 +49,7 @@ var ( + availableGranularities = []string{ + string(mesh.LogicalGranularity), + string(mesh.FullGranularity), ++ string(mesh.CrossGranularity), + string(mesh.AutoGranularity), + } + availableLogLevels = []string{ +@@ -91,6 +92,7 @@ func runRoot(c *cobra.Command, _ []string) error { + switch opts.granularity { + case mesh.LogicalGranularity: + case mesh.FullGranularity: ++ case mesh.CrossGranularity: + case mesh.AutoGranularity: + default: + return fmt.Errorf("mesh granularity %s unknown; posible values are: %s", granularity, availableGranularities) +@@ -164,8 +166,9 @@ func determineGranularity(gr mesh.Granularity, ns []*mesh.Node) (mesh.Granularit + switch ret { + case mesh.LogicalGranularity: + case mesh.FullGranularity: ++ case mesh.CrossGranularity: + default: +- return ret, fmt.Errorf("mesh granularity %s is not supported", opts.granularity) ++ return ret, fmt.Errorf("mesh granularity %s is not supported", ret) + } + return ret, nil + } +diff --git a/docs/kg.md b/docs/kg.md +index 3f58515e..f4829a25 100644 +--- a/docs/kg.md ++++ b/docs/kg.md +@@ -50,7 +50,7 @@ Flags: + --local Should Kilo manage routes within a location? (default true) + --log-level string Log level to use. Possible values: all, debug, info, warn, error, none (default "info") + --master string The address of the Kubernetes API server (overrides any value in kubeconfig). +- --mesh-granularity string The granularity of the network mesh to create. Possible values: location, full (default "location") ++ --mesh-granularity string The granularity of the network mesh to create. Possible values: location, full, cross (default "location") + --mtu string The MTU of the WireGuard interface created by Kilo. Set to 'auto' to detect from the underlay interface. (default "auto") + --port int The port over which WireGuard peers should communicate. (default 51820) + --prioritise-private-addresses Prefer to assign a private IP address to the node's endpoint. +diff --git a/docs/topology.md b/docs/topology.md +index e5eafd0a..3fbef690 100644 +--- a/docs/topology.md ++++ b/docs/topology.md +@@ -47,6 +47,11 @@ kgctl graph | circo -Tsvg > cluster.svg + + + ++# Cross Mesh ++ ++In this topology all nodes within the same location are not encrypted. Traffic to any other node outside of current location is encrypted ++with direct node-to-node encryption. To use this mesh specify `--mesh-granularity=cross`. ++ + ## Mixed + + The `kilo.squat.ai/location` annotation can be used to create cluster mixing some fully meshed nodes and some nodes grouped by logical location. +diff --git a/e2e/cross-mesh.sh b/e2e/cross-mesh.sh +new file mode 100755 +index 00000000..0874b9d4 +--- /dev/null ++++ b/e2e/cross-mesh.sh +@@ -0,0 +1,63 @@ ++#!/usr/bin/env bash ++# shellcheck disable=SC1091 ++. lib.sh ++ ++# This suite exercises --mesh-granularity=cross on the bridge-CNI test ++# cluster. Cross drops the WireGuard tunnel between nodes that share a ++# location and expects the underlying CNI to handle intra-location ++# traffic over its own overlay (e.g. Cilium VXLAN). The Kilo bridge CNI ++# used by this kind cluster has no such overlay, so cross-location peer ++# topology can be validated here but pod-to-pod connectivity cannot — ++# that lives in the Cilium-CNI suite (e2e/cilium-cross-mesh.sh). ++ ++setup_suite() { ++ # Place control-plane and the first worker into one location, and the ++ # second worker into another, so that "cross" produces tunnels only ++ # between the two locations and not within a single location. ++ _kubectl annotate node "$KIND_CLUSTER-control-plane" kilo.squat.ai/location=loc-a --overwrite ++ _kubectl annotate node "$KIND_CLUSTER-worker" kilo.squat.ai/location=loc-a --overwrite ++ _kubectl annotate node "$KIND_CLUSTER-worker2" kilo.squat.ai/location=loc-b --overwrite ++ # shellcheck disable=SC2016 ++ _kubectl patch ds -n kube-system kilo -p '{"spec": {"template":{"spec":{"containers":[{"name":"kilo","args":["--hostname=$(NODE_NAME)","--create-interface=false","--kubeconfig=/etc/kubernetes/kubeconfig","--mesh-granularity=cross"]}]}}}}' ++ block_until_ready_by_name kube-system kilo-userspace ++} ++ ++# Restore the cluster to a clean state for the suites that follow ++# (multi-cluster.sh, handlers.sh, kgctl.sh): remove the location ++# annotations this suite added and roll the DaemonSet back to ++# --mesh-granularity=location, matching the state location-mesh.sh ++# leaves behind. ++teardown_suite() { ++ _kubectl annotate node "$KIND_CLUSTER-control-plane" kilo.squat.ai/location- 2>/dev/null || true ++ _kubectl annotate node "$KIND_CLUSTER-worker" kilo.squat.ai/location- 2>/dev/null || true ++ _kubectl annotate node "$KIND_CLUSTER-worker2" kilo.squat.ai/location- 2>/dev/null || true ++ # shellcheck disable=SC2016 ++ _kubectl patch ds -n kube-system kilo -p '{"spec": {"template":{"spec":{"containers":[{"name":"kilo","args":["--hostname=$(NODE_NAME)","--create-interface=false","--kubeconfig=/etc/kubernetes/kubeconfig","--mesh-granularity=location"]}]}}}}' ++ block_until_ready_by_name kube-system kilo-userspace ++} ++ ++test_cross_mesh_peer() { ++ check_peer wg99 e2e 10.5.0.1/32 cross ++} ++ ++test_mesh_granularity_auto_detect() { ++ assert_equals "$(_kgctl graph)" "$(_kgctl graph --mesh-granularity cross)" ++} ++ ++# In "cross" granularity, every node in another location must appear as a ++# WireGuard peer (direct tunnels across locations), while nodes in the same ++# location must NOT appear as peers (intra-location traffic stays on the CNI). ++# In "location" the same-location worker would not have any [Peer] entry at ++# all (it is a non-leader); in "full" both same- and cross-location nodes ++# would appear as peers. This sanity-checks that "cross" sits in between. ++test_cross_peer_topology() { ++ local CP_PEERS WORKER_PEERS WORKER2_PEERS ++ CP_PEERS=$(_kgctl showconf node "$KIND_CLUSTER-control-plane" | grep -c '^\[Peer\]') ++ WORKER_PEERS=$(_kgctl showconf node "$KIND_CLUSTER-worker" | grep -c '^\[Peer\]') ++ WORKER2_PEERS=$(_kgctl showconf node "$KIND_CLUSTER-worker2" | grep -c '^\[Peer\]') ++ # Each loc-a node should peer only with the single loc-b node. ++ assert_equals "1" "$CP_PEERS" "control-plane (loc-a) should have 1 peer (the loc-b node)" ++ assert_equals "1" "$WORKER_PEERS" "worker (loc-a) should have 1 peer (the loc-b node)" ++ # The loc-b node should peer with both loc-a nodes. ++ assert_equals "2" "$WORKER2_PEERS" "worker2 (loc-b) should have 2 peers (both loc-a nodes)" ++} +diff --git a/pkg/k8s/backend.go b/pkg/k8s/backend.go +index 91d78aef..ae8a72a9 100644 +--- a/pkg/k8s/backend.go ++++ b/pkg/k8s/backend.go +@@ -340,6 +340,7 @@ func translateNode(node *v1.Node, topologyLabel string) *mesh.Node { + switch meshGranularity { + case mesh.LogicalGranularity: + case mesh.FullGranularity: ++ case mesh.CrossGranularity: + default: + meshGranularity = "" + } +diff --git a/pkg/mesh/backend.go b/pkg/mesh/backend.go +index eebbec2a..78becc89 100644 +--- a/pkg/mesh/backend.go ++++ b/pkg/mesh/backend.go +@@ -50,6 +50,9 @@ const ( + // FullGranularity indicates that the network should create + // a mesh between every node. + FullGranularity Granularity = "full" ++ // CrossGranularity indicates that network is encrypted only ++ // between nodes in different locations. ++ CrossGranularity Granularity = "cross" + // AutoGranularity can be used with kgctl to obtain + // the granularity automatically. + AutoGranularity Granularity = "auto" +diff --git a/pkg/mesh/graph.go b/pkg/mesh/graph.go +index cdf9ff3b..42130c6b 100644 +--- a/pkg/mesh/graph.go ++++ b/pkg/mesh/graph.go +@@ -44,22 +44,30 @@ func (t *Topology) Dot() (string, error) { + return "", fmt.Errorf("failed to set direction") + } + leaders := make([]string, len(t.segments)) ++ nodeLocations := make([]string, len(t.segments)) + nodeAttrs := map[string]string{ + string(gographviz.Shape): "ellipse", + } + + for i, s := range t.segments { +- if err := g.AddSubGraph("kilo", subGraphName(s.location), nil); err != nil { ++ location := s.location ++ plainConnection := false ++ if s.nodeLocation != "" { ++ location = s.nodeLocation ++ plainConnection = true ++ } ++ ++ if err := g.AddSubGraph("kilo", subGraphName(location), nil); err != nil { + return "", fmt.Errorf("failed to add subgraph") + } +- if err := g.AddAttr(subGraphName(s.location), string(gographviz.Label), graphEscape(s.location)); err != nil { ++ if err := g.AddAttr(subGraphName(location), string(gographviz.Label), graphEscape(location)); err != nil { + return "", fmt.Errorf("failed to add label to subgraph") + } +- if err := g.AddAttr(subGraphName(s.location), string(gographviz.Style), `"dashed,rounded"`); err != nil { ++ if err := g.AddAttr(subGraphName(location), string(gographviz.Style), `"dashed,rounded"`); err != nil { + return "", fmt.Errorf("failed to add style to subgraph") + } + for j := range s.cidrs { +- if err := g.AddNode(subGraphName(s.location), graphEscape(s.hostnames[j]), nodeAttrs); err != nil { ++ if err := g.AddNode(subGraphName(location), graphEscape(s.hostnames[j]), nodeAttrs); err != nil { + return "", fmt.Errorf("failed to add node to subgraph") + } + var wg net.IP +@@ -75,14 +83,28 @@ func (t *Topology) Dot() (string, error) { + if s.privateIPs != nil { + priv = s.privateIPs[j] + } +- if err := g.Nodes.Lookup[graphEscape(s.hostnames[j])].Attrs.Add(string(gographviz.Label), nodeLabel(s.location, s.hostnames[j], s.cidrs[j], priv, wg, endpoint)); err != nil { ++ if err := g.Nodes.Lookup[graphEscape(s.hostnames[j])].Attrs.Add(string(gographviz.Label), nodeLabel(location, s.hostnames[j], s.cidrs[j], priv, wg, endpoint)); err != nil { + return "", fmt.Errorf("failed to add label to node") + } + } +- meshSubGraph(g, g.Relations.SortedChildren(subGraphName(s.location)), s.leader, nil) ++ if s.nodeLocation == "" { ++ meshSubGraph(g, g.Relations.SortedChildren(subGraphName(location)), s.leader, plainConnection, nil) ++ } ++ nodeLocations[i] = s.nodeLocation + leaders[i] = graphEscape(s.hostnames[s.leader]) + } +- meshGraph(g, leaders, nil) ++ ++ seen := make(map[string]bool) ++ for _, s := range t.segments { ++ if s.nodeLocation == "" || seen[s.nodeLocation] { ++ continue ++ } ++ seen[s.nodeLocation] = true ++ children := g.Relations.SortedChildren(subGraphName(s.nodeLocation)) ++ meshSubGraph(g, children, 0, true, nil) ++ } ++ ++ meshGraph(g, leaders, nodeLocations, nil) + + if err := g.AddSubGraph("kilo", graphEscape("cluster_peers"), nil); err != nil { + return "", fmt.Errorf("failed to add peer subgraph") +@@ -106,7 +128,7 @@ func (t *Topology) Dot() (string, error) { + return g.String(), nil + } + +-func meshGraph(g *gographviz.Graph, nodes []string, attrs gographviz.Attrs) { ++func meshGraph(g *gographviz.Graph, nodes []string, nodeLocations []string, attrs gographviz.Attrs) { + if attrs == nil { + attrs = make(gographviz.Attrs) + attrs[gographviz.Dir] = "both" +@@ -116,21 +138,43 @@ func meshGraph(g *gographviz.Graph, nodes []string, attrs gographviz.Attrs) { + if i == j { + continue + } ++ if nodeLocations != nil && nodeLocations[i] != "" && nodeLocations[i] == nodeLocations[j] { ++ continue ++ } ++ dsts := g.Edges.SrcToDsts[nodes[i]] ++ if dsts != nil && len(dsts[nodes[j]]) != 0 { ++ // nodes already connected via plain connection ++ continue ++ } ++ + g.Edges.Add(&gographviz.Edge{Src: nodes[i], Dst: nodes[j], Dir: true, Attrs: attrs}) + } + } + } + +-func meshSubGraph(g *gographviz.Graph, nodes []string, leader int, attrs gographviz.Attrs) { ++func meshSubGraph(g *gographviz.Graph, nodes []string, leader int, plainConnection bool, attrs gographviz.Attrs) { + if attrs == nil { + attrs = make(gographviz.Attrs) + attrs[gographviz.Dir] = "both" ++ if plainConnection { ++ attrs[gographviz.Style] = "dotted" ++ attrs[gographviz.ArrowHead] = "none" ++ attrs[gographviz.ArrowTail] = "none" ++ } + } +- for i := range nodes { +- if i == leader { +- continue ++ if plainConnection { ++ for i := range nodes { ++ for j := i + 1; j < len(nodes); j++ { ++ g.Edges.Add(&gographviz.Edge{Src: nodes[i], Dst: nodes[j], Dir: true, Attrs: attrs}) ++ } ++ } ++ } else { ++ for i := range nodes { ++ if i == leader { ++ continue ++ } ++ g.Edges.Add(&gographviz.Edge{Src: nodes[leader], Dst: nodes[i], Dir: true, Attrs: attrs}) + } +- g.Edges.Add(&gographviz.Edge{Src: nodes[leader], Dst: nodes[i], Dir: true, Attrs: attrs}) + } + } + +diff --git a/pkg/mesh/routes.go b/pkg/mesh/routes.go +index 38d6c97c..f81cf821 100644 +--- a/pkg/mesh/routes.go ++++ b/pkg/mesh/routes.go +@@ -147,7 +147,7 @@ func (t *Topology) Routes(kiloIfaceName string, kiloIface, privIface, tunlIface + } + for _, segment := range t.segments { + // Add routes for the current segment if local is true. +- if segment.location == t.location { ++ if (segment.location == t.location) || (t.nodeLocation != "" && segment.nodeLocation == t.nodeLocation) { + // If the local node does not have a private IP address, + // then skip adding routes, because the node is in its own location. + if local && t.privateIP != nil { +@@ -402,9 +402,12 @@ func (t *Topology) Rules(cni, iptablesForwardRule bool) iptables.RuleSet { + // Make sure packets to allowed location IPs go through the KILO-NAT chain, so they can be MASQUERADEd, + // Otherwise packets to these destinations will reach the destination, but never find their way back. + // We only want to NAT in locations of the corresponding allowed location IPs. ++ // Skip the jump when the source is in the same allowed-location CIDR: that traffic is local L2 ++ // (e.g. node-to-VIP) and must not be MASQUERADEd, otherwise floating IPs / VIPs whose addresses ++ // have no explicit RETURN rule fall through to MASQUERADE and break etcd / control-plane HA. + if t.location == s.location { + for _, alip := range s.allowedLocationIPs { +- rules.AddToPrepend(iptables.NewRule(iptables.GetProtocol(alip.IP), "nat", "POSTROUTING", "-d", alip.String(), "-m", "comment", "--comment", "Kilo: jump to NAT chain", "-j", "KILO-NAT")) ++ rules.AddToPrepend(iptables.NewRule(iptables.GetProtocol(alip.IP), "nat", "POSTROUTING", "-d", alip.String(), "!", "-s", alip.String(), "-m", "comment", "--comment", "Kilo: jump to NAT chain", "-j", "KILO-NAT")) + } + } + } +diff --git a/pkg/mesh/topology.go b/pkg/mesh/topology.go +index e08528f2..2734900c 100644 +--- a/pkg/mesh/topology.go ++++ b/pkg/mesh/topology.go +@@ -37,10 +37,12 @@ type Topology struct { + // key is the private key of the node creating the topology. + key wgtypes.Key + port int +- // Location is the logical location of the local host. ++ // location is the logical location of the local host. + location string +- segments []*segment +- peers []*Peer ++ // nodeLocation is the location annotation of the node. This is set only in cross location topology. ++ nodeLocation string ++ segments []*segment ++ peers []*Peer + + // hostname is the hostname of the local host. + hostname string +@@ -75,8 +77,10 @@ type segment struct { + endpoint *wireguard.Endpoint + key wgtypes.Key + persistentKeepalive time.Duration +- // Location is the logical location of this segment. ++ // location is the logical location of this segment. + location string ++ // nodeLocation is the node location annotation. This is set only for cross location topology. ++ nodeLocation string + + // cidrs is a slice of subnets of all peers in the segment. + cidrs []*net.IPNet +@@ -97,36 +101,48 @@ type segment struct { + allowedLocationIPs []net.IPNet + } + ++// topoKey is used to group nodes into locations. ++type topoKey struct { ++ location string ++ nodeLocation string ++} ++ + // NewTopology creates a new Topology struct from a given set of nodes and peers. + func NewTopology(nodes map[string]*Node, peers map[string]*Peer, granularity Granularity, hostname string, port int, key wgtypes.Key, subnet *net.IPNet, serviceCIDRs []*net.IPNet, persistentKeepalive time.Duration, logger log.Logger) (*Topology, error) { + if logger == nil { + logger = log.NewNopLogger() + } +- topoMap := make(map[string][]*Node) ++ topoMap := make(map[topoKey][]*Node) ++ var localLocation, localNodeLocation string ++ switch granularity { ++ case LogicalGranularity: ++ localLocation = logicalLocationPrefix + nodes[hostname].Location ++ if nodes[hostname].InternalIP == nil { ++ localLocation = nodeLocationPrefix + hostname ++ } ++ case FullGranularity: ++ localLocation = nodeLocationPrefix + hostname ++ case CrossGranularity: ++ localLocation = nodeLocationPrefix + hostname ++ localNodeLocation = logicalLocationPrefix + nodes[hostname].Location ++ } ++ + for _, node := range nodes { +- var location string ++ var location, nodeLocation string + switch granularity { + case LogicalGranularity: + location = logicalLocationPrefix + node.Location +- // Put node in a different location, if no private +- // IP was found. + if node.InternalIP == nil { + location = nodeLocationPrefix + node.Name + } + case FullGranularity: + location = nodeLocationPrefix + node.Name ++ case CrossGranularity: ++ location = nodeLocationPrefix + node.Name ++ nodeLocation = logicalLocationPrefix + node.Location + } +- topoMap[location] = append(topoMap[location], node) +- } +- var localLocation string +- switch granularity { +- case LogicalGranularity: +- localLocation = logicalLocationPrefix + nodes[hostname].Location +- if nodes[hostname].InternalIP == nil { +- localLocation = nodeLocationPrefix + hostname +- } +- case FullGranularity: +- localLocation = nodeLocationPrefix + hostname ++ key := topoKey{location: location, nodeLocation: nodeLocation} ++ topoMap[key] = append(topoMap[key], node) + } + + t := Topology{ +@@ -134,6 +150,7 @@ func NewTopology(nodes map[string]*Node, peers map[string]*Peer, granularity Gra + port: port, + hostname: hostname, + location: localLocation, ++ nodeLocation: localNodeLocation, + persistentKeepalive: persistentKeepalive, + privateIP: nodes[hostname].InternalIP, + subnet: nodes[hostname].Subnet, +@@ -148,7 +165,7 @@ func NewTopology(nodes map[string]*Node, peers map[string]*Peer, granularity Gra + return topoMap[location][i].Name < topoMap[location][j].Name + }) + leader := findLeader(topoMap[location]) +- if location == localLocation && topoMap[location][leader].Name == hostname { ++ if location.nodeLocation != "" || (location.location == localLocation && topoMap[location][leader].Name == hostname) { + t.leader = true + } + var allowedIPs []net.IPNet +@@ -190,7 +207,8 @@ func NewTopology(nodes map[string]*Node, peers map[string]*Peer, granularity Gra + endpoint: topoMap[location][leader].Endpoint, + key: topoMap[location][leader].Key, + persistentKeepalive: topoMap[location][leader].PersistentKeepalive, +- location: location, ++ location: location.location, ++ nodeLocation: location.nodeLocation, + cidrs: cidrs, + hostnames: hostnames, + leader: leader, +@@ -235,7 +253,7 @@ func NewTopology(nodes map[string]*Node, peers map[string]*Peer, granularity Gra + + // Now that the topology is ordered, update the discoveredEndpoints map + // add new ones by going through the ordered topology: segments, nodes +- for _, node := range topoMap[segment.location] { ++ for _, node := range topoMap[topoKey{location: segment.location, nodeLocation: segment.nodeLocation}] { + for key := range node.DiscoveredEndpoints { + if _, ok := t.discoveredEndpoints[key]; !ok { + t.discoveredEndpoints[key] = node.DiscoveredEndpoints[key] +@@ -323,7 +341,7 @@ func (t *Topology) Conf() *wireguard.Conf { + }, + } + for _, s := range t.segments { +- if s.location == t.location { ++ if (s.location == t.location) || (t.nodeLocation != "" && t.nodeLocation == s.nodeLocation) { + continue + } + peer := wireguard.Peer{ +diff --git a/pkg/mesh/topology_test.go b/pkg/mesh/topology_test.go +index b8e86f55..cf71628b 100644 +--- a/pkg/mesh/topology_test.go ++++ b/pkg/mesh/topology_test.go +@@ -41,6 +41,7 @@ var ( + key3 = wgtypes.Key{'k', 'e', 'y', '3'} + key4 = wgtypes.Key{'k', 'e', 'y', '4'} + key5 = wgtypes.Key{'k', 'e', 'y', '5'} ++ key6 = wgtypes.Key{'k', 'e', 'y', '6'} + ) + + func setup(t *testing.T) (map[string]*Node, map[string]*Peer, wgtypes.Key, int) { +@@ -557,6 +558,492 @@ func TestNewTopology(t *testing.T) { + logger: log.NewNopLogger(), + }, + }, ++ { ++ name: "cross from a", ++ granularity: CrossGranularity, ++ hostname: nodes["a"].Name, ++ result: &Topology{ ++ hostname: nodes["a"].Name, ++ leader: true, ++ location: nodeLocationPrefix + nodes["a"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["a"].Location, ++ subnet: nodes["a"].Subnet, ++ privateIP: nodes["a"].InternalIP, ++ wireGuardCIDR: &net.IPNet{IP: w1, Mask: net.CIDRMask(16, 32)}, ++ segments: []*segment{ ++ { ++ allowedIPs: []net.IPNet{*nodes["a"].Subnet, *nodes["a"].InternalIP, {IP: w1, Mask: net.CIDRMask(32, 32)}}, ++ endpoint: nodes["a"].Endpoint, ++ key: nodes["a"].Key, ++ persistentKeepalive: nodes["a"].PersistentKeepalive, ++ location: nodeLocationPrefix + nodes["a"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["a"].Location, ++ cidrs: []*net.IPNet{nodes["a"].Subnet}, ++ hostnames: []string{"a"}, ++ privateIPs: []net.IP{nodes["a"].InternalIP.IP}, ++ cniCompatibilityIPs: []*net.IPNet{nil}, ++ wireGuardIP: w1, ++ }, ++ { ++ allowedIPs: []net.IPNet{*nodes["b"].Subnet, *nodes["b"].InternalIP, {IP: w2, Mask: net.CIDRMask(32, 32)}}, ++ endpoint: nodes["b"].Endpoint, ++ key: nodes["b"].Key, ++ persistentKeepalive: nodes["b"].PersistentKeepalive, ++ location: nodeLocationPrefix + nodes["b"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["b"].Location, ++ cidrs: []*net.IPNet{nodes["b"].Subnet}, ++ hostnames: []string{"b"}, ++ privateIPs: []net.IP{nodes["b"].InternalIP.IP}, ++ cniCompatibilityIPs: []*net.IPNet{nil}, ++ wireGuardIP: w2, ++ allowedLocationIPs: nodes["b"].AllowedLocationIPs, ++ }, ++ { ++ allowedIPs: []net.IPNet{*nodes["c"].Subnet, *nodes["c"].InternalIP, {IP: w3, Mask: net.CIDRMask(32, 32)}}, ++ endpoint: nodes["c"].Endpoint, ++ key: nodes["c"].Key, ++ persistentKeepalive: nodes["c"].PersistentKeepalive, ++ location: nodeLocationPrefix + nodes["c"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["c"].Location, ++ cidrs: []*net.IPNet{nodes["c"].Subnet}, ++ hostnames: []string{"c"}, ++ privateIPs: []net.IP{nodes["c"].InternalIP.IP}, ++ cniCompatibilityIPs: []*net.IPNet{nil}, ++ wireGuardIP: w3, ++ }, ++ { ++ allowedIPs: []net.IPNet{*nodes["d"].Subnet, {IP: w4, Mask: net.CIDRMask(32, 32)}}, ++ endpoint: nodes["d"].Endpoint, ++ key: nodes["d"].Key, ++ persistentKeepalive: nodes["d"].PersistentKeepalive, ++ location: nodeLocationPrefix + nodes["d"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["d"].Location, ++ cidrs: []*net.IPNet{nodes["d"].Subnet}, ++ hostnames: []string{"d"}, ++ privateIPs: nil, ++ cniCompatibilityIPs: []*net.IPNet{nil}, ++ wireGuardIP: w4, ++ }, ++ }, ++ peers: []*Peer{peers["a"], peers["b"]}, ++ logger: log.NewNopLogger(), ++ }, ++ }, ++ { ++ name: "cross from b", ++ granularity: CrossGranularity, ++ hostname: nodes["b"].Name, ++ result: &Topology{ ++ hostname: nodes["b"].Name, ++ leader: true, ++ location: nodeLocationPrefix + nodes["b"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["b"].Location, ++ subnet: nodes["b"].Subnet, ++ privateIP: nodes["b"].InternalIP, ++ wireGuardCIDR: &net.IPNet{IP: w2, Mask: net.CIDRMask(16, 32)}, ++ segments: []*segment{ ++ { ++ allowedIPs: []net.IPNet{*nodes["a"].Subnet, *nodes["a"].InternalIP, {IP: w1, Mask: net.CIDRMask(32, 32)}}, ++ endpoint: nodes["a"].Endpoint, ++ key: nodes["a"].Key, ++ persistentKeepalive: nodes["a"].PersistentKeepalive, ++ location: nodeLocationPrefix + nodes["a"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["a"].Location, ++ cidrs: []*net.IPNet{nodes["a"].Subnet}, ++ hostnames: []string{"a"}, ++ privateIPs: []net.IP{nodes["a"].InternalIP.IP}, ++ cniCompatibilityIPs: []*net.IPNet{nil}, ++ wireGuardIP: w1, ++ }, ++ { ++ allowedIPs: []net.IPNet{*nodes["b"].Subnet, *nodes["b"].InternalIP, {IP: w2, Mask: net.CIDRMask(32, 32)}}, ++ endpoint: nodes["b"].Endpoint, ++ key: nodes["b"].Key, ++ persistentKeepalive: nodes["b"].PersistentKeepalive, ++ location: nodeLocationPrefix + nodes["b"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["b"].Location, ++ cidrs: []*net.IPNet{nodes["b"].Subnet}, ++ hostnames: []string{"b"}, ++ privateIPs: []net.IP{nodes["b"].InternalIP.IP}, ++ cniCompatibilityIPs: []*net.IPNet{nil}, ++ wireGuardIP: w2, ++ allowedLocationIPs: nodes["b"].AllowedLocationIPs, ++ }, ++ { ++ allowedIPs: []net.IPNet{*nodes["c"].Subnet, *nodes["c"].InternalIP, {IP: w3, Mask: net.CIDRMask(32, 32)}}, ++ endpoint: nodes["c"].Endpoint, ++ key: nodes["c"].Key, ++ persistentKeepalive: nodes["c"].PersistentKeepalive, ++ location: nodeLocationPrefix + nodes["c"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["c"].Location, ++ cidrs: []*net.IPNet{nodes["c"].Subnet}, ++ hostnames: []string{"c"}, ++ privateIPs: []net.IP{nodes["c"].InternalIP.IP}, ++ cniCompatibilityIPs: []*net.IPNet{nil}, ++ wireGuardIP: w3, ++ }, ++ { ++ allowedIPs: []net.IPNet{*nodes["d"].Subnet, {IP: w4, Mask: net.CIDRMask(32, 32)}}, ++ endpoint: nodes["d"].Endpoint, ++ key: nodes["d"].Key, ++ persistentKeepalive: nodes["d"].PersistentKeepalive, ++ location: nodeLocationPrefix + nodes["d"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["d"].Location, ++ cidrs: []*net.IPNet{nodes["d"].Subnet}, ++ hostnames: []string{"d"}, ++ privateIPs: nil, ++ cniCompatibilityIPs: []*net.IPNet{nil}, ++ wireGuardIP: w4, ++ }, ++ }, ++ peers: []*Peer{peers["a"], peers["b"]}, ++ logger: log.NewNopLogger(), ++ }, ++ }, ++ { ++ name: "cross from c", ++ granularity: CrossGranularity, ++ hostname: nodes["c"].Name, ++ result: &Topology{ ++ hostname: nodes["c"].Name, ++ leader: true, ++ location: nodeLocationPrefix + nodes["c"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["c"].Location, ++ subnet: nodes["c"].Subnet, ++ privateIP: nodes["c"].InternalIP, ++ wireGuardCIDR: &net.IPNet{IP: w3, Mask: net.CIDRMask(16, 32)}, ++ segments: []*segment{ ++ { ++ allowedIPs: []net.IPNet{*nodes["a"].Subnet, *nodes["a"].InternalIP, {IP: w1, Mask: net.CIDRMask(32, 32)}}, ++ endpoint: nodes["a"].Endpoint, ++ key: nodes["a"].Key, ++ persistentKeepalive: nodes["a"].PersistentKeepalive, ++ location: nodeLocationPrefix + nodes["a"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["a"].Location, ++ cidrs: []*net.IPNet{nodes["a"].Subnet}, ++ hostnames: []string{"a"}, ++ privateIPs: []net.IP{nodes["a"].InternalIP.IP}, ++ cniCompatibilityIPs: []*net.IPNet{nil}, ++ wireGuardIP: w1, ++ }, ++ { ++ allowedIPs: []net.IPNet{*nodes["b"].Subnet, *nodes["b"].InternalIP, {IP: w2, Mask: net.CIDRMask(32, 32)}}, ++ endpoint: nodes["b"].Endpoint, ++ key: nodes["b"].Key, ++ persistentKeepalive: nodes["b"].PersistentKeepalive, ++ location: nodeLocationPrefix + nodes["b"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["b"].Location, ++ cidrs: []*net.IPNet{nodes["b"].Subnet}, ++ hostnames: []string{"b"}, ++ privateIPs: []net.IP{nodes["b"].InternalIP.IP}, ++ cniCompatibilityIPs: []*net.IPNet{nil}, ++ wireGuardIP: w2, ++ allowedLocationIPs: nodes["b"].AllowedLocationIPs, ++ }, ++ { ++ allowedIPs: []net.IPNet{*nodes["c"].Subnet, *nodes["c"].InternalIP, {IP: w3, Mask: net.CIDRMask(32, 32)}}, ++ endpoint: nodes["c"].Endpoint, ++ key: nodes["c"].Key, ++ persistentKeepalive: nodes["c"].PersistentKeepalive, ++ location: nodeLocationPrefix + nodes["c"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["c"].Location, ++ cidrs: []*net.IPNet{nodes["c"].Subnet}, ++ hostnames: []string{"c"}, ++ privateIPs: []net.IP{nodes["c"].InternalIP.IP}, ++ cniCompatibilityIPs: []*net.IPNet{nil}, ++ wireGuardIP: w3, ++ }, ++ { ++ allowedIPs: []net.IPNet{*nodes["d"].Subnet, {IP: w4, Mask: net.CIDRMask(32, 32)}}, ++ endpoint: nodes["d"].Endpoint, ++ key: nodes["d"].Key, ++ persistentKeepalive: nodes["d"].PersistentKeepalive, ++ location: nodeLocationPrefix + nodes["d"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["d"].Location, ++ cidrs: []*net.IPNet{nodes["d"].Subnet}, ++ hostnames: []string{"d"}, ++ privateIPs: nil, ++ cniCompatibilityIPs: []*net.IPNet{nil}, ++ wireGuardIP: w4, ++ }, ++ }, ++ peers: []*Peer{peers["a"], peers["b"]}, ++ logger: log.NewNopLogger(), ++ }, ++ }, ++ { ++ name: "cross from d", ++ granularity: CrossGranularity, ++ hostname: nodes["d"].Name, ++ result: &Topology{ ++ hostname: nodes["d"].Name, ++ leader: true, ++ location: nodeLocationPrefix + nodes["d"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["d"].Location, ++ subnet: nodes["d"].Subnet, ++ privateIP: nil, ++ wireGuardCIDR: &net.IPNet{IP: w4, Mask: net.CIDRMask(16, 32)}, ++ segments: []*segment{ ++ { ++ allowedIPs: []net.IPNet{*nodes["a"].Subnet, *nodes["a"].InternalIP, {IP: w1, Mask: net.CIDRMask(32, 32)}}, ++ endpoint: nodes["a"].Endpoint, ++ key: nodes["a"].Key, ++ persistentKeepalive: nodes["a"].PersistentKeepalive, ++ location: nodeLocationPrefix + nodes["a"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["a"].Location, ++ cidrs: []*net.IPNet{nodes["a"].Subnet}, ++ hostnames: []string{"a"}, ++ privateIPs: []net.IP{nodes["a"].InternalIP.IP}, ++ cniCompatibilityIPs: []*net.IPNet{nil}, ++ wireGuardIP: w1, ++ }, ++ { ++ allowedIPs: []net.IPNet{*nodes["b"].Subnet, *nodes["b"].InternalIP, {IP: w2, Mask: net.CIDRMask(32, 32)}}, ++ endpoint: nodes["b"].Endpoint, ++ key: nodes["b"].Key, ++ persistentKeepalive: nodes["b"].PersistentKeepalive, ++ location: nodeLocationPrefix + nodes["b"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["b"].Location, ++ cidrs: []*net.IPNet{nodes["b"].Subnet}, ++ hostnames: []string{"b"}, ++ privateIPs: []net.IP{nodes["b"].InternalIP.IP}, ++ cniCompatibilityIPs: []*net.IPNet{nil}, ++ wireGuardIP: w2, ++ allowedLocationIPs: nodes["b"].AllowedLocationIPs, ++ }, ++ { ++ allowedIPs: []net.IPNet{*nodes["c"].Subnet, *nodes["c"].InternalIP, {IP: w3, Mask: net.CIDRMask(32, 32)}}, ++ endpoint: nodes["c"].Endpoint, ++ key: nodes["c"].Key, ++ persistentKeepalive: nodes["c"].PersistentKeepalive, ++ location: nodeLocationPrefix + nodes["c"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["c"].Location, ++ cidrs: []*net.IPNet{nodes["c"].Subnet}, ++ hostnames: []string{"c"}, ++ privateIPs: []net.IP{nodes["c"].InternalIP.IP}, ++ cniCompatibilityIPs: []*net.IPNet{nil}, ++ wireGuardIP: w3, ++ }, ++ { ++ allowedIPs: []net.IPNet{*nodes["d"].Subnet, {IP: w4, Mask: net.CIDRMask(32, 32)}}, ++ endpoint: nodes["d"].Endpoint, ++ key: nodes["d"].Key, ++ persistentKeepalive: nodes["d"].PersistentKeepalive, ++ location: nodeLocationPrefix + nodes["d"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["d"].Location, ++ cidrs: []*net.IPNet{nodes["d"].Subnet}, ++ hostnames: []string{"d"}, ++ privateIPs: nil, ++ cniCompatibilityIPs: []*net.IPNet{nil}, ++ wireGuardIP: w4, ++ }, ++ }, ++ peers: []*Peer{peers["a"], peers["b"]}, ++ logger: log.NewNopLogger(), ++ }, ++ }, ++ } { ++ tc.result.key = key ++ tc.result.port = port ++ topo, err := NewTopology(nodes, peers, tc.granularity, tc.hostname, port, key, DefaultKiloSubnet, nil, 0, nil) ++ if err != nil { ++ t.Errorf("test case %q: failed to generate Topology: %v", tc.name, err) ++ } ++ if diff := pretty.Compare(topo, tc.result); diff != "" { ++ t.Errorf("test case %q: got diff: %v", tc.name, diff) ++ } ++ } ++} ++ ++func TestNewTopologyEmptyLocation(t *testing.T) { ++ nodes, peers, key, port := setup(t) ++ ++ e5 := &net.IPNet{IP: net.ParseIP("10.1.0.5").To4(), Mask: net.CIDRMask(16, 32)} ++ i5 := &net.IPNet{IP: net.ParseIP("10.1.0.5").To4(), Mask: net.CIDRMask(32, 32)} ++ nodes["e"] = &Node{ ++ Name: "e", ++ Endpoint: wireguard.NewEndpoint(e5.IP, DefaultKiloPort), ++ InternalIP: i5, ++ Location: "", ++ Subnet: &net.IPNet{IP: net.ParseIP("10.2.5.0"), Mask: net.CIDRMask(24, 32)}, ++ Key: key6, ++ } ++ ++ // LogicalGranularity: node e has empty Location and a valid InternalIP. ++ // Because Location == "", it is grouped under "location:" (logicalLocationPrefix + ""). ++ // ++ // Segment sort order (location string): ++ // location: → node e → w1 (Location="" with InternalIP set) ++ // location:1 → node a → w2 ++ // location:2 → nodes b, c → w3 ++ // node:d → node d → w4 (Location="1" but InternalIP==nil) ++ w1 := net.ParseIP("10.4.0.1").To4() ++ w2 := net.ParseIP("10.4.0.2").To4() ++ w3 := net.ParseIP("10.4.0.3").To4() ++ w4 := net.ParseIP("10.4.0.4").To4() ++ w5 := net.ParseIP("10.4.0.5").To4() ++ ++ for _, tc := range []struct { ++ name string ++ granularity Granularity ++ hostname string ++ result *Topology ++ }{ ++ { ++ name: "logical from e (empty location)", ++ granularity: LogicalGranularity, ++ hostname: nodes["e"].Name, ++ result: &Topology{ ++ hostname: nodes["e"].Name, ++ leader: true, ++ location: logicalLocationPrefix + nodes["e"].Location, ++ subnet: nodes["e"].Subnet, ++ privateIP: nodes["e"].InternalIP, ++ wireGuardCIDR: &net.IPNet{IP: w1, Mask: net.CIDRMask(16, 32)}, ++ segments: []*segment{ ++ { ++ allowedIPs: []net.IPNet{*nodes["e"].Subnet, *nodes["e"].InternalIP, {IP: w1, Mask: net.CIDRMask(32, 32)}}, ++ endpoint: nodes["e"].Endpoint, ++ key: nodes["e"].Key, ++ persistentKeepalive: nodes["e"].PersistentKeepalive, ++ location: logicalLocationPrefix + nodes["e"].Location, ++ cidrs: []*net.IPNet{nodes["e"].Subnet}, ++ hostnames: []string{"e"}, ++ privateIPs: []net.IP{nodes["e"].InternalIP.IP}, ++ cniCompatibilityIPs: []*net.IPNet{nil}, ++ wireGuardIP: w1, ++ }, ++ { ++ allowedIPs: []net.IPNet{*nodes["a"].Subnet, *nodes["a"].InternalIP, {IP: w2, Mask: net.CIDRMask(32, 32)}}, ++ endpoint: nodes["a"].Endpoint, ++ key: nodes["a"].Key, ++ persistentKeepalive: nodes["a"].PersistentKeepalive, ++ location: logicalLocationPrefix + nodes["a"].Location, ++ cidrs: []*net.IPNet{nodes["a"].Subnet}, ++ hostnames: []string{"a"}, ++ privateIPs: []net.IP{nodes["a"].InternalIP.IP}, ++ cniCompatibilityIPs: []*net.IPNet{nil}, ++ wireGuardIP: w2, ++ }, ++ { ++ allowedIPs: []net.IPNet{*nodes["b"].Subnet, *nodes["b"].InternalIP, *nodes["c"].Subnet, *nodes["c"].InternalIP, {IP: w3, Mask: net.CIDRMask(32, 32)}}, ++ endpoint: nodes["b"].Endpoint, ++ key: nodes["b"].Key, ++ persistentKeepalive: nodes["b"].PersistentKeepalive, ++ location: logicalLocationPrefix + nodes["b"].Location, ++ cidrs: []*net.IPNet{nodes["b"].Subnet, nodes["c"].Subnet}, ++ hostnames: []string{"b", "c"}, ++ privateIPs: []net.IP{nodes["b"].InternalIP.IP, nodes["c"].InternalIP.IP}, ++ cniCompatibilityIPs: []*net.IPNet{nil, nil}, ++ wireGuardIP: w3, ++ allowedLocationIPs: nodes["b"].AllowedLocationIPs, ++ }, ++ { ++ allowedIPs: []net.IPNet{*nodes["d"].Subnet, {IP: w4, Mask: net.CIDRMask(32, 32)}}, ++ endpoint: nodes["d"].Endpoint, ++ key: nodes["d"].Key, ++ persistentKeepalive: nodes["d"].PersistentKeepalive, ++ location: nodeLocationPrefix + nodes["d"].Name, ++ cidrs: []*net.IPNet{nodes["d"].Subnet}, ++ hostnames: []string{"d"}, ++ privateIPs: nil, ++ cniCompatibilityIPs: []*net.IPNet{nil}, ++ wireGuardIP: w4, ++ }, ++ }, ++ peers: []*Peer{peers["a"], peers["b"]}, ++ logger: log.NewNopLogger(), ++ }, ++ }, ++ { ++ // CrossGranularity: every node gets its own segment keyed by node name. ++ // For node e with empty Location, nodeLocation must be "location:" (logicalLocationPrefix + ""). ++ // ++ // Segment sort order (location = node:): ++ // node:a → w1, node:b → w2, node:c → w3, node:d → w4, node:e → w5 ++ name: "cross from e (empty location)", ++ granularity: CrossGranularity, ++ hostname: nodes["e"].Name, ++ result: &Topology{ ++ hostname: nodes["e"].Name, ++ leader: true, ++ location: nodeLocationPrefix + nodes["e"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["e"].Location, ++ subnet: nodes["e"].Subnet, ++ privateIP: nodes["e"].InternalIP, ++ wireGuardCIDR: &net.IPNet{IP: w5, Mask: net.CIDRMask(16, 32)}, ++ segments: []*segment{ ++ { ++ allowedIPs: []net.IPNet{*nodes["a"].Subnet, *nodes["a"].InternalIP, {IP: w1, Mask: net.CIDRMask(32, 32)}}, ++ endpoint: nodes["a"].Endpoint, ++ key: nodes["a"].Key, ++ persistentKeepalive: nodes["a"].PersistentKeepalive, ++ location: nodeLocationPrefix + nodes["a"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["a"].Location, ++ cidrs: []*net.IPNet{nodes["a"].Subnet}, ++ hostnames: []string{"a"}, ++ privateIPs: []net.IP{nodes["a"].InternalIP.IP}, ++ cniCompatibilityIPs: []*net.IPNet{nil}, ++ wireGuardIP: w1, ++ }, ++ { ++ allowedIPs: []net.IPNet{*nodes["b"].Subnet, *nodes["b"].InternalIP, {IP: w2, Mask: net.CIDRMask(32, 32)}}, ++ endpoint: nodes["b"].Endpoint, ++ key: nodes["b"].Key, ++ persistentKeepalive: nodes["b"].PersistentKeepalive, ++ location: nodeLocationPrefix + nodes["b"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["b"].Location, ++ cidrs: []*net.IPNet{nodes["b"].Subnet}, ++ hostnames: []string{"b"}, ++ privateIPs: []net.IP{nodes["b"].InternalIP.IP}, ++ cniCompatibilityIPs: []*net.IPNet{nil}, ++ wireGuardIP: w2, ++ allowedLocationIPs: nodes["b"].AllowedLocationIPs, ++ }, ++ { ++ allowedIPs: []net.IPNet{*nodes["c"].Subnet, *nodes["c"].InternalIP, {IP: w3, Mask: net.CIDRMask(32, 32)}}, ++ endpoint: nodes["c"].Endpoint, ++ key: nodes["c"].Key, ++ persistentKeepalive: nodes["c"].PersistentKeepalive, ++ location: nodeLocationPrefix + nodes["c"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["c"].Location, ++ cidrs: []*net.IPNet{nodes["c"].Subnet}, ++ hostnames: []string{"c"}, ++ privateIPs: []net.IP{nodes["c"].InternalIP.IP}, ++ cniCompatibilityIPs: []*net.IPNet{nil}, ++ wireGuardIP: w3, ++ }, ++ { ++ allowedIPs: []net.IPNet{*nodes["d"].Subnet, {IP: w4, Mask: net.CIDRMask(32, 32)}}, ++ endpoint: nodes["d"].Endpoint, ++ key: nodes["d"].Key, ++ persistentKeepalive: nodes["d"].PersistentKeepalive, ++ location: nodeLocationPrefix + nodes["d"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["d"].Location, ++ cidrs: []*net.IPNet{nodes["d"].Subnet}, ++ hostnames: []string{"d"}, ++ privateIPs: nil, ++ cniCompatibilityIPs: []*net.IPNet{nil}, ++ wireGuardIP: w4, ++ }, ++ { ++ allowedIPs: []net.IPNet{*nodes["e"].Subnet, *nodes["e"].InternalIP, {IP: w5, Mask: net.CIDRMask(32, 32)}}, ++ endpoint: nodes["e"].Endpoint, ++ key: nodes["e"].Key, ++ persistentKeepalive: nodes["e"].PersistentKeepalive, ++ location: nodeLocationPrefix + nodes["e"].Name, ++ nodeLocation: logicalLocationPrefix + nodes["e"].Location, ++ cidrs: []*net.IPNet{nodes["e"].Subnet}, ++ hostnames: []string{"e"}, ++ privateIPs: []net.IP{nodes["e"].InternalIP.IP}, ++ cniCompatibilityIPs: []*net.IPNet{nil}, ++ wireGuardIP: w5, ++ }, ++ }, ++ peers: []*Peer{peers["a"], peers["b"]}, ++ logger: log.NewNopLogger(), ++ }, ++ }, + } { + tc.result.key = key + tc.result.port = port