From 26cb65e93660beb78b1fea8f8e7fb868ee90d7ef Mon Sep 17 00:00:00 2001 From: Mikhail Date: Thu, 14 May 2026 23:26:19 +0300 Subject: [PATCH] Fix VPN fabric-only routing guard --- .../rap-node-agent/cmd/rap-node-agent/main.go | 71 ++++++++------ .../cmd/rap-node-agent/main_test.go | 44 +++++++++ .../rap-node-agent/internal/agent/payload.go | 64 ++++++------ .../internal/vpnruntime/fabric_transport.go | 62 +++++++++--- .../vpnruntime/fabric_transport_test.go | 98 +++++++++++++++++-- 5 files changed, 260 insertions(+), 79 deletions(-) diff --git a/agents/rap-node-agent/cmd/rap-node-agent/main.go b/agents/rap-node-agent/cmd/rap-node-agent/main.go index a62054c..a99975b 100644 --- a/agents/rap-node-agent/cmd/rap-node-agent/main.go +++ b/agents/rap-node-agent/cmd/rap-node-agent/main.go @@ -85,7 +85,15 @@ func main() { } defer stopMeshEndpoint() - supervisor := supervisor.StubSupervisor{Version: agent.Version} + supervisor := supervisor.StubSupervisor{ + Version: agent.Version, + RemoteWorkspaceRealAdapter: supervisor.RemoteWorkspaceRealAdapterConfig{ + EnabledRequested: cfg.RemoteWorkspaceRealAdapterEnabled, + Command: cfg.RemoteWorkspaceRealAdapterCommand, + ArgsJSON: cfg.RemoteWorkspaceRealAdapterArgsJSON, + WorkDir: cfg.RemoteWorkspaceRealAdapterWorkDir, + }, + } startedAt := time.Now().UTC() ticker := time.NewTicker(cfg.HeartbeatInterval) defer ticker.Stop() @@ -805,6 +813,7 @@ func newVPNFabricIngress(meshState *syntheticMeshState, identity state.Identity, if ingress == nil { ingress = &vpnruntime.FabricClientPacketIngress{} } + ingress.PreventLastRouteWithdrawal = true ingress.UpdateRuntime( meshState.ProductionForwardTransport, meshState.VPNFabricInbox, @@ -2484,8 +2493,9 @@ func fabricServiceChannelRuntimeReport(meshState *syntheticMeshState, identity s "service_class": "vpn_packets", "channel_class": mesh.ProductionChannelVPNPacket, "route_manager": "primary_sticky_with_alternate_route_failover", - "backend_relay_fallback": true, - "backend_relay_fallback_position": "after_all_fabric_routes_fail", + "backend_relay_fallback": false, + "backend_relay_fallback_position": "disabled_farm_owned_dataplane", + "route_authority": "fabric_farm", "application_protocol_agnostic": true, "observed_at": observedAt.UTC().Format(time.RFC3339Nano), } @@ -4191,6 +4201,24 @@ func ensureVPNGatewayRuntime(ctx context.Context, api *client.Client, identity s } activeOwner := false for _, assignment := range assignments { + if assignment.AssignmentReason == "eligible_candidate" && assignment.DesiredState == "enabled" { + lease, err := api.AcquireNodeVPNAssignmentLease(ctx, identity.ClusterID, identity.NodeID, assignment.VPNConnectionID, client.NodeVPNAssignmentLeaseAcquireRequest{ + TTLSeconds: 300, + Metadata: map[string]any{ + "reason": "node_agent_auto_acquire", + "node_id": identity.NodeID, + "agent": "rap-node-agent", + "acquired_at": time.Now().UTC().Format(time.RFC3339Nano), + }, + }) + if err != nil { + log.Printf("vpn assignment lease auto-acquire skipped: vpn_connection_id=%s error=%v", assignment.VPNConnectionID, err) + } else if lease != nil { + assignment.AssignmentReason = "active_owner" + assignment.ActiveLease = lease + log.Printf("vpn assignment lease auto-acquired: vpn_connection_id=%s lease_id=%s", assignment.VPNConnectionID, lease.LeaseID) + } + } if assignment.AssignmentReason != "active_owner" { continue } @@ -4220,6 +4248,11 @@ func ensureVPNGatewayRuntime(ctx context.Context, api *client.Client, identity s } else if _, ok := gateway.Transport.(*vpnruntime.AdaptivePacketTransport); ok { gateway.Stop() gateway.Transport = nil + } else { + gateway.Stop() + gateway.Transport = nil + log.Printf("vpn gateway runtime skipped: vpn_connection_id=%s reason=fabric_packet_transport_unavailable", assignment.VPNConnectionID) + return nil } if err := gateway.EnsureStarted(ctx); err != nil { return err @@ -4236,29 +4269,17 @@ func ensureVPNGatewayRuntime(ctx context.Context, api *client.Client, identity s return nil } -func localGatewayTransportForAssignment(identity state.Identity, assignment client.NodeVPNAssignment, meshState *syntheticMeshState, api *client.Client) vpnruntime.PacketTransport { +func localGatewayTransportForAssignment(identity state.Identity, assignment client.NodeVPNAssignment, meshState *syntheticMeshState, _ *client.Client) vpnruntime.PacketTransport { if meshState == nil || meshState.VPNFabricInbox == nil || assignment.VPNConnectionID == "" { return nil } - local := &vpnruntime.LocalPacketTransport{ + return &vpnruntime.LocalPacketTransport{ Inbox: meshState.VPNFabricInbox, VPNConnectionID: assignment.VPNConnectionID, } - if api == nil { - return local - } - return &vpnruntime.AdaptivePacketTransport{ - Primary: local, - Fallback: vpnruntime.BackendPacketTransport{ - API: api, - ClusterID: identity.ClusterID, - VPNConnectionID: assignment.VPNConnectionID, - }, - PrimaryTimeout: 50 * time.Millisecond, - } } -func fabricGatewayTransportForAssignment(identity state.Identity, assignment client.NodeVPNAssignment, meshState *syntheticMeshState, api *client.Client) vpnruntime.PacketTransport { +func fabricGatewayTransportForAssignment(identity state.Identity, assignment client.NodeVPNAssignment, meshState *syntheticMeshState, _ *client.Client) vpnruntime.PacketTransport { if meshState == nil || meshState.ProductionForwardTransport == nil || meshState.VPNFabricInbox == nil { return nil } @@ -4266,7 +4287,7 @@ func fabricGatewayTransportForAssignment(identity state.Identity, assignment cli if !ok { return nil } - fabric := &vpnruntime.FabricPacketTransport{ + return &vpnruntime.FabricPacketTransport{ ForwardTransport: meshState.ProductionForwardTransport, Inbox: meshState.VPNFabricInbox, ClusterID: identity.ClusterID, @@ -4279,18 +4300,6 @@ func fabricGatewayTransportForAssignment(identity state.Identity, assignment cli SendDirection: vpnruntime.FabricDirectionGatewayToClient, ReceiveDirection: vpnruntime.FabricDirectionClientToGateway, } - if api == nil { - return fabric - } - return &vpnruntime.AdaptivePacketTransport{ - Primary: fabric, - Fallback: vpnruntime.BackendPacketTransport{ - API: api, - ClusterID: identity.ClusterID, - VPNConnectionID: assignment.VPNConnectionID, - }, - PrimaryTimeout: 50 * time.Millisecond, - } } func selectVPNPacketRoute(routes []mesh.SyntheticRoute, clusterID string, localNodeID string) (mesh.SyntheticRoute, string, bool) { diff --git a/agents/rap-node-agent/cmd/rap-node-agent/main_test.go b/agents/rap-node-agent/cmd/rap-node-agent/main_test.go index 830ecdc..fa6ff40 100644 --- a/agents/rap-node-agent/cmd/rap-node-agent/main_test.go +++ b/agents/rap-node-agent/cmd/rap-node-agent/main_test.go @@ -20,6 +20,7 @@ import ( "github.com/example/remote-access-platform/agents/rap-node-agent/internal/config" "github.com/example/remote-access-platform/agents/rap-node-agent/internal/mesh" "github.com/example/remote-access-platform/agents/rap-node-agent/internal/state" + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/vpnruntime" ) func TestLoadSyntheticMeshConfigPrefersScopedFile(t *testing.T) { @@ -197,6 +198,49 @@ func TestRouteManagerDecisionsFromControlPlaneRejectsGuardedRemediationCommand(t } } +func TestGatewayTransportForAssignmentUsesFabricWithoutBackendFallback(t *testing.T) { + inbox := vpnruntime.NewFabricPacketInbox(4) + transport := fabricGatewayTransportForAssignment( + state.Identity{ClusterID: "cluster-1", NodeID: "exit-1"}, + client.NodeVPNAssignment{VPNConnectionID: "vpn-1"}, + &syntheticMeshState{ + ProductionForwardTransport: noopProductionForwardTransport{}, + VPNFabricInbox: inbox, + Routes: []mesh.SyntheticRoute{{ + RouteID: "route-exit-entry", + ClusterID: "cluster-1", + SourceNodeID: "exit-1", + DestinationNodeID: "entry-1", + Hops: []string{"exit-1", "entry-1"}, + AllowedChannels: []string{mesh.ProductionChannelVPNPacket}, + ExpiresAt: time.Now().UTC().Add(time.Minute), + }}, + }, + nil, + ) + if _, ok := transport.(*vpnruntime.FabricPacketTransport); !ok { + t.Fatalf("transport = %T, want fabric packet transport without backend fallback", transport) + } +} + +func TestLocalGatewayTransportForAssignmentUsesLocalInboxWithoutBackendFallback(t *testing.T) { + transport := localGatewayTransportForAssignment( + state.Identity{ClusterID: "cluster-1", NodeID: "exit-1"}, + client.NodeVPNAssignment{VPNConnectionID: "vpn-1"}, + &syntheticMeshState{VPNFabricInbox: vpnruntime.NewFabricPacketInbox(4)}, + nil, + ) + if _, ok := transport.(*vpnruntime.LocalPacketTransport); !ok { + t.Fatalf("transport = %T, want local packet transport without backend fallback", transport) + } +} + +type noopProductionForwardTransport struct{} + +func (noopProductionForwardTransport) SendProduction(context.Context, string, mesh.ProductionEnvelope) (mesh.ProductionForwardResult, error) { + return mesh.ProductionForwardResult{}, nil +} + func TestRouteManagerDecisionsFromControlPlaneKeepsExplicitRemediationCommand(t *testing.T) { now := time.Now().UTC() report := &client.RoutePathDecisionReport{Decisions: []client.RoutePathDecision{{ diff --git a/agents/rap-node-agent/internal/agent/payload.go b/agents/rap-node-agent/internal/agent/payload.go index 50625c7..536b5fb 100644 --- a/agents/rap-node-agent/internal/agent/payload.go +++ b/agents/rap-node-agent/internal/agent/payload.go @@ -7,7 +7,7 @@ import ( "github.com/example/remote-access-platform/agents/rap-node-agent/internal/state" ) -const Version = "0.2.256-c18z82" +const Version = "0.2.267-vpnfarmonly" func EnrollmentPayload(clusterID, joinToken string, identity state.Identity) client.EnrollRequest { return client.EnrollRequest{ @@ -17,26 +17,29 @@ func EnrollmentPayload(clusterID, joinToken string, identity state.Identity) cli NodeFingerprint: identity.NodeFingerprint, PublicKey: identity.PublicKey, ReportedCapabilities: map[string]any{ - "can_accept_client_ingress": false, - "can_accept_node_ingress": false, - "can_route_mesh": false, - "can_run_rdp_worker": true, - "can_run_vnc_worker": false, - "can_run_vpn_exit": true, - "can_run_vpn_connector": true, - "can_run_file_cache": false, - "can_run_update_cache": false, - "can_run_video_relay": false, - "native_node_agent_version": Version, - "node_update_plan_contract": "rap.node_update_plan.v1", - "node_update_status_report": true, - "host_agent_update_required": true, - "service_supervision_enabled": false, - "vpn_assignment_status": true, - "vpn_packet_forwarding": true, - "vpn_fabric_packet_transport": true, - "vpn_local_gateway_shortcut": true, - "external_backend_entry_proxy": true, + "can_accept_client_ingress": false, + "can_accept_node_ingress": false, + "can_route_mesh": false, + "can_run_rdp_worker": true, + "can_run_vnc_worker": false, + "can_run_vpn_exit": true, + "can_run_vpn_connector": true, + "can_run_file_cache": false, + "can_run_update_cache": false, + "can_run_video_relay": false, + "native_node_agent_version": Version, + "node_update_plan_contract": "rap.node_update_plan.v1", + "node_update_status_report": true, + "host_agent_update_required": true, + "service_supervision_enabled": false, + "vpn_assignment_status": true, + "vpn_packet_forwarding": true, + "vpn_fabric_packet_transport": true, + "vpn_local_gateway_shortcut": false, + "vpn_farm_owned_dataplane": true, + "vpn_backend_relay_fallback": false, + "fabric_service_channel_required": true, + "external_backend_entry_proxy": true, }, ReportedFacts: map[string]any{ "os": runtime.GOOS, @@ -53,14 +56,17 @@ func HeartbeatPayload() client.HeartbeatRequest { HealthStatus: "healthy", ReportedVersion: Version, Capabilities: map[string]any{ - "native_node_agent": true, - "node_update_plan_contract": "rap.node_update_plan.v1", - "node_update_status_report": true, - "vpn_assignment_status": true, - "vpn_packet_forwarding": true, - "vpn_fabric_packet_transport": true, - "vpn_local_gateway_shortcut": true, - "external_backend_entry_proxy": true, + "native_node_agent": true, + "node_update_plan_contract": "rap.node_update_plan.v1", + "node_update_status_report": true, + "vpn_assignment_status": true, + "vpn_packet_forwarding": true, + "vpn_fabric_packet_transport": true, + "vpn_local_gateway_shortcut": false, + "vpn_farm_owned_dataplane": true, + "vpn_backend_relay_fallback": false, + "fabric_service_channel_required": true, + "external_backend_entry_proxy": true, }, ServiceStates: map[string]any{ "workload_supervision": "not_implemented_c3", diff --git a/agents/rap-node-agent/internal/vpnruntime/fabric_transport.go b/agents/rap-node-agent/internal/vpnruntime/fabric_transport.go index 6fa5e53..7c1ae32 100644 --- a/agents/rap-node-agent/internal/vpnruntime/fabric_transport.go +++ b/agents/rap-node-agent/internal/vpnruntime/fabric_transport.go @@ -44,14 +44,16 @@ type FabricPacketTransport struct { } type FabricClientPacketIngress struct { - ForwardTransport mesh.ProductionForwardTransport - Inbox *FabricPacketInbox - Routes func() []mesh.SyntheticRoute - LocalGateway func(vpnConnectionID string) bool - FlowScheduler *FabricFlowScheduler - MaxParallelFlowSends int - RecoveryPolicyFingerprint string - AdaptivePolicyFingerprint string + ForwardTransport mesh.ProductionForwardTransport + Inbox *FabricPacketInbox + Routes func() []mesh.SyntheticRoute + LocalGateway func(vpnConnectionID string) bool + AllowLegacyLocalGatewayFallback bool + FlowScheduler *FabricFlowScheduler + MaxParallelFlowSends int + RecoveryPolicyFingerprint string + AdaptivePolicyFingerprint string + PreventLastRouteWithdrawal bool ClusterID string LocalNodeID string @@ -1623,7 +1625,7 @@ func (i *FabricClientPacketIngress) ReceiveClientPacketBatch(ctx context.Context } func (i *FabricClientPacketIngress) localGatewayReady(vpnConnectionID string) bool { - if i == nil || i.inbox() == nil || vpnConnectionID == "" { + if i == nil || !i.AllowLegacyLocalGatewayFallback || i.inbox() == nil || vpnConnectionID == "" { return false } localGateway := i.localGateway() @@ -1669,6 +1671,7 @@ func (i *FabricClientPacketIngress) routeCandidatesWithPreference(clusterID stri var preferred []fabricClientRouteCandidate var alternates []fabricClientRouteCandidate var deferred []fabricClientRouteCandidate + var withdrawn []fabricClientRouteCandidate manager := i.routeManager() if preferredRouteID != "" && manager.isWithdrawn(preferredRouteID) { if replacementRouteID := manager.replacementRouteID(preferredRouteID); replacementRouteID != "" { @@ -1684,9 +1687,6 @@ func (i *FabricClientPacketIngress) routeCandidatesWithPreference(clusterID stri if route.ClusterID != clusterID || route.SourceNodeID != localNodeID || !containsString(route.AllowedChannels, mesh.ProductionChannelVPNPacket) { continue } - if manager.isWithdrawn(route.RouteID) { - continue - } if !route.ExpiresAt.IsZero() && !route.ExpiresAt.After(now) { continue } @@ -1695,6 +1695,10 @@ func (i *FabricClientPacketIngress) routeCandidatesWithPreference(clusterID stri continue } candidate := fabricClientRouteCandidate{Route: route, NextHop: nextHop} + if manager.isWithdrawn(route.RouteID) { + withdrawn = append(withdrawn, candidate) + continue + } if preferredRouteID != "" && route.RouteID == preferredRouteID { preferred = append(preferred, candidate) } else if avoidRouteID != "" && route.RouteID == avoidRouteID { @@ -1703,9 +1707,32 @@ func (i *FabricClientPacketIngress) routeCandidatesWithPreference(clusterID stri alternates = append(alternates, candidate) } } + if len(preferred) > 0 { + destinationNodeID := strings.TrimSpace(preferred[0].Route.DestinationNodeID) + alternates = filterRouteCandidatesByDestination(alternates, destinationNodeID) + deferred = filterRouteCandidatesByDestination(deferred, destinationNodeID) + } out := append(preferred, alternates...) out = i.applyRouteQualityPreferences(out, preferredRouteID) - return append(out, deferred...) + out = append(out, deferred...) + if len(out) == 0 && i.preventLastRouteWithdrawal() { + return withdrawn + } + return out +} + +func filterRouteCandidatesByDestination(candidates []fabricClientRouteCandidate, destinationNodeID string) []fabricClientRouteCandidate { + destinationNodeID = strings.TrimSpace(destinationNodeID) + if destinationNodeID == "" || len(candidates) == 0 { + return candidates + } + out := candidates[:0] + for _, candidate := range candidates { + if strings.TrimSpace(candidate.Route.DestinationNodeID) == destinationNodeID { + out = append(out, candidate) + } + } + return out } func (i *FabricClientPacketIngress) applyRouteQualityPreferences(candidates []fabricClientRouteCandidate, preferredRouteID string) []fabricClientRouteCandidate { @@ -1744,6 +1771,15 @@ func (i *FabricClientPacketIngress) applyRouteQualityPreferences(candidates []fa return out } +func (i *FabricClientPacketIngress) preventLastRouteWithdrawal() bool { + if i == nil { + return false + } + i.mu.Lock() + defer i.mu.Unlock() + return i.PreventLastRouteWithdrawal +} + func (t *FabricPacketTransport) ReceiveGatewayPacketBatch(ctx context.Context, timeout time.Duration) ([][]byte, error) { if t == nil || t.Inbox == nil { return nil, mesh.ErrForwardRuntimeUnavailable diff --git a/agents/rap-node-agent/internal/vpnruntime/fabric_transport_test.go b/agents/rap-node-agent/internal/vpnruntime/fabric_transport_test.go index afbf28c..42aefd2 100644 --- a/agents/rap-node-agent/internal/vpnruntime/fabric_transport_test.go +++ b/agents/rap-node-agent/internal/vpnruntime/fabric_transport_test.go @@ -524,6 +524,52 @@ func TestFabricClientPacketIngressTriesAlternateRouteBeforeBackendFallback(t *te } } +func TestFabricClientPacketIngressDoesNotFailOverPreferredRouteToDifferentDestination(t *testing.T) { + transport := &failoverProductionTransport{failNextHop: "relay-home"} + ingress := &FabricClientPacketIngress{ + ForwardTransport: transport, + Inbox: NewFabricPacketInbox(4), + ClusterID: "cluster-1", + LocalNodeID: "entry-1", + Routes: func() []mesh.SyntheticRoute { + return []mesh.SyntheticRoute{ + { + RouteID: "route-other", + ClusterID: "cluster-1", + SourceNodeID: "entry-1", + DestinationNodeID: "ifcm-1", + Hops: []string{"entry-1", "relay-ifcm", "ifcm-1"}, + AllowedChannels: []string{mesh.ProductionChannelVPNPacket}, + ExpiresAt: time.Now().UTC().Add(time.Minute), + MaxTTL: 8, + }, + { + RouteID: "route-home", + ClusterID: "cluster-1", + SourceNodeID: "entry-1", + DestinationNodeID: "home-1", + Hops: []string{"entry-1", "relay-home", "home-1"}, + AllowedChannels: []string{mesh.ProductionChannelVPNPacket}, + ExpiresAt: time.Now().UTC().Add(time.Minute), + MaxTTL: 8, + }, + } + }, + } + ingress.PreferClientRoute("route-home") + + err := ingress.SendClientPacketBatch(context.Background(), "cluster-1", "vpn-1", [][]byte{[]byte("packet")}) + if err == nil { + t.Fatal("send client packet batch succeeded after preferred route failure; want failure without cross-destination fallback") + } + if len(transport.calls) != 1 || transport.calls[0] != "relay-home" { + t.Fatalf("route attempts = %#v, want only relay-home", transport.calls) + } + if transport.envelope.RouteID == "route-other" { + t.Fatalf("cross-destination route was used: %+v", transport.envelope) + } +} + func TestFabricClientPacketIngressAvoidsChannelFailedRouteOnNextSend(t *testing.T) { transport := &captureManyProductionTransport{} scheduler := NewFabricFlowScheduler(8, 16) @@ -822,6 +868,44 @@ func TestFabricClientPacketIngressPendingDegradedFallbackWithdrawsRouteWithoutAl } } +func TestFabricClientPacketIngressKeepsLastRouteWhenWithdrawalPreventionEnabled(t *testing.T) { + transport := &captureManyProductionTransport{} + ingress := &FabricClientPacketIngress{ + ForwardTransport: transport, + Inbox: NewFabricPacketInbox(4), + ClusterID: "cluster-1", + LocalNodeID: "entry-1", + PreventLastRouteWithdrawal: true, + Routes: func() []mesh.SyntheticRoute { + return []mesh.SyntheticRoute{{ + RouteID: "route-only", + ClusterID: "cluster-1", + SourceNodeID: "entry-1", + DestinationNodeID: "exit-1", + Hops: []string{"entry-1", "exit-1"}, + AllowedChannels: []string{mesh.ProductionChannelVPNPacket}, + ExpiresAt: time.Now().UTC().Add(time.Minute), + MaxTTL: 8, + }} + }, + } + ingress.UpdateRouteManager([]FabricServiceChannelRouteManagerDecision{{ + RouteID: "route-only", + RebuildStatus: "pending_degraded_fallback", + DecisionSource: "service_channel_feedback_no_alternate", + }}, "config-v2", time.Now().UTC()) + + if err := ingress.SendClientPacketBatch(context.Background(), "cluster-1", "vpn-1", [][]byte{[]byte("packet")}); err != nil { + t.Fatalf("send client packet batch: %v", err) + } + if len(transport.envelopes) != 1 || transport.envelopes[0].RouteID != "route-only" { + t.Fatalf("envelopes = %+v, want preserved last route", transport.envelopes) + } + if snapshot := ingress.Snapshot("cluster-1"); snapshot.RouteCandidateCount != 1 { + t.Fatalf("route candidate count = %d, want last withdrawn route preserved", snapshot.RouteCandidateCount) + } +} + func TestFabricClientPacketIngressMarksChannelForRebuildAfterRepeatedRouteFailures(t *testing.T) { transport := &failoverProductionTransport{failNextHop: "relay-bad"} scheduler := NewFabricFlowScheduler(8, 16) @@ -1930,9 +2014,10 @@ func TestFabricClientPacketIngressBoundedLoadReportsPerChannelDrops(t *testing.T func TestFabricClientPacketIngressUsesLocalGatewayShortcutWithoutRoute(t *testing.T) { inbox := NewFabricPacketInbox(4) ingress := &FabricClientPacketIngress{ - Inbox: inbox, - ClusterID: "cluster-1", - LocalNodeID: "entry-1", + Inbox: inbox, + ClusterID: "cluster-1", + LocalNodeID: "entry-1", + AllowLegacyLocalGatewayFallback: true, LocalGateway: func(vpnConnectionID string) bool { return vpnConnectionID == "vpn-1" }, @@ -1954,9 +2039,10 @@ func TestFabricClientPacketIngressUsesLocalGatewayShortcutWithoutRoute(t *testin func TestFabricClientPacketIngressReceivesLocalGatewayReplyWithoutRoute(t *testing.T) { inbox := NewFabricPacketInbox(4) ingress := &FabricClientPacketIngress{ - Inbox: inbox, - ClusterID: "cluster-1", - LocalNodeID: "entry-1", + Inbox: inbox, + ClusterID: "cluster-1", + LocalNodeID: "entry-1", + AllowLegacyLocalGatewayFallback: true, LocalGateway: func(vpnConnectionID string) bool { return vpnConnectionID == "vpn-1" },