Refactor RDP proxy handling and update related tests
This commit is contained in:
@@ -53,6 +53,10 @@ const (
|
||||
FabricServiceClassRemoteWorkspace = "remote_workspace"
|
||||
FabricServiceClassFileTransfer = "file_transfer"
|
||||
FabricServiceClassVideo = "video"
|
||||
FabricServiceClassPlatformAdmin = "platform_admin"
|
||||
FabricServiceClassClusterAdmin = "cluster_admin"
|
||||
FabricServiceClassOrganization = "organization_portal"
|
||||
FabricServiceClassUserPortal = "user_portal"
|
||||
|
||||
FabricChannelControl = "control"
|
||||
FabricChannelInteractive = "interactive"
|
||||
@@ -62,16 +66,27 @@ const (
|
||||
)
|
||||
|
||||
var allowedNodeRoles = map[string]struct{}{
|
||||
"entry-node": {},
|
||||
"relay-node": {},
|
||||
"core-mesh": {},
|
||||
"rdp-worker": {},
|
||||
"vnc-worker": {},
|
||||
"vpn-exit": {},
|
||||
"vpn-connector": {},
|
||||
"file-storage-cache": {},
|
||||
"update-cache": {},
|
||||
"video-relay": {},
|
||||
"public-ingress": {},
|
||||
"admin-ingress": {},
|
||||
"global-admin-runtime": {},
|
||||
"cluster-admin-runtime": {},
|
||||
"organization-portal-runtime": {},
|
||||
"user-portal-runtime": {},
|
||||
"identity-runtime": {},
|
||||
"policy-authority": {},
|
||||
"audit-sink": {},
|
||||
"entry-node": {},
|
||||
"relay-node": {},
|
||||
"core-mesh": {},
|
||||
"rdp-worker": {},
|
||||
"vnc-worker": {},
|
||||
"vpn-exit": {},
|
||||
"vpn-connector": {},
|
||||
"vpn-client": {},
|
||||
"ipv4-egress": {},
|
||||
"file-storage-cache": {},
|
||||
"update-cache": {},
|
||||
"video-relay": {},
|
||||
}
|
||||
|
||||
type Cluster struct {
|
||||
@@ -353,6 +368,7 @@ type NodeUpdatePlan struct {
|
||||
Artifact *ReleaseArtifact `json:"artifact,omitempty"`
|
||||
AuthorityPayload json.RawMessage `json:"authority_payload,omitempty"`
|
||||
AuthoritySignature *ClusterSignature `json:"authority_signature,omitempty"`
|
||||
AuthorityQuorum *QuorumEnvelope `json:"authority_quorum,omitempty"`
|
||||
ProductionForwarding bool `json:"production_forwarding"`
|
||||
}
|
||||
|
||||
@@ -373,14 +389,15 @@ type NodeUpdateStatus struct {
|
||||
}
|
||||
|
||||
type NodeBootstrap struct {
|
||||
NodeID string `json:"node_id"`
|
||||
ClusterID string `json:"cluster_id"`
|
||||
IdentityStatus string `json:"identity_status"`
|
||||
Certificate map[string]any `json:"certificate"`
|
||||
HeartbeatEndpoint string `json:"heartbeat_endpoint"`
|
||||
ClusterAuthority *ClusterAuthorityDescriptor `json:"cluster_authority,omitempty"`
|
||||
AuthorityPayload json.RawMessage `json:"authority_payload,omitempty"`
|
||||
AuthoritySignature *ClusterSignature `json:"authority_signature,omitempty"`
|
||||
NodeID string `json:"node_id"`
|
||||
ClusterID string `json:"cluster_id"`
|
||||
IdentityStatus string `json:"identity_status"`
|
||||
Certificate map[string]any `json:"certificate"`
|
||||
HeartbeatEndpoint string `json:"heartbeat_endpoint"`
|
||||
ClusterAuthority *ClusterAuthorityDescriptor `json:"cluster_authority,omitempty"`
|
||||
ClusterAuthorityQuorum *QuorumDescriptor `json:"cluster_authority_quorum,omitempty"`
|
||||
AuthorityPayload json.RawMessage `json:"authority_payload,omitempty"`
|
||||
AuthoritySignature *ClusterSignature `json:"authority_signature,omitempty"`
|
||||
}
|
||||
|
||||
type NodeJoinRequest struct {
|
||||
@@ -1531,6 +1548,8 @@ type ClusterAuthorityState struct {
|
||||
}
|
||||
|
||||
type ClusterSignature = clusterauth.Signature
|
||||
type QuorumEnvelope = clusterauth.QuorumEnvelope
|
||||
type QuorumDescriptor = clusterauth.QuorumDescriptor
|
||||
|
||||
type ClusterAuthorityDescriptor struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
@@ -1545,7 +1564,9 @@ type ClusterAuthorityDescriptor struct {
|
||||
|
||||
type ClusterAuthorityKey struct {
|
||||
ClusterAuthorityDescriptor
|
||||
PrivateKey string `json:"-"`
|
||||
PrivateKey string `json:"-"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
QuorumDescriptor *QuorumDescriptor `json:"quorum_descriptor,omitempty"`
|
||||
}
|
||||
|
||||
type ClusterAdminSummary struct {
|
||||
@@ -1808,6 +1829,8 @@ type VPNClientConnection struct {
|
||||
AllowedNodeIDs []string `json:"allowed_node_ids"`
|
||||
EntryNodeIDs []string `json:"entry_node_ids"`
|
||||
ExitNodeID string `json:"exit_node_id,omitempty"`
|
||||
ExitPoolID string `json:"exit_pool_id,omitempty"`
|
||||
ExitPoolName string `json:"exit_pool_name,omitempty"`
|
||||
ActiveLease *NodeVPNAssignmentLease `json:"active_lease,omitempty"`
|
||||
RoutePolicies json.RawMessage `json:"route_policies"`
|
||||
ClientConfig json.RawMessage `json:"client_config"`
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -33,6 +34,13 @@ type Module struct {
|
||||
vpnClientDiagnosticHub *vpnClientDiagnosticHub
|
||||
}
|
||||
|
||||
const (
|
||||
adminRuntimeProjectionRequestSchema = "rap.web_ingress.control_api_projection_request.v1"
|
||||
adminRuntimeProjectionResponseSchema = "rap.web_ingress.control_api_projection_response.v1"
|
||||
adminRuntimeProjectionBodySchema = "rap.control_api.admin_runtime_projection.v1"
|
||||
adminRuntimeManifestSchema = "rap.web_ingress.ui_manifest.v1"
|
||||
)
|
||||
|
||||
func NewModule(deps module.Dependencies, verifiers ...*authority.Verifier) *Module {
|
||||
store := NewPostgresStore(deps.Infra.DB, verifiers...)
|
||||
if deps.Config.Secret.EncryptionKeyBase64 != "" {
|
||||
@@ -52,6 +60,7 @@ func (m *Module) Name() string {
|
||||
}
|
||||
|
||||
func (m *Module) RegisterRoutes(router chi.Router) {
|
||||
router.Get("/downloads/{fileName}", m.downloadReleaseFile)
|
||||
router.Route("/clusters", func(r chi.Router) {
|
||||
r.Get("/", m.listClusters)
|
||||
r.Post("/", m.createCluster)
|
||||
@@ -90,6 +99,7 @@ func (m *Module) RegisterRoutes(router chi.Router) {
|
||||
r.Put("/{clusterID}/nodes/{nodeID}/workloads/{serviceType}/desired", m.setDesiredWorkload)
|
||||
r.Post("/{clusterID}/nodes/{nodeID}/workloads/{serviceType}/status", m.reportWorkloadStatus)
|
||||
r.Get("/{clusterID}/nodes/{nodeID}/workloads/status", m.listWorkloadStatuses)
|
||||
r.Post("/{clusterID}/nodes/{nodeID}/admin-runtime/projection", m.projectAdminRuntime)
|
||||
r.Get("/{clusterID}/mesh/links", m.listMeshLinks)
|
||||
r.Post("/{clusterID}/mesh/links", m.reportMeshLink)
|
||||
r.Get("/{clusterID}/mesh/route-intents", m.listRouteIntents)
|
||||
@@ -97,14 +107,6 @@ func (m *Module) RegisterRoutes(router chi.Router) {
|
||||
r.Post("/{clusterID}/mesh/route-intents/{routeIntentID}/expire", m.expireRouteIntent)
|
||||
r.Post("/{clusterID}/mesh/route-intents/{routeIntentID}/disable", m.disableRouteIntent)
|
||||
r.Get("/{clusterID}/mesh/qos-policies", m.listQoSPolicies)
|
||||
r.Get("/{clusterID}/fabric/entry-points", m.listFabricEntryPoints)
|
||||
r.Post("/{clusterID}/fabric/entry-points", m.createFabricEntryPoint)
|
||||
r.Get("/{clusterID}/fabric/entry-points/{entryPointID}/nodes", m.listFabricEntryPointNodes)
|
||||
r.Put("/{clusterID}/fabric/entry-points/{entryPointID}/nodes/{nodeID}", m.setFabricEntryPointNode)
|
||||
r.Get("/{clusterID}/fabric/egress-pools", m.listFabricEgressPools)
|
||||
r.Post("/{clusterID}/fabric/egress-pools", m.createFabricEgressPool)
|
||||
r.Get("/{clusterID}/fabric/egress-pools/{egressPoolID}/nodes", m.listFabricEgressPoolNodes)
|
||||
r.Put("/{clusterID}/fabric/egress-pools/{egressPoolID}/nodes/{nodeID}", m.setFabricEgressPoolNode)
|
||||
r.Get("/{clusterID}/fabric/service-channels/route-feedback", m.listFabricServiceChannelRouteFeedback)
|
||||
r.Post("/{clusterID}/fabric/service-channels/route-feedback/expire", m.expireFabricServiceChannelRouteFeedback)
|
||||
r.Get("/{clusterID}/fabric/service-channels/rebuild-attempts", m.listFabricServiceChannelRouteRebuildAttempts)
|
||||
@@ -172,6 +174,24 @@ func (m *Module) RegisterRoutes(router chi.Router) {
|
||||
router.Put("/fabric/testing-flags", m.upsertFabricTestingFlag)
|
||||
}
|
||||
|
||||
func (m *Module) downloadReleaseFile(w http.ResponseWriter, r *http.Request) {
|
||||
fileName := filepath.Base(strings.TrimSpace(chi.URLParam(r, "fileName")))
|
||||
if fileName == "" || fileName == "." || fileName != strings.TrimSpace(chi.URLParam(r, "fileName")) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
releaseDir := strings.TrimSpace(os.Getenv("RAP_RELEASE_DIR"))
|
||||
if releaseDir == "" {
|
||||
releaseDir = "/tmp/rap-release"
|
||||
}
|
||||
path := filepath.Join(releaseDir, fileName)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
|
||||
func (m *Module) listClusters(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := m.service.ListClusters(r.Context(), r.URL.Query().Get("actor_user_id"))
|
||||
if writeServiceError(w, err) {
|
||||
@@ -239,6 +259,126 @@ func (m *Module) updateCluster(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"cluster": item})
|
||||
}
|
||||
|
||||
func (m *Module) projectAdminRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
clusterID := chi.URLParam(r, "clusterID")
|
||||
nodeID := chi.URLParam(r, "nodeID")
|
||||
var payload struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Query string `json:"query"`
|
||||
Host string `json:"host"`
|
||||
Scope string `json:"scope"`
|
||||
ServiceClass string `json:"service_class"`
|
||||
ObservedAt string `json:"observed_at"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid admin runtime projection payload")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(payload.SchemaVersion) != adminRuntimeProjectionRequestSchema {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid admin runtime projection schema")
|
||||
return
|
||||
}
|
||||
method := strings.ToUpper(strings.TrimSpace(payload.Method))
|
||||
path := strings.TrimSpace(payload.Path)
|
||||
if method != http.MethodGet && method != http.MethodHead {
|
||||
httpx.WriteJSON(w, http.StatusOK, adminRuntimeProjectionResponse(http.StatusForbidden, "blocked", "control_api_mutation_rejected", nil))
|
||||
return
|
||||
}
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
scope := strings.TrimSpace(payload.Scope)
|
||||
serviceClass := normalizeFabricServiceClass(payload.ServiceClass)
|
||||
if !isAllowedAdminRuntimeProjectionScope(scope, serviceClass) {
|
||||
httpx.WriteJSON(w, http.StatusOK, adminRuntimeProjectionResponse(http.StatusForbidden, "blocked", "control_api_projection_scope_rejected", nil))
|
||||
return
|
||||
}
|
||||
body := map[string]any{
|
||||
"schema_version": adminRuntimeProjectionBodySchema,
|
||||
"cluster_id": clusterID,
|
||||
"node_id": nodeID,
|
||||
"scope": scope,
|
||||
"service_class": serviceClass,
|
||||
"path": path,
|
||||
"query": payload.Query,
|
||||
"host": payload.Host,
|
||||
"projection": "read_only",
|
||||
"audit_required": true,
|
||||
}
|
||||
if path == "/ui-manifest" || strings.HasSuffix(path, "/ui-manifest") {
|
||||
body["manifest"] = adminRuntimeManifest(scope, serviceClass)
|
||||
httpx.WriteJSON(w, http.StatusOK, adminRuntimeProjectionResponse(http.StatusOK, "ready", "ui_manifest_ready", body))
|
||||
return
|
||||
}
|
||||
if path == "/healthz" || path == "/readyz" {
|
||||
httpx.WriteJSON(w, http.StatusOK, adminRuntimeProjectionResponse(http.StatusOK, "ready", "admin_runtime_projection_ready", body))
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, adminRuntimeProjectionResponse(http.StatusNotImplemented, "blocked", "control_api_projection_not_implemented", body))
|
||||
}
|
||||
|
||||
func adminRuntimeProjectionResponse(statusCode int, status string, reason string, body map[string]any) map[string]any {
|
||||
raw, _ := json.Marshal(body)
|
||||
return map[string]any{
|
||||
"schema_version": adminRuntimeProjectionResponseSchema,
|
||||
"status": status,
|
||||
"reason": reason,
|
||||
"status_code": statusCode,
|
||||
"headers": map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
"body": json.RawMessage(raw),
|
||||
}
|
||||
}
|
||||
|
||||
func isAllowedAdminRuntimeProjectionScope(scope string, serviceClass string) bool {
|
||||
switch serviceClass {
|
||||
case FabricServiceClassPlatformAdmin:
|
||||
return scope == "platform"
|
||||
case FabricServiceClassClusterAdmin:
|
||||
return scope == "cluster"
|
||||
case FabricServiceClassOrganization:
|
||||
return scope == "organization"
|
||||
case FabricServiceClassUserPortal:
|
||||
return scope == "user" || scope == "organization"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func adminRuntimeManifest(scope string, serviceClass string) map[string]any {
|
||||
sections := []string{"status"}
|
||||
actions := []string{"read_status"}
|
||||
switch strings.TrimSpace(serviceClass) {
|
||||
case FabricServiceClassPlatformAdmin:
|
||||
sections = []string{"clusters", "nodes", "roles", "fabric", "workloads", "audit"}
|
||||
actions = []string{"read_platform_summary", "read_cluster_summaries", "read_node_status"}
|
||||
case FabricServiceClassClusterAdmin:
|
||||
sections = []string{"cluster", "nodes", "fabric", "workloads", "audit"}
|
||||
actions = []string{"read_cluster_summary", "read_node_status"}
|
||||
case FabricServiceClassOrganization:
|
||||
sections = []string{"organization", "sessions", "resources", "audit"}
|
||||
actions = []string{"read_organization_summary", "read_sessions"}
|
||||
case FabricServiceClassUserPortal:
|
||||
sections = []string{"profile", "sessions", "resources"}
|
||||
actions = []string{"read_profile", "read_sessions"}
|
||||
}
|
||||
return map[string]any{
|
||||
"schema_version": adminRuntimeManifestSchema,
|
||||
"scope": scope,
|
||||
"service_class": serviceClass,
|
||||
"sections": sections,
|
||||
"allowed_actions": actions,
|
||||
"mutation_enabled": false,
|
||||
"projection_binding": "control_api_read_only",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Module) listClusterNodes(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := m.service.ListClusterNodes(r.Context(), r.URL.Query().Get("actor_user_id"), chi.URLParam(r, "clusterID"))
|
||||
if writeServiceError(w, err) {
|
||||
@@ -1073,160 +1213,6 @@ func (m *Module) listQoSPolicies(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"qos_policies": items})
|
||||
}
|
||||
|
||||
func (m *Module) listFabricEntryPoints(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := m.service.ListFabricEntryPoints(r.Context(), r.URL.Query().Get("actor_user_id"), chi.URLParam(r, "clusterID"))
|
||||
if writeServiceError(w, err) {
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"entry_points": items})
|
||||
}
|
||||
|
||||
func (m *Module) createFabricEntryPoint(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
ActorUserID string `json:"actor_user_id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
EndpointType string `json:"endpoint_type"`
|
||||
PublicEndpoint *string `json:"public_endpoint"`
|
||||
Policy json.RawMessage `json:"policy"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid fabric entry point payload")
|
||||
return
|
||||
}
|
||||
item, err := m.service.CreateFabricEntryPoint(r.Context(), CreateFabricEntryPointInput{
|
||||
ActorUserID: payload.ActorUserID,
|
||||
ClusterID: chi.URLParam(r, "clusterID"),
|
||||
Name: payload.Name,
|
||||
Status: payload.Status,
|
||||
EndpointType: payload.EndpointType,
|
||||
PublicEndpoint: payload.PublicEndpoint,
|
||||
Policy: payload.Policy,
|
||||
Metadata: payload.Metadata,
|
||||
})
|
||||
if writeServiceError(w, err) {
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"entry_point": item})
|
||||
}
|
||||
|
||||
func (m *Module) setFabricEntryPointNode(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
ActorUserID string `json:"actor_user_id"`
|
||||
Status string `json:"status"`
|
||||
Priority int `json:"priority"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid fabric entry point node payload")
|
||||
return
|
||||
}
|
||||
item, err := m.service.SetFabricEntryPointNode(r.Context(), SetFabricEntryPointNodeInput{
|
||||
ActorUserID: payload.ActorUserID,
|
||||
ClusterID: chi.URLParam(r, "clusterID"),
|
||||
EntryPointID: chi.URLParam(r, "entryPointID"),
|
||||
NodeID: chi.URLParam(r, "nodeID"),
|
||||
Status: payload.Status,
|
||||
Priority: payload.Priority,
|
||||
Metadata: payload.Metadata,
|
||||
})
|
||||
if writeServiceError(w, err) {
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"entry_point_node": item})
|
||||
}
|
||||
|
||||
func (m *Module) listFabricEntryPointNodes(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := m.service.ListFabricEntryPointNodes(
|
||||
r.Context(),
|
||||
r.URL.Query().Get("actor_user_id"),
|
||||
chi.URLParam(r, "clusterID"),
|
||||
chi.URLParam(r, "entryPointID"),
|
||||
)
|
||||
if writeServiceError(w, err) {
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"entry_point_nodes": items})
|
||||
}
|
||||
|
||||
func (m *Module) listFabricEgressPools(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := m.service.ListFabricEgressPools(r.Context(), r.URL.Query().Get("actor_user_id"), chi.URLParam(r, "clusterID"))
|
||||
if writeServiceError(w, err) {
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"egress_pools": items})
|
||||
}
|
||||
|
||||
func (m *Module) createFabricEgressPool(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
ActorUserID string `json:"actor_user_id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Description *string `json:"description"`
|
||||
RouteScope json.RawMessage `json:"route_scope"`
|
||||
Policy json.RawMessage `json:"policy"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid fabric egress pool payload")
|
||||
return
|
||||
}
|
||||
item, err := m.service.CreateFabricEgressPool(r.Context(), CreateFabricEgressPoolInput{
|
||||
ActorUserID: payload.ActorUserID,
|
||||
ClusterID: chi.URLParam(r, "clusterID"),
|
||||
Name: payload.Name,
|
||||
Status: payload.Status,
|
||||
Description: payload.Description,
|
||||
RouteScope: payload.RouteScope,
|
||||
Policy: payload.Policy,
|
||||
Metadata: payload.Metadata,
|
||||
})
|
||||
if writeServiceError(w, err) {
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"egress_pool": item})
|
||||
}
|
||||
|
||||
func (m *Module) setFabricEgressPoolNode(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
ActorUserID string `json:"actor_user_id"`
|
||||
Status string `json:"status"`
|
||||
Priority int `json:"priority"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid fabric egress pool node payload")
|
||||
return
|
||||
}
|
||||
item, err := m.service.SetFabricEgressPoolNode(r.Context(), SetFabricEgressPoolNodeInput{
|
||||
ActorUserID: payload.ActorUserID,
|
||||
ClusterID: chi.URLParam(r, "clusterID"),
|
||||
EgressPoolID: chi.URLParam(r, "egressPoolID"),
|
||||
NodeID: chi.URLParam(r, "nodeID"),
|
||||
Status: payload.Status,
|
||||
Priority: payload.Priority,
|
||||
Metadata: payload.Metadata,
|
||||
})
|
||||
if writeServiceError(w, err) {
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"egress_pool_node": item})
|
||||
}
|
||||
|
||||
func (m *Module) listFabricEgressPoolNodes(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := m.service.ListFabricEgressPoolNodes(
|
||||
r.Context(),
|
||||
r.URL.Query().Get("actor_user_id"),
|
||||
chi.URLParam(r, "clusterID"),
|
||||
chi.URLParam(r, "egressPoolID"),
|
||||
)
|
||||
if writeServiceError(w, err) {
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"egress_pool_nodes": items})
|
||||
}
|
||||
|
||||
func (m *Module) issueFabricServiceChannelLease(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
ActorUserID string `json:"actor_user_id"`
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestProjectAdminRuntimeReturnsReadOnlyManifest(t *testing.T) {
|
||||
router := chi.NewRouter()
|
||||
module := &Module{}
|
||||
router.Post("/clusters/{clusterID}/nodes/{nodeID}/admin-runtime/projection", module.projectAdminRuntime)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/clusters/cluster-1/nodes/node-1/admin-runtime/projection", bytes.NewReader([]byte(`{
|
||||
"schema_version":"rap.web_ingress.control_api_projection_request.v1",
|
||||
"method":"GET",
|
||||
"path":"/platform-admin/ui-manifest",
|
||||
"scope":"platform",
|
||||
"service_class":"platform_admin"
|
||||
}`)))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
StatusCode int `json:"status_code"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if response.SchemaVersion != "rap.web_ingress.control_api_projection_response.v1" ||
|
||||
response.Status != "ready" ||
|
||||
response.Reason != "ui_manifest_ready" ||
|
||||
response.StatusCode != http.StatusOK ||
|
||||
response.Headers["Content-Type"] != "application/json" {
|
||||
t.Fatalf("response = %+v", response)
|
||||
}
|
||||
var body struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
ClusterID string `json:"cluster_id"`
|
||||
NodeID string `json:"node_id"`
|
||||
Manifest map[string]any `json:"manifest"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body, &body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if body.ClusterID != "cluster-1" ||
|
||||
body.NodeID != "node-1" ||
|
||||
body.Manifest["projection_binding"] != "control_api_read_only" ||
|
||||
body.Manifest["mutation_enabled"] != false {
|
||||
t.Fatalf("body = %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectAdminRuntimeRejectsMutations(t *testing.T) {
|
||||
router := chi.NewRouter()
|
||||
module := &Module{}
|
||||
router.Post("/clusters/{clusterID}/nodes/{nodeID}/admin-runtime/projection", module.projectAdminRuntime)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/clusters/cluster-1/nodes/node-1/admin-runtime/projection", bytes.NewReader([]byte(`{
|
||||
"schema_version":"rap.web_ingress.control_api_projection_request.v1",
|
||||
"method":"POST",
|
||||
"path":"/platform-admin/nodes",
|
||||
"scope":"platform",
|
||||
"service_class":"platform_admin"
|
||||
}`)))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
StatusCode int `json:"status_code"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if response.Status != "blocked" || response.Reason != "control_api_mutation_rejected" || response.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("response = %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectAdminRuntimeReturnsHealthProjection(t *testing.T) {
|
||||
router := chi.NewRouter()
|
||||
module := &Module{}
|
||||
router.Post("/clusters/{clusterID}/nodes/{nodeID}/admin-runtime/projection", module.projectAdminRuntime)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/clusters/cluster-1/nodes/node-1/admin-runtime/projection", bytes.NewReader([]byte(`{
|
||||
"schema_version":"rap.web_ingress.control_api_projection_request.v1",
|
||||
"method":"GET",
|
||||
"path":"/readyz",
|
||||
"scope":"platform",
|
||||
"service_class":"platform_admin"
|
||||
}`)))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
StatusCode int `json:"status_code"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if response.Status != "ready" || response.Reason != "admin_runtime_projection_ready" || response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("response = %+v", response)
|
||||
}
|
||||
var body struct {
|
||||
Projection string `json:"projection"`
|
||||
AuditRequired bool `json:"audit_required"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body, &body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if body.Projection != "read_only" || !body.AuditRequired {
|
||||
t.Fatalf("body = %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectAdminRuntimeBlocksUnknownReadProjection(t *testing.T) {
|
||||
router := chi.NewRouter()
|
||||
module := &Module{}
|
||||
router.Post("/clusters/{clusterID}/nodes/{nodeID}/admin-runtime/projection", module.projectAdminRuntime)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/clusters/cluster-1/nodes/node-1/admin-runtime/projection", bytes.NewReader([]byte(`{
|
||||
"schema_version":"rap.web_ingress.control_api_projection_request.v1",
|
||||
"method":"GET",
|
||||
"path":"/platform-admin/nodes",
|
||||
"scope":"platform",
|
||||
"service_class":"platform_admin"
|
||||
}`)))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
StatusCode int `json:"status_code"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if response.Status != "blocked" ||
|
||||
response.Reason != "control_api_projection_not_implemented" ||
|
||||
response.StatusCode != http.StatusNotImplemented {
|
||||
t.Fatalf("response = %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectAdminRuntimeRejectsScopeClassMismatch(t *testing.T) {
|
||||
router := chi.NewRouter()
|
||||
module := &Module{}
|
||||
router.Post("/clusters/{clusterID}/nodes/{nodeID}/admin-runtime/projection", module.projectAdminRuntime)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/clusters/cluster-1/nodes/node-1/admin-runtime/projection", bytes.NewReader([]byte(`{
|
||||
"schema_version":"rap.web_ingress.control_api_projection_request.v1",
|
||||
"method":"GET",
|
||||
"path":"/platform-admin/ui-manifest",
|
||||
"scope":"organization",
|
||||
"service_class":"platform_admin"
|
||||
}`)))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
StatusCode int `json:"status_code"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if response.Status != "blocked" ||
|
||||
response.Reason != "control_api_projection_scope_rejected" ||
|
||||
response.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("response = %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectAdminRuntimeRejectsInvalidSchema(t *testing.T) {
|
||||
router := chi.NewRouter()
|
||||
module := &Module{}
|
||||
router.Post("/clusters/{clusterID}/nodes/{nodeID}/admin-runtime/projection", module.projectAdminRuntime)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/clusters/cluster-1/nodes/node-1/admin-runtime/projection", bytes.NewReader([]byte(`{
|
||||
"schema_version":"wrong.schema",
|
||||
"method":"GET",
|
||||
"path":"/readyz",
|
||||
"scope":"platform",
|
||||
"service_class":"platform_admin"
|
||||
}`)))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -190,7 +192,7 @@ func (s *PostgresStore) UpdateCluster(ctx context.Context, input UpdateClusterIn
|
||||
func (s *PostgresStore) GetClusterAuthority(ctx context.Context, clusterID string) (ClusterAuthorityKey, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
SELECT cluster_id::text, authority_state, key_algorithm, public_key,
|
||||
public_key_fingerprint, private_key, created_at, updated_at
|
||||
public_key_fingerprint, private_key, created_at, updated_at, metadata
|
||||
FROM cluster_authorities
|
||||
WHERE cluster_id = $1::uuid
|
||||
`, clusterID)
|
||||
@@ -3497,7 +3499,7 @@ func (s *PostgresStore) CheckVPNLeaseOwnerEligibility(ctx context.Context, clust
|
||||
WHERE nra.cluster_id = vc.cluster_id
|
||||
AND nra.node_id = $3::uuid
|
||||
AND nra.status = 'active'
|
||||
AND nra.role IN ('vpn-exit', 'vpn-connector')
|
||||
AND nra.role IN ('vpn-exit', 'vpn-connector', 'ipv4-egress')
|
||||
AND (nra.organization_id IS NULL OR nra.organization_id = vc.organization_id)
|
||||
) AS has_authorized_role
|
||||
FROM vpn_connections vc
|
||||
@@ -3582,7 +3584,7 @@ func (s *PostgresStore) ListNodeVPNAssignments(ctx context.Context, clusterID, n
|
||||
WHERE nra.cluster_id = vc.cluster_id
|
||||
AND nra.node_id = $2::uuid
|
||||
AND nra.status = 'active'
|
||||
AND nra.role IN ('vpn-exit', 'vpn-connector')
|
||||
AND nra.role IN ('vpn-exit', 'vpn-connector', 'ipv4-egress')
|
||||
AND (nra.organization_id IS NULL OR nra.organization_id = vc.organization_id)
|
||||
) AS has_authorized_role,
|
||||
EXISTS (
|
||||
@@ -3769,13 +3771,33 @@ func scanClusterAuthority(row scanner) (ClusterAuthorityKey, error) {
|
||||
&item.PrivateKey,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
&item.Metadata,
|
||||
); err != nil {
|
||||
return ClusterAuthorityKey{}, err
|
||||
}
|
||||
item.SchemaVersion = clusterauth.AuthoritySchemaVersion
|
||||
ensureRaw(&item.Metadata, `{}`)
|
||||
item.QuorumDescriptor = clusterAuthorityQuorumDescriptorFromMetadata(item.Metadata)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func clusterAuthorityQuorumDescriptorFromMetadata(metadata json.RawMessage) *QuorumDescriptor {
|
||||
if len(metadata) == 0 || !json.Valid(metadata) {
|
||||
return nil
|
||||
}
|
||||
var envelope struct {
|
||||
QuorumDescriptor *QuorumDescriptor `json:"quorum_descriptor"`
|
||||
Quorum *QuorumDescriptor `json:"quorum"`
|
||||
}
|
||||
if err := json.Unmarshal(metadata, &envelope); err != nil {
|
||||
return nil
|
||||
}
|
||||
if envelope.QuorumDescriptor != nil {
|
||||
return envelope.QuorumDescriptor
|
||||
}
|
||||
return envelope.Quorum
|
||||
}
|
||||
|
||||
func scanNodeGroup(row scanner) (ClusterNodeGroup, error) {
|
||||
var item ClusterNodeGroup
|
||||
if err := row.Scan(
|
||||
@@ -4517,6 +4539,8 @@ func (s *PostgresStore) GetVPNClientProfile(
|
||||
), '[]'::jsonb) AS allowed_node_ids,
|
||||
COALESCE(vc.placement_policy->'entry_node_ids', '[]'::jsonb) AS entry_node_ids,
|
||||
COALESCE(vc.placement_policy->>'exit_node_id', '') AS exit_node_id,
|
||||
COALESCE(pool.id::text, '') AS exit_pool_id,
|
||||
COALESCE(pool.name, vc.name) AS exit_pool_name,
|
||||
CASE WHEN l.id IS NULL THEN NULL ELSE jsonb_build_object(
|
||||
'lease_id', l.id::text,
|
||||
'owner_node_id', l.owner_node_id::text,
|
||||
@@ -4576,6 +4600,34 @@ func (s *PostgresStore) GetVPNClientProfile(
|
||||
'runtime_observed_at', gateway_status.observed_at
|
||||
)) END AS client_config
|
||||
FROM vpn_connections vc
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT ep.id, ep.name
|
||||
FROM fabric_egress_pools ep
|
||||
WHERE ep.cluster_id = vc.cluster_id
|
||||
AND ep.status = 'active'
|
||||
AND (
|
||||
ep.id::text = COALESCE(vc.placement_policy->>'exit_pool_id', '')
|
||||
OR ep.name = COALESCE(vc.placement_policy->>'exit_pool_name', '')
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM fabric_egress_pool_nodes epn
|
||||
WHERE epn.egress_pool_id = ep.id
|
||||
AND epn.cluster_id = vc.cluster_id
|
||||
AND epn.status = 'active'
|
||||
AND epn.node_id::text = ANY (
|
||||
SELECT jsonb_array_elements_text(COALESCE(vc.placement_policy->'exit_node_ids', '[]'::jsonb))
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN ep.id::text = COALESCE(vc.placement_policy->>'exit_pool_id', '') THEN 0
|
||||
WHEN ep.name = COALESCE(vc.placement_policy->>'exit_pool_name', '') THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
ep.name
|
||||
LIMIT 1
|
||||
) pool ON TRUE
|
||||
LEFT JOIN vpn_connection_leases l
|
||||
ON l.cluster_id = vc.cluster_id
|
||||
AND l.vpn_connection_id = vc.id
|
||||
@@ -4620,6 +4672,8 @@ func (s *PostgresStore) GetVPNClientProfile(
|
||||
&allowedRaw,
|
||||
&entryRaw,
|
||||
&item.ExitNodeID,
|
||||
&item.ExitPoolID,
|
||||
&item.ExitPoolName,
|
||||
&activeLeaseRaw,
|
||||
&item.RoutePolicies,
|
||||
&item.ClientConfig,
|
||||
@@ -4641,6 +4695,15 @@ func (s *PostgresStore) GetVPNClientProfile(
|
||||
ensureRaw(&item.PlacementPolicy, `{}`)
|
||||
ensureRaw(&item.RoutePolicies, `[]`)
|
||||
ensureRaw(&item.ClientConfig, `{}`)
|
||||
if item.ExitPoolName != "" || item.ExitPoolID != "" {
|
||||
item.ClientConfig = mergeJSONObjects(item.ClientConfig, map[string]any{
|
||||
"exit_pool": map[string]any{
|
||||
"id": item.ExitPoolID,
|
||||
"name": firstNonEmptyMetadataString(item.ExitPoolName, item.Name),
|
||||
"kind": "virtual_pool",
|
||||
},
|
||||
})
|
||||
}
|
||||
item.ClientConfig = enrichVPNClientFabricRoute(item, preferredEntryNodeID, preferredExitNodeID)
|
||||
profile.Connections = append(profile.Connections, item)
|
||||
}
|
||||
@@ -4651,8 +4714,13 @@ func (s *PostgresStore) GetVPNClientProfile(
|
||||
if err != nil {
|
||||
return VPNClientProfile{}, err
|
||||
}
|
||||
exitEndpoints, err := s.vpnEntryEndpointCandidates(ctx, clusterID, vpnProfileExitNodeIDs(profile))
|
||||
if err != nil {
|
||||
return VPNClientProfile{}, err
|
||||
}
|
||||
for i := range profile.Connections {
|
||||
profile.Connections[i].ClientConfig = enrichVPNClientEntryEndpointCandidates(profile.Connections[i], entryEndpoints)
|
||||
profile.Connections[i].ClientConfig = enrichVPNClientExitEndpointCandidates(profile.Connections[i], exitEndpoints)
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
@@ -4733,6 +4801,18 @@ func vpnProfileEntryNodeIDs(profile VPNClientProfile) []string {
|
||||
return dedupeStrings(out)
|
||||
}
|
||||
|
||||
func vpnProfileExitNodeIDs(profile VPNClientProfile) []string {
|
||||
var out []string
|
||||
for _, connection := range profile.Connections {
|
||||
route := vpnFabricRouteFromClientConfig(connection.ClientConfig)
|
||||
out = append(out, route.SelectedExitNodeID)
|
||||
out = append(out, route.ExitPoolNodeIDs...)
|
||||
out = append(out, connection.ExitNodeID)
|
||||
out = append(out, connection.AllowedNodeIDs...)
|
||||
}
|
||||
return dedupeStrings(out)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) vpnEntryEndpointCandidates(ctx context.Context, clusterID string, entryNodeIDs []string) (map[string][]map[string]any, error) {
|
||||
entryNodeIDs = dedupeStrings(entryNodeIDs)
|
||||
out := make(map[string][]map[string]any, len(entryNodeIDs))
|
||||
@@ -4778,13 +4858,12 @@ func vpnEntryEndpointCandidatesFromHeartbeat(nodeID string, capabilities json.Ra
|
||||
if len(metadata) == 0 || json.Unmarshal(metadata, &payload) != nil {
|
||||
return nil
|
||||
}
|
||||
certByCandidate := endpointCandidateCertsFromHeartbeatMetadata(metadata)
|
||||
report := payload.MeshEndpointReport
|
||||
var out []map[string]any
|
||||
seen := map[string]struct{}{}
|
||||
for _, candidate := range report.EndpointCandidates {
|
||||
address := strings.TrimSpace(candidate.Address)
|
||||
if address == "" {
|
||||
continue
|
||||
}
|
||||
candidateNodeID := strings.TrimSpace(candidate.NodeID)
|
||||
if candidateNodeID == "" {
|
||||
candidateNodeID = nodeID
|
||||
@@ -4793,6 +4872,9 @@ func vpnEntryEndpointCandidatesFromHeartbeat(nodeID string, capabilities json.Ra
|
||||
if transport == "" {
|
||||
transport = strings.TrimSpace(report.Transport)
|
||||
}
|
||||
if !usableVPNFabricPeerEndpoint(address, transport) {
|
||||
continue
|
||||
}
|
||||
connectivityMode := strings.TrimSpace(candidate.ConnectivityMode)
|
||||
if connectivityMode == "" {
|
||||
connectivityMode = strings.TrimSpace(report.ConnectivityMode)
|
||||
@@ -4813,6 +4895,11 @@ func vpnEntryEndpointCandidatesFromHeartbeat(nodeID string, capabilities json.Ra
|
||||
if endpointID == "" {
|
||||
endpointID = "mesh-" + candidateNodeID
|
||||
}
|
||||
key := candidateNodeID + "\x00" + strings.ToLower(transport) + "\x00" + strings.ToLower(address)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
item := map[string]any{
|
||||
"node_id": candidateNodeID,
|
||||
"endpoint_id": endpointID,
|
||||
@@ -4826,6 +4913,15 @@ func vpnEntryEndpointCandidatesFromHeartbeat(nodeID string, capabilities json.Ra
|
||||
"status": "reported",
|
||||
"source": "node_latest_heartbeat.mesh_endpoint_report.endpoint_candidates",
|
||||
}
|
||||
if certSHA256 := firstNonEmptyMetadataString(
|
||||
endpointCandidateMetadataString(candidate.Metadata, "tls_cert_sha256", "peer_cert_sha256"),
|
||||
certByCandidate[endpointID],
|
||||
certByCandidate[address],
|
||||
certByCandidate[candidateNodeID+"\x00"+address],
|
||||
); certSHA256 != "" {
|
||||
item["tls_cert_sha256"] = certSHA256
|
||||
item["peer_cert_sha256"] = certSHA256
|
||||
}
|
||||
if apiBaseURL := vpnEntryAPIBaseURL(address); apiBaseURL != "" {
|
||||
item["api_base_url"] = apiBaseURL
|
||||
}
|
||||
@@ -4833,7 +4929,7 @@ func vpnEntryEndpointCandidatesFromHeartbeat(nodeID string, capabilities json.Ra
|
||||
}
|
||||
if len(out) == 0 {
|
||||
address := strings.TrimSpace(report.PeerEndpoint)
|
||||
if address != "" {
|
||||
if usableVPNFabricPeerEndpoint(address, strings.TrimSpace(report.Transport)) {
|
||||
item := map[string]any{
|
||||
"node_id": nodeID,
|
||||
"endpoint_id": "mesh-peer-endpoint-" + nodeID,
|
||||
@@ -4856,6 +4952,107 @@ func vpnEntryEndpointCandidatesFromHeartbeat(nodeID string, capabilities json.Ra
|
||||
return out
|
||||
}
|
||||
|
||||
func endpointCandidateCertsFromHeartbeatMetadata(metadata json.RawMessage) map[string]string {
|
||||
out := map[string]string{}
|
||||
var payload map[string]any
|
||||
if len(metadata) == 0 || json.Unmarshal(metadata, &payload) != nil {
|
||||
return out
|
||||
}
|
||||
report, _ := payload["mesh_endpoint_report"].(map[string]any)
|
||||
candidates, _ := report["endpoint_candidates"].([]any)
|
||||
for _, raw := range candidates {
|
||||
candidate, _ := raw.(map[string]any)
|
||||
if candidate == nil {
|
||||
continue
|
||||
}
|
||||
meta, _ := candidate["metadata"].(map[string]any)
|
||||
cert := strings.TrimSpace(metadataAnyString(meta["tls_cert_sha256"]))
|
||||
if cert == "" {
|
||||
cert = strings.TrimSpace(metadataAnyString(meta["peer_cert_sha256"]))
|
||||
}
|
||||
if cert == "" {
|
||||
continue
|
||||
}
|
||||
endpointID := strings.TrimSpace(metadataAnyString(candidate["endpoint_id"]))
|
||||
address := strings.TrimSpace(metadataAnyString(candidate["address"]))
|
||||
nodeID := strings.TrimSpace(metadataAnyString(candidate["node_id"]))
|
||||
if endpointID != "" {
|
||||
out[endpointID] = cert
|
||||
}
|
||||
if address != "" {
|
||||
out[address] = cert
|
||||
}
|
||||
if nodeID != "" && address != "" {
|
||||
out[nodeID+"\x00"+address] = cert
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func metadataAnyString(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmptyMetadataString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func usableVPNFabricPeerEndpoint(address string, transport string) bool {
|
||||
address = strings.TrimSpace(address)
|
||||
if address == "" {
|
||||
return false
|
||||
}
|
||||
transport = strings.ToLower(strings.TrimSpace(transport))
|
||||
if !strings.Contains(transport, "quic") {
|
||||
return false
|
||||
}
|
||||
parsed, err := url.Parse(address)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if strings.ToLower(parsed.Scheme) != "quic" {
|
||||
return false
|
||||
}
|
||||
host := parsed.Hostname()
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return true
|
||||
}
|
||||
if ip.IsUnspecified() || ip.IsLoopback() {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func endpointCandidateMetadataString(metadata json.RawMessage, keys ...string) string {
|
||||
if len(metadata) == 0 {
|
||||
return ""
|
||||
}
|
||||
var values map[string]any
|
||||
if json.Unmarshal(metadata, &values) != nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range keys {
|
||||
if value, ok := values[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func heartbeatCapabilityEnabled(capabilities json.RawMessage, name string) bool {
|
||||
var cfg map[string]any
|
||||
if len(capabilities) == 0 || json.Unmarshal(capabilities, &cfg) != nil {
|
||||
@@ -4921,6 +5118,44 @@ func enrichVPNClientEntryEndpointCandidates(connection VPNClientConnection, endp
|
||||
return out
|
||||
}
|
||||
|
||||
func enrichVPNClientExitEndpointCandidates(connection VPNClientConnection, endpoints map[string][]map[string]any) json.RawMessage {
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal(connection.ClientConfig, &cfg); err != nil || cfg == nil {
|
||||
cfg = map[string]any{}
|
||||
}
|
||||
route := vpnFabricRouteFromClientConfig(connection.ClientConfig)
|
||||
exitIDs := dedupeStrings(append([]string{route.SelectedExitNodeID}, route.ExitPoolNodeIDs...))
|
||||
exitIDs = dedupeStrings(append(exitIDs, connection.ExitNodeID))
|
||||
exitIDs = dedupeStrings(append(exitIDs, connection.AllowedNodeIDs...))
|
||||
var candidates []map[string]any
|
||||
seen := map[string]struct{}{}
|
||||
for _, nodeID := range exitIDs {
|
||||
for _, candidate := range endpoints[nodeID] {
|
||||
address, _ := candidate["address"].(string)
|
||||
endpointID, _ := candidate["endpoint_id"].(string)
|
||||
key := nodeID + "\x00" + endpointID + "\x00" + address
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
enriched := make(map[string]any, len(candidate)+2)
|
||||
for k, v := range candidate {
|
||||
enriched[k] = v
|
||||
}
|
||||
enriched["selected_exit"] = nodeID != "" && nodeID == route.SelectedExitNodeID
|
||||
enriched["exit_pool_member"] = true
|
||||
candidates = append(candidates, enriched)
|
||||
}
|
||||
}
|
||||
cfg["vpn_exit_endpoint_candidates"] = candidates
|
||||
cfg["vpn_exit_endpoint_candidate_count"] = len(candidates)
|
||||
out, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return connection.ClientConfig
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func listVPNConnectionAllowedNodes(ctx context.Context, q rowQuerier, clusterID, vpnConnectionID string) ([]VPNConnectionAllowedNode, error) {
|
||||
rows, err := q.Query(ctx, `
|
||||
SELECT vpn_connection_id::text, cluster_id::text, node_id::text, role_preference,
|
||||
@@ -5087,13 +5322,32 @@ func ensureRaw(raw *json.RawMessage, fallback string) {
|
||||
}
|
||||
}
|
||||
|
||||
func mergeJSONObjects(raw json.RawMessage, values map[string]any) json.RawMessage {
|
||||
out := map[string]any{}
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
if out == nil {
|
||||
out = map[string]any{}
|
||||
}
|
||||
for key, value := range values {
|
||||
out[key] = value
|
||||
}
|
||||
payload, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func enrichVPNClientFabricRoute(item VPNClientConnection, preferredEntryNodeID, preferredExitNodeID string) json.RawMessage {
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal(item.ClientConfig, &cfg); err != nil || cfg == nil {
|
||||
cfg = map[string]any{}
|
||||
}
|
||||
entryPool := dedupeStrings(append([]string{}, item.EntryNodeIDs...))
|
||||
if len(entryPool) == 0 {
|
||||
placementPolicy := jsonObjectFromRaw(item.PlacementPolicy)
|
||||
entrySelector, _ := placementPolicy["entry_selector"].(string)
|
||||
clientNodeEntry := strings.EqualFold(strings.TrimSpace(entrySelector), "client_node") || placementPolicy["android_node_agent_target"] == true
|
||||
if len(entryPool) == 0 && !clientNodeEntry {
|
||||
entryPool = dedupeStrings(append([]string{}, item.AllowedNodeIDs...))
|
||||
}
|
||||
exitPool := []string{}
|
||||
@@ -5107,7 +5361,10 @@ func enrichVPNClientFabricRoute(item VPNClientConnection, preferredEntryNodeID,
|
||||
exitPool = dedupeStrings(exitPool)
|
||||
|
||||
preferredEntryNodeID = strings.TrimSpace(preferredEntryNodeID)
|
||||
selectedEntry := selectPreferredNode(entryPool, preferredEntryNodeID)
|
||||
selectedEntry := ""
|
||||
if !clientNodeEntry {
|
||||
selectedEntry = selectPreferredNode(entryPool, preferredEntryNodeID)
|
||||
}
|
||||
selectedExit := selectPreferredNode(exitPool, preferredExitNodeID)
|
||||
if selectedExit == "" && item.ActiveLease != nil && item.ActiveLease.OwnerNodeID != "" {
|
||||
selectedExit = item.ActiveLease.OwnerNodeID
|
||||
@@ -5116,6 +5373,8 @@ func enrichVPNClientFabricRoute(item VPNClientConnection, preferredEntryNodeID,
|
||||
switch {
|
||||
case selectedEntry != "" && selectedExit != "":
|
||||
status = "planned"
|
||||
case clientNodeEntry && selectedExit != "":
|
||||
status = "planned"
|
||||
case selectedEntry == "":
|
||||
status = "waiting_for_entry"
|
||||
case selectedExit == "":
|
||||
@@ -5129,8 +5388,10 @@ func enrichVPNClientFabricRoute(item VPNClientConnection, preferredEntryNodeID,
|
||||
"preferred_data_plane": "fabric_service_channel",
|
||||
"fallback_data_plane": "none",
|
||||
"backend_relay_fallback": false,
|
||||
"selection_mode": "farm_authoritative_entry_to_exit",
|
||||
"selection_mode": "farm_authoritative_client_node_to_exit_pool",
|
||||
"route_authority": "fabric_farm",
|
||||
"entry_selector": firstNonEmptyString(entrySelector, "entry-node"),
|
||||
"client_node_entry": clientNodeEntry,
|
||||
"vpn_builds_routes": false,
|
||||
"vpn_builds_tunnels": false,
|
||||
"farm_builds_routes": true,
|
||||
@@ -5163,7 +5424,9 @@ func enrichVPNClientFabricRoute(item VPNClientConnection, preferredEntryNodeID,
|
||||
"diagnostics_only_protocol_summaries": true,
|
||||
},
|
||||
"route_selection": map[string]any{
|
||||
"mode": "farm_authoritative_lowest_latency_healthy_route",
|
||||
"mode": "farm_authoritative_lowest_latency_healthy_route_to_exit_pool",
|
||||
"entry_selector": firstNonEmptyString(entrySelector, "entry-node"),
|
||||
"client_node_entry": clientNodeEntry,
|
||||
"selected_entry_node_id": selectedEntry,
|
||||
"selected_exit_node_id": selectedExit,
|
||||
"route_candidates": routeCandidates,
|
||||
@@ -5175,7 +5438,7 @@ func enrichVPNClientFabricRoute(item VPNClientConnection, preferredEntryNodeID,
|
||||
"preserve_vpn_connection_id": true,
|
||||
"alternate_route_count": alternateVPNRouteCount(routeCandidates, selectedEntry, selectedExit),
|
||||
"reroute_triggers": []string{
|
||||
"entry_unhealthy",
|
||||
"client_node_mesh_path_unhealthy",
|
||||
"exit_unhealthy",
|
||||
"mesh_route_latency_regression",
|
||||
"mesh_route_loss_regression",
|
||||
@@ -5199,12 +5462,30 @@ func enrichVPNClientFabricRoute(item VPNClientConnection, preferredEntryNodeID,
|
||||
return out
|
||||
}
|
||||
|
||||
func jsonObjectFromRaw(raw json.RawMessage) map[string]any {
|
||||
var out map[string]any
|
||||
if len(raw) == 0 || json.Unmarshal(raw, &out) != nil || out == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func vpnFabricRouteCandidates(entryPool, exitPool []string, selectedEntry, selectedExit string) []map[string]any {
|
||||
type pair struct {
|
||||
entry string
|
||||
exit string
|
||||
}
|
||||
pairs := make([]pair, 0, len(entryPool)*len(exitPool)+1)
|
||||
if len(entryPool) == 0 && selectedExit != "" {
|
||||
pairs = append(pairs, pair{exit: selectedExit})
|
||||
}
|
||||
if len(entryPool) == 0 {
|
||||
for _, exit := range exitPool {
|
||||
if exit != "" {
|
||||
pairs = append(pairs, pair{exit: exit})
|
||||
}
|
||||
}
|
||||
}
|
||||
if selectedEntry != "" && selectedExit != "" {
|
||||
pairs = append(pairs, pair{entry: selectedEntry, exit: selectedExit})
|
||||
}
|
||||
@@ -5219,6 +5500,9 @@ func vpnFabricRouteCandidates(entryPool, exitPool []string, selectedEntry, selec
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]map[string]any, 0, len(pairs))
|
||||
for _, pair := range pairs {
|
||||
if pair.exit == "" {
|
||||
continue
|
||||
}
|
||||
key := pair.entry + "\x00" + pair.exit
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
@@ -5226,17 +5510,22 @@ func vpnFabricRouteCandidates(entryPool, exitPool []string, selectedEntry, selec
|
||||
seen[key] = struct{}{}
|
||||
priority := len(out) + 1
|
||||
role := "alternate"
|
||||
if pair.entry == selectedEntry && pair.exit == selectedExit {
|
||||
if pair.exit == selectedExit && (pair.entry == selectedEntry || selectedEntry == "") {
|
||||
role = "preferred"
|
||||
priority = 0
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"entry_node_id": pair.entry,
|
||||
"exit_node_id": pair.exit,
|
||||
"role": role,
|
||||
"priority": priority,
|
||||
"status": "candidate",
|
||||
})
|
||||
candidate := map[string]any{
|
||||
"exit_node_id": pair.exit,
|
||||
"role": role,
|
||||
"priority": priority,
|
||||
"status": "candidate",
|
||||
"source_role": "vpn-client",
|
||||
"route_scope": "client_node_to_exit_pool",
|
||||
}
|
||||
if pair.entry != "" {
|
||||
candidate["entry_node_id"] = pair.entry
|
||||
}
|
||||
out = append(out, candidate)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ func TestEnrichVPNClientFabricRouteUsesActiveLeaseWhenNoPolicyExit(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichVPNClientEntryEndpointCandidatesAddsReportedEntryAPI(t *testing.T) {
|
||||
func TestEnrichVPNClientEntryEndpointCandidatesAddsReportedQUICEndpoint(t *testing.T) {
|
||||
item := VPNClientConnection{
|
||||
EntryNodeIDs: []string{"entry-1"},
|
||||
ClientConfig: json.RawMessage(`{
|
||||
@@ -150,16 +150,16 @@ func TestEnrichVPNClientEntryEndpointCandidatesAddsReportedEntryAPI(t *testing.T
|
||||
}
|
||||
heartbeatMetadata := json.RawMessage(`{
|
||||
"mesh_endpoint_report": {
|
||||
"transport": "direct_http",
|
||||
"transport": "direct_quic",
|
||||
"connectivity_mode": "direct",
|
||||
"nat_type": "none",
|
||||
"region": "test",
|
||||
"peer_endpoint": "http://entry.example.test:19131",
|
||||
"peer_endpoint": "quic://entry.example.test:19131",
|
||||
"endpoint_candidates": [{
|
||||
"endpoint_id": "public-http",
|
||||
"endpoint_id": "public-quic",
|
||||
"node_id": "entry-1",
|
||||
"transport": "direct_http",
|
||||
"address": "http://entry.example.test:19131",
|
||||
"transport": "direct_quic",
|
||||
"address": "quic://entry.example.test:19131",
|
||||
"reachability": "public",
|
||||
"priority": 0
|
||||
}]
|
||||
@@ -178,9 +178,12 @@ func TestEnrichVPNClientEntryEndpointCandidatesAddsReportedEntryAPI(t *testing.T
|
||||
}
|
||||
candidates := cfg["vpn_entry_endpoint_candidates"].([]any)
|
||||
candidate := candidates[0].(map[string]any)
|
||||
if candidate["node_id"] != "entry-1" || candidate["api_base_url"] != "http://entry.example.test:19131/api/v1" {
|
||||
if candidate["node_id"] != "entry-1" || candidate["address"] != "quic://entry.example.test:19131" {
|
||||
t.Fatalf("unexpected endpoint candidate: %#v", candidate)
|
||||
}
|
||||
if _, ok := candidate["api_base_url"]; ok {
|
||||
t.Fatalf("QUIC dataplane candidate must not expose an API base URL: %#v", candidate)
|
||||
}
|
||||
if _, ok := candidate["local_gateway_shortcut"]; ok {
|
||||
t.Fatalf("local gateway shortcut must not be advertised in farm-owned VPN mode: %#v", candidate)
|
||||
}
|
||||
@@ -188,3 +191,29 @@ func TestEnrichVPNClientEntryEndpointCandidatesAddsReportedEntryAPI(t *testing.T
|
||||
t.Fatalf("unexpected endpoint metadata: %#v", candidate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVPNEntryEndpointCandidatesKeepsQUICEndpointsAndRejectsLegacyHTTP(t *testing.T) {
|
||||
heartbeatMetadata := json.RawMessage(`{
|
||||
"mesh_endpoint_report": {
|
||||
"transport": "direct_quic",
|
||||
"connectivity_mode": "direct",
|
||||
"peer_endpoint": "quic://192.168.200.85:18080",
|
||||
"endpoint_candidates": [
|
||||
{"endpoint_id":"admin-web","node_id":"entry-1","transport":"direct_quic","address":"quic://192.168.200.85:18080","reachability":"private","priority":0},
|
||||
{"endpoint_id":"http-old","node_id":"entry-1","transport":"direct_http","address":"http://192.168.200.85:19131","reachability":"private","priority":1},
|
||||
{"endpoint_id":"mesh-quic","node_id":"entry-1","transport":"direct_quic","address":"quic://192.168.200.85:19131","reachability":"private","priority":2}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
candidates := vpnEntryEndpointCandidatesFromHeartbeat("entry-1", nil, heartbeatMetadata)
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("candidate count = %d, want two QUIC dataplane endpoints: %#v", len(candidates), candidates)
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, candidate := range candidates {
|
||||
got[candidate["endpoint_id"].(string)] = candidate["address"].(string)
|
||||
}
|
||||
if got["admin-web"] != "quic://192.168.200.85:18080" || got["mesh-quic"] != "quic://192.168.200.85:19131" {
|
||||
t.Fatalf("unexpected candidates: %#v", candidates)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -347,6 +347,71 @@ func TestAssignNodeRoleRejectsUnknownRole(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignNodeRoleAllowsWebAdminPlacementRoles(t *testing.T) {
|
||||
roles := []string{
|
||||
"public-ingress",
|
||||
"admin-ingress",
|
||||
"global-admin-runtime",
|
||||
"cluster-admin-runtime",
|
||||
"organization-portal-runtime",
|
||||
"user-portal-runtime",
|
||||
"identity-runtime",
|
||||
"policy-authority",
|
||||
"audit-sink",
|
||||
}
|
||||
for _, role := range roles {
|
||||
t.Run(role, func(t *testing.T) {
|
||||
store := &fakeRepository{platformRole: PlatformRoleAdmin}
|
||||
service := NewService(store)
|
||||
|
||||
item, err := service.AssignNodeRole(context.Background(), AssignNodeRoleInput{
|
||||
ActorUserID: "admin-1",
|
||||
ClusterID: "cluster-1",
|
||||
NodeID: "node-1",
|
||||
Role: role,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("assign role: %v", err)
|
||||
}
|
||||
if item.Role != role {
|
||||
t.Fatalf("role = %q, want %q", item.Role, role)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFabricAdminServiceClassesAreScopedToAdminRoles(t *testing.T) {
|
||||
cases := []struct {
|
||||
serviceClass string
|
||||
requiredRole string
|
||||
pathNeedle string
|
||||
}{
|
||||
{FabricServiceClassPlatformAdmin, "global-admin-runtime", "platform-admin"},
|
||||
{FabricServiceClassClusterAdmin, "cluster-admin-runtime", "cluster-admin"},
|
||||
{FabricServiceClassOrganization, "organization-portal-runtime", "organizations"},
|
||||
{FabricServiceClassUserPortal, "user-portal-runtime", "users"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.serviceClass, func(t *testing.T) {
|
||||
if !isAllowedFabricServiceClass(tc.serviceClass) {
|
||||
t.Fatalf("service class %q is not allowed", tc.serviceClass)
|
||||
}
|
||||
roles := normalizeFabricRequiredRoles(nil, tc.serviceClass)
|
||||
if !containsString(roles, tc.requiredRole) || !containsString(roles, "identity-runtime") || !containsString(roles, "policy-authority") {
|
||||
t.Fatalf("required roles = %+v", roles)
|
||||
}
|
||||
channels := normalizeFabricServiceChannels(nil, tc.serviceClass)
|
||||
if !containsString(channels, FabricChannelControl) || !containsString(channels, FabricChannelInteractive) || !containsString(channels, FabricChannelReliable) {
|
||||
t.Fatalf("channels = %+v", channels)
|
||||
}
|
||||
ingress := fabricServiceChannelHTTPIngress(tc.serviceClass)
|
||||
if !strings.Contains(ingress.PathTemplate, tc.pathNeedle) {
|
||||
t.Fatalf("path = %q, want %q", ingress.PathTemplate, tc.pathNeedle)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachExistingNodeRequiresPlatformAdmin(t *testing.T) {
|
||||
store := &fakeRepository{platformRole: "user"}
|
||||
service := NewService(store)
|
||||
@@ -567,6 +632,70 @@ func TestApproveJoinRequestReturnsBootstrapContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveJoinRequestReturnsSignedQuorumDescriptor(t *testing.T) {
|
||||
keys, err := clusterauth.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
quorum := &QuorumDescriptor{
|
||||
SchemaVersion: clusterauth.QuorumSchemaVersion,
|
||||
ClusterID: "cluster-1",
|
||||
Epoch: "epoch-1",
|
||||
Threshold: 1,
|
||||
Members: []clusterauth.QuorumMember{
|
||||
{
|
||||
NodeID: "authority-1",
|
||||
Role: "update-authority",
|
||||
PublicKey: keys.PublicKeyB64,
|
||||
PublicKeyFingerprint: keys.Fingerprint,
|
||||
Scopes: []string{"update-authority"},
|
||||
},
|
||||
},
|
||||
}
|
||||
store := &fakeRepository{
|
||||
platformRole: PlatformRoleAdmin,
|
||||
clusterAuthority: ClusterAuthorityKey{
|
||||
ClusterAuthorityDescriptor: ClusterAuthorityDescriptor{
|
||||
SchemaVersion: clusterauth.AuthoritySchemaVersion,
|
||||
ClusterID: "cluster-1",
|
||||
AuthorityState: "active",
|
||||
KeyAlgorithm: clusterauth.AlgorithmEd25519,
|
||||
PublicKey: keys.PublicKeyB64,
|
||||
PublicKeyFingerprint: keys.Fingerprint,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
},
|
||||
PrivateKey: keys.PrivateKeyB64,
|
||||
QuorumDescriptor: quorum,
|
||||
},
|
||||
}
|
||||
service := NewService(store)
|
||||
|
||||
approved, err := service.ApproveJoinRequest(context.Background(), ApproveJoinRequestInput{
|
||||
ActorUserID: "admin-1",
|
||||
ClusterID: "cluster-1",
|
||||
JoinRequestID: "join-request-1",
|
||||
NodeKey: "node-key-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("approve join request: %v", err)
|
||||
}
|
||||
if approved.Bootstrap.ClusterAuthorityQuorum == nil {
|
||||
t.Fatalf("bootstrap missing quorum descriptor: %+v", approved.Bootstrap)
|
||||
}
|
||||
var payload clusterNodeApprovalAuthorityPayload
|
||||
if err := json.Unmarshal(approved.Bootstrap.AuthorityPayload, &payload); err != nil {
|
||||
t.Fatalf("decode authority payload: %v", err)
|
||||
}
|
||||
quorumHash, err := clusterauth.QuorumDescriptorHash(*quorum)
|
||||
if err != nil {
|
||||
t.Fatalf("hash quorum: %v", err)
|
||||
}
|
||||
if payload.ClusterAuthorityQuorumSHA256 != quorumHash {
|
||||
t.Fatalf("quorum hash = %q, want %q", payload.ClusterAuthorityQuorumSHA256, quorumHash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetJoinRequestBootstrapReturnsSignedApproval(t *testing.T) {
|
||||
nodeID := "node-1"
|
||||
store := &fakeRepository{
|
||||
@@ -694,7 +823,8 @@ func TestGetVPNClientProfileEnsuresFabricVPNPacketRouteIntents(t *testing.T) {
|
||||
vpnClientProfile: VPNClientProfile{
|
||||
SchemaVersion: "rap.vpn_client_profile.v1",
|
||||
Connections: []VPNClientConnection{{
|
||||
ID: "vpn-1",
|
||||
ID: "vpn-1",
|
||||
TargetEndpoint: json.RawMessage(`{"type":"fabric_ipv4_exit_pool","exit_pool_ids":["home-ipv4"]}`),
|
||||
ClientConfig: json.RawMessage(`{
|
||||
"vpn_fabric_route": {
|
||||
"status": "planned",
|
||||
@@ -735,6 +865,34 @@ func TestGetVPNClientProfileEnsuresFabricVPNPacketRouteIntents(t *testing.T) {
|
||||
if session["preferred_transport"] != "fabric_service_channel_v1" || session["fallback_transport"] != "none" || session["backend_relay_allowed"] != false {
|
||||
t.Fatalf("unexpected dataplane session transports: %#v", session)
|
||||
}
|
||||
request, ok := session["fabric_service_channel_request"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("missing fabric service channel request in %#v", session)
|
||||
}
|
||||
if request["service_class"] != "vpn_packets" || request["source_role"] != "vpn-client" {
|
||||
t.Fatalf("unexpected fabric service channel request: %#v", request)
|
||||
}
|
||||
target := request["target"].(map[string]any)
|
||||
poolIDs := target["pool_ids"].([]any)
|
||||
if target["kind"] != "pool" || target["service_role"] != "ipv4-egress" || len(poolIDs) != 1 || poolIDs[0] != "home-ipv4" {
|
||||
t.Fatalf("unexpected fabric service channel target: %#v", target)
|
||||
}
|
||||
adapter := request["adapter_contract"].(map[string]any)
|
||||
if adapter["adapter_may_select_endpoint"] != false || adapter["adapter_may_use_legacy_relay"] != false {
|
||||
t.Fatalf("vpn adapter must not own transport decisions: %#v", adapter)
|
||||
}
|
||||
routeBundle, ok := session["fabric_route_bundle"].(map[string]any)
|
||||
if !ok || routeBundle["legacy_visibility"] != "opaque_to_service_adapters" {
|
||||
t.Fatalf("missing opaque route bundle: %#v", session["fabric_route_bundle"])
|
||||
}
|
||||
routeLease, ok := routeBundle["route_lease"].(map[string]any)
|
||||
if !ok || routeLease["schema_version"] != "rap.fabric_route_lease.v1" || routeLease["service_visibility"] != "opaque_route_lease" {
|
||||
t.Fatalf("missing route lease: %#v", routeBundle["route_lease"])
|
||||
}
|
||||
rebuildPolicy := routeLease["rebuild_policy"].(map[string]any)
|
||||
if rebuildPolicy["owner"] != "fabric_farm" || rebuildPolicy["service_adapter_action"] != "keep_sending_packets_to_channel" {
|
||||
t.Fatalf("unexpected route lease rebuild policy: %#v", rebuildPolicy)
|
||||
}
|
||||
if session["entry_node_id"] != "entry-1" || session["exit_node_id"] != "exit-1" {
|
||||
t.Fatalf("unexpected dataplane session route: %#v", session)
|
||||
}
|
||||
@@ -920,6 +1078,88 @@ func TestNodeUpdatePlanSelectsMatchingReleaseArtifact(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeUpdatePlanIncludesQuorumAuthorityWhenConfigured(t *testing.T) {
|
||||
keys, err := clusterauth.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
store := &fakeRepository{
|
||||
platformRole: PlatformRoleAdmin,
|
||||
clusterAuthority: ClusterAuthorityKey{
|
||||
ClusterAuthorityDescriptor: ClusterAuthorityDescriptor{
|
||||
SchemaVersion: clusterauth.AuthoritySchemaVersion,
|
||||
ClusterID: "cluster-1",
|
||||
AuthorityState: "active",
|
||||
KeyAlgorithm: clusterauth.AlgorithmEd25519,
|
||||
PublicKey: keys.PublicKeyB64,
|
||||
PublicKeyFingerprint: keys.Fingerprint,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
},
|
||||
PrivateKey: keys.PrivateKeyB64,
|
||||
QuorumDescriptor: &QuorumDescriptor{
|
||||
SchemaVersion: clusterauth.QuorumSchemaVersion,
|
||||
ClusterID: "cluster-1",
|
||||
Epoch: "epoch-1",
|
||||
Threshold: 1,
|
||||
Members: []clusterauth.QuorumMember{
|
||||
{
|
||||
NodeID: "authority-1",
|
||||
Role: "update-authority",
|
||||
PublicKey: keys.PublicKeyB64,
|
||||
PublicKeyFingerprint: keys.Fingerprint,
|
||||
Scopes: []string{"update-authority"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
releaseVersions: []ReleaseVersion{
|
||||
{
|
||||
ID: "release-1",
|
||||
ClusterID: "cluster-1",
|
||||
Product: "rap-node-agent",
|
||||
Version: "0.1.0-c17z26",
|
||||
Channel: "dev",
|
||||
Status: "active",
|
||||
Artifacts: []ReleaseArtifact{
|
||||
{ID: "docker", ClusterID: "cluster-1", Product: "rap-node-agent", Version: "0.1.0-c17z26", OS: "linux", Arch: "amd64", InstallType: "docker", Kind: "docker_image_tar", URL: "https://cache/agent.tar", SHA256: "docker-sha"},
|
||||
},
|
||||
},
|
||||
},
|
||||
nodeUpdatePolicies: map[string]NodeUpdatePolicy{
|
||||
"node-1|rap-node-agent": {
|
||||
ClusterID: "cluster-1",
|
||||
NodeID: "node-1",
|
||||
Product: "rap-node-agent",
|
||||
Channel: "dev",
|
||||
Strategy: "manual",
|
||||
Enabled: true,
|
||||
RollbackAllowed: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
service := NewService(store)
|
||||
|
||||
plan, err := service.GetNodeUpdatePlan(context.Background(), GetNodeUpdatePlanInput{
|
||||
ClusterID: "cluster-1",
|
||||
NodeID: "node-1",
|
||||
Product: "rap-node-agent",
|
||||
CurrentVersion: "0.1.0-c17z25",
|
||||
OS: "linux",
|
||||
Arch: "amd64",
|
||||
InstallType: "docker",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update plan: %v", err)
|
||||
}
|
||||
if plan.AuthorityQuorum == nil {
|
||||
t.Fatalf("update plan must include quorum envelope: %+v", plan)
|
||||
}
|
||||
if err := clusterauth.VerifyQuorumRaw(*store.clusterAuthority.QuorumDescriptor, plan.AuthorityPayload, *plan.AuthorityQuorum, "update-authority"); err != nil {
|
||||
t.Fatalf("verify quorum authority: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeUpdatePlanAbsolutizesRelativeArtifactURLs(t *testing.T) {
|
||||
store := &fakeRepository{
|
||||
platformRole: PlatformRoleAdmin,
|
||||
@@ -1914,6 +2154,7 @@ func TestGetNodeSyntheticMeshConfigScopesPrivateBootstrapPeersForOutboundOnlyNod
|
||||
clusterNodes: []ClusterNode{
|
||||
{ID: "node-local", RegistrationStatus: NodeRegistrationActive, HealthStatus: "healthy", MembershipStatus: "active", CreatedAt: now.Add(-2 * time.Hour), LastSeenAt: ptrTime(now)},
|
||||
{ID: "node-peer", RegistrationStatus: NodeRegistrationActive, HealthStatus: "healthy", MembershipStatus: "active", CreatedAt: now.Add(-time.Hour), LastSeenAt: ptrTime(now.Add(-time.Second))},
|
||||
{ID: "node-relay", RegistrationStatus: NodeRegistrationActive, HealthStatus: "healthy", MembershipStatus: "active", CreatedAt: now.Add(-90 * time.Minute), LastSeenAt: ptrTime(now.Add(-2 * time.Second))},
|
||||
},
|
||||
nodeRoles: map[string][]NodeRoleAssignment{
|
||||
"node-local": {{NodeID: "node-local", Role: "core-mesh", Status: "active"}},
|
||||
@@ -1954,8 +2195,8 @@ func TestGetNodeSyntheticMeshConfigScopesPrivateBootstrapPeersForOutboundOnlyNod
|
||||
"endpoint_candidates": [{
|
||||
"endpoint_id": "node-peer-lan",
|
||||
"node_id": "node-peer",
|
||||
"transport": "direct_http",
|
||||
"address": "http://192.168.200.61:19133",
|
||||
"transport": "direct_quic",
|
||||
"address": "quic://192.168.200.61:19133",
|
||||
"reachability": "private",
|
||||
"connectivity_mode": "private_lan",
|
||||
"priority": 1
|
||||
@@ -1963,6 +2204,30 @@ func TestGetNodeSyntheticMeshConfigScopesPrivateBootstrapPeersForOutboundOnlyNod
|
||||
}
|
||||
}`),
|
||||
}},
|
||||
"node-relay": {{
|
||||
ClusterID: "cluster-1",
|
||||
NodeID: "node-relay",
|
||||
ObservedAt: now,
|
||||
Metadata: json.RawMessage(`{
|
||||
"mesh_endpoint_report": {
|
||||
"cluster_id": "cluster-1",
|
||||
"node_id": "node-relay",
|
||||
"peer_endpoint": "quic://relay.example.test:19131",
|
||||
"transport": "direct_quic",
|
||||
"connectivity_mode": "direct",
|
||||
"region": "public",
|
||||
"endpoint_candidates": [{
|
||||
"endpoint_id": "node-relay-public",
|
||||
"node_id": "node-relay",
|
||||
"transport": "direct_quic",
|
||||
"address": "quic://relay.example.test:19131",
|
||||
"reachability": "public",
|
||||
"connectivity_mode": "direct",
|
||||
"priority": 1
|
||||
}]
|
||||
}
|
||||
}`),
|
||||
}},
|
||||
},
|
||||
})
|
||||
service.now = func() time.Time { return now }
|
||||
@@ -1982,7 +2247,7 @@ func TestGetNodeSyntheticMeshConfigScopesPrivateBootstrapPeersForOutboundOnlyNod
|
||||
t.Fatalf("peer candidates = %+v, want relay-required candidate", cfg.PeerEndpointCandidates)
|
||||
}
|
||||
candidate := candidates[0]
|
||||
if candidate.Transport != "relay" || candidate.Reachability != "relay" || candidate.ConnectivityMode != "relay_required" {
|
||||
if candidate.Transport != "relay_quic" || candidate.Reachability != "relay" || candidate.ConnectivityMode != "relay_required" {
|
||||
t.Fatalf("candidate not converted to relay required: %+v", candidate)
|
||||
}
|
||||
if !containsString(candidate.PolicyTags, "offsite-private-lan-blocked") || !containsString(candidate.PolicyTags, "relay-required") {
|
||||
@@ -2002,10 +2267,10 @@ func TestGetNodeSyntheticMeshConfigScopesPrivateBootstrapPeersForOutboundOnlyNod
|
||||
}
|
||||
lease := cfg.RendezvousLeases[0]
|
||||
if lease.PeerNodeID != "node-peer" ||
|
||||
lease.RelayNodeID != "control-plane-relay" ||
|
||||
lease.RelayEndpoint != "https://control.example.test" ||
|
||||
lease.RelayNodeID != "node-relay" ||
|
||||
lease.RelayEndpoint != "quic://relay.example.test:19131" ||
|
||||
lease.Transport != "relay_control" ||
|
||||
lease.Reason != "control_plane_bootstrap_relay" ||
|
||||
lease.Reason != "farm_mesh_bootstrap_relay" ||
|
||||
!lease.ControlPlaneOnly {
|
||||
t.Fatalf("unexpected bootstrap rendezvous lease: %+v", lease)
|
||||
}
|
||||
@@ -2395,6 +2660,206 @@ func TestGetNodeSyntheticMeshConfigAppliesReplacementPathHintForExit(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutePathDecisionUsesRendezvousLeaseForPassiveNATRoute(t *testing.T) {
|
||||
now := time.Date(2026, 5, 17, 3, 45, 0, 0, time.UTC)
|
||||
route := SyntheticMeshRouteConfig{
|
||||
RouteID: "route-a-b",
|
||||
ClusterID: "cluster-1",
|
||||
SourceNodeID: "node-a",
|
||||
DestinationNodeID: "node-b",
|
||||
Hops: []string{"node-a", "node-b"},
|
||||
AllowedChannels: []string{"fabric_control", "route_control"},
|
||||
ExpiresAt: now.Add(time.Hour),
|
||||
}
|
||||
decision := routePathDecisionForRoute(route, "node-a", []PeerRendezvousLease{{
|
||||
LeaseID: "route-a-b-rv-node-b-via-node-r",
|
||||
PeerNodeID: "node-b",
|
||||
RelayNodeID: "node-r",
|
||||
RelayEndpoint: "quic://node-r.example.test:19443",
|
||||
Transport: "relay_control",
|
||||
ConnectivityMode: "relay_required",
|
||||
RouteIDs: []string{"route-a-b"},
|
||||
Priority: 10,
|
||||
ControlPlaneOnly: true,
|
||||
IssuedAt: now,
|
||||
ExpiresAt: now.Add(time.Hour),
|
||||
Reason: "auto_rendezvous_required",
|
||||
}}, newRendezvousRelayPolicy("node-a", nil, now), "generation-1", fabricServiceChannelRouteFeedback{})
|
||||
|
||||
if decision.DecisionSource != "rendezvous_relay_required" ||
|
||||
decision.SelectedRelayID != "node-r" ||
|
||||
decision.SelectedRelayEndpoint != "quic://node-r.example.test:19443" ||
|
||||
decision.RendezvousPeerNodeID != "node-b" ||
|
||||
decision.RendezvousLeaseID != "route-a-b-rv-node-b-via-node-r" ||
|
||||
decision.RendezvousLeaseReason != "auto_rendezvous_required" ||
|
||||
decision.NextHopID != "node-r" ||
|
||||
decision.LocalRole != "entry" ||
|
||||
strings.Join(decision.EffectiveHops, ",") != "node-a,node-r,node-b" ||
|
||||
!decision.ControlPlaneOnly ||
|
||||
decision.ProductionForwarding {
|
||||
t.Fatalf("unexpected rendezvous route path decision: %+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedRendezvousLeasesKeepsOperatorPassiveNATLeaseWhenRelayFeedbackIsStale(t *testing.T) {
|
||||
now := time.Date(2026, 5, 17, 5, 15, 0, 0, time.UTC)
|
||||
route := SyntheticMeshRouteConfig{
|
||||
RouteID: "route-a-b",
|
||||
ClusterID: "cluster-1",
|
||||
SourceNodeID: "node-a",
|
||||
DestinationNodeID: "node-b",
|
||||
Hops: []string{"node-a", "node-b"},
|
||||
AllowedChannels: []string{"fabric_control", "route_control"},
|
||||
ExpiresAt: now.Add(time.Hour),
|
||||
}
|
||||
lease := PeerRendezvousLease{
|
||||
LeaseID: "route-a-b-rv-node-b-via-node-r",
|
||||
PeerNodeID: "node-b",
|
||||
RelayNodeID: "node-r",
|
||||
RelayEndpoint: "quic://node-r.example.test:19443",
|
||||
Transport: "relay_control",
|
||||
ConnectivityMode: "relay_required",
|
||||
RouteIDs: []string{"route-a-b"},
|
||||
Priority: 10,
|
||||
ControlPlaneOnly: true,
|
||||
IssuedAt: now,
|
||||
ExpiresAt: now.Add(time.Hour),
|
||||
Reason: "operator_rendezvous_required_for_passive_nat",
|
||||
}
|
||||
relayPolicy := newRendezvousRelayPolicy("node-a", nil, now)
|
||||
relayPolicy.addFeedback([]rendezvousRelayFeedbackEntry{{
|
||||
RouteIDs: []string{"route-a-b"},
|
||||
PeerNodeID: "node-b",
|
||||
RelayNodeID: "node-r",
|
||||
LeaseID: "route-a-b-rv-node-b-via-node-r",
|
||||
ReporterNodeID: "node-a",
|
||||
}})
|
||||
|
||||
leases := scopedRendezvousLeases([]PeerRendezvousLease{lease}, route, "node-a", relayPolicy, now)
|
||||
if len(leases) != 1 || leases[0].LeaseID != lease.LeaseID {
|
||||
t.Fatalf("operator passive NAT lease must remain scoped despite stale feedback: %+v", leases)
|
||||
}
|
||||
if report := relayPolicy.report(); report != nil && report.WithdrawnLeaseCount != 0 {
|
||||
t.Fatalf("operator passive NAT lease must not be withdrawn: %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedRendezvousLeaseCanSelectRelayOutsideOriginalPath(t *testing.T) {
|
||||
now := time.Date(2026, 5, 17, 4, 30, 0, 0, time.UTC)
|
||||
route := SyntheticMeshRouteConfig{
|
||||
RouteID: "route-a-b",
|
||||
ClusterID: "cluster-1",
|
||||
SourceNodeID: "node-a",
|
||||
DestinationNodeID: "node-b",
|
||||
Hops: []string{"node-a", "node-b"},
|
||||
AllowedChannels: []string{"fabric_control", "route_control"},
|
||||
ExpiresAt: now.Add(time.Hour),
|
||||
}
|
||||
leases := derivedRendezvousLeases(route, map[string]string{}, map[string][]PeerEndpointCandidate{
|
||||
"node-b": {
|
||||
{
|
||||
EndpointID: "node-b-private",
|
||||
NodeID: "node-b",
|
||||
Transport: "direct_quic",
|
||||
Address: "quic://10.10.10.20:19131",
|
||||
Reachability: "private",
|
||||
ConnectivityMode: "private_lan",
|
||||
Region: "remote-lan",
|
||||
Priority: 5,
|
||||
LastVerifiedAt: &now,
|
||||
},
|
||||
},
|
||||
"node-r": {
|
||||
{
|
||||
EndpointID: "node-r-public",
|
||||
NodeID: "node-r",
|
||||
Transport: "direct_quic",
|
||||
Address: "quic://203.0.113.10:19131",
|
||||
Reachability: "public",
|
||||
ConnectivityMode: "direct",
|
||||
Region: "internet",
|
||||
Priority: 10,
|
||||
PolicyTags: []string{"fast-path"},
|
||||
LastVerifiedAt: &now,
|
||||
},
|
||||
},
|
||||
}, "node-a", endpointPerspective{Region: "home-lan"}, newRendezvousRelayPolicy("node-a", nil, now), now)
|
||||
|
||||
if len(leases) != 1 ||
|
||||
leases[0].PeerNodeID != "node-b" ||
|
||||
leases[0].RelayNodeID != "node-r" ||
|
||||
leases[0].RelayEndpoint != "quic://203.0.113.10:19131" ||
|
||||
leases[0].Reason != "auto_rendezvous_required" {
|
||||
t.Fatalf("unexpected derived rendezvous leases: %+v", leases)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNodeSyntheticMeshConfigIncludesRendezvousRelayOutsideOriginalHops(t *testing.T) {
|
||||
now := time.Date(2026, 5, 17, 4, 15, 0, 0, time.UTC)
|
||||
service := NewService(&fakeRepository{
|
||||
testingFlags: EffectiveNodeTestingFlags{
|
||||
Enabled: true,
|
||||
SyntheticLinksEnabled: true,
|
||||
},
|
||||
routeIntents: []MeshRouteIntent{
|
||||
{
|
||||
ID: "route-a-b",
|
||||
ClusterID: "cluster-1",
|
||||
SourceSelector: json.RawMessage(`{"node_id":"node-a"}`),
|
||||
DestinationSelector: json.RawMessage(`{"node_id":"node-b"}`),
|
||||
ServiceClass: "vpn_packets",
|
||||
Status: "active",
|
||||
Policy: json.RawMessage(`{
|
||||
"synthetic_enabled": true,
|
||||
"hops": ["node-a", "node-b"],
|
||||
"allowed_channels": ["fabric_control", "route_control"],
|
||||
"expires_at": "2026-05-17T05:15:00Z",
|
||||
"rendezvous_leases": [
|
||||
{
|
||||
"lease_id": "route-a-b-rv-node-b-via-node-r",
|
||||
"peer_node_id": "node-b",
|
||||
"relay_node_id": "node-r",
|
||||
"relay_endpoint": "quic://node-r.example.test:19443",
|
||||
"transport": "relay_control",
|
||||
"connectivity_mode": "relay_required",
|
||||
"route_ids": ["route-a-b"],
|
||||
"allowed_channels": ["fabric_control", "route_control"],
|
||||
"priority": 10,
|
||||
"control_plane_only": true,
|
||||
"expires_at": "2026-05-17T05:15:00Z",
|
||||
"reason": "auto_rendezvous_required"
|
||||
}
|
||||
]
|
||||
}`),
|
||||
UpdatedAt: now,
|
||||
},
|
||||
},
|
||||
})
|
||||
service.now = func() time.Time { return now }
|
||||
|
||||
cfg, err := service.GetNodeSyntheticMeshConfig(context.Background(), GetNodeSyntheticMeshConfigInput{
|
||||
ClusterID: "cluster-1",
|
||||
NodeID: "node-r",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get synthetic config: %v", err)
|
||||
}
|
||||
if len(cfg.Routes) != 1 || strings.Join(cfg.Routes[0].Hops, ",") != "node-a,node-r,node-b" {
|
||||
t.Fatalf("relay scoped route missing effective hops: %+v", cfg.Routes)
|
||||
}
|
||||
if cfg.RoutePathDecisions == nil || len(cfg.RoutePathDecisions.Decisions) != 1 {
|
||||
t.Fatalf("relay route path decision missing: %+v", cfg.RoutePathDecisions)
|
||||
}
|
||||
decision := cfg.RoutePathDecisions.Decisions[0]
|
||||
if decision.SelectedRelayID != "node-r" ||
|
||||
decision.LocalRole != "selected_relay" ||
|
||||
decision.PreviousHopID != "node-a" ||
|
||||
decision.NextHopID != "node-b" ||
|
||||
strings.Join(decision.EffectiveHops, ",") != "node-a,node-r,node-b" {
|
||||
t.Fatalf("unexpected relay scoped decision: %+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNodeSyntheticMeshConfigUsesRouteHealthDriftToReselectRelay(t *testing.T) {
|
||||
now := time.Date(2026, 4, 28, 12, 30, 0, 0, time.UTC)
|
||||
routeHealthMetadata, err := json.Marshal(map[string]any{
|
||||
|
||||
Reference in New Issue
Block a user